Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
node_modules
.git
dist
build
__pycache__
**/__pycache__
.pytest_cache
.ruff_cache
.venv
backend/.venv
*.db
appops.db
coverage
logs
data
.env
.env.*
!/.env.example
+3
View File
@@ -0,0 +1,3 @@
APPOPS_OPERATOR_TOKEN=
APPOPS_SECRET_KEY=
# Generate both independently: python -c "import secrets; print(secrets.token_urlsafe(48))"
+6
View File
@@ -0,0 +1,6 @@
*.sh text eol=lf
Dockerfile text eol=lf
docker/Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
ops/gitea-deploy-broker/*.py text eol=lf
+40
View File
@@ -0,0 +1,40 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Environment
.env
.env.local
# Python/backend artifacts
*.pyc
__pycache__/
backend/.venv/
backend/.env
ops/gitea-deploy-broker/private-tls.json
*.db
*.db-shm
*.db-wal
appops-gateway.cdx.json
/data/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13
View File
@@ -0,0 +1,13 @@
# Reviewed noncredential schema declaration and deterministic test inputs only.
[extend]
useDefault = true
[[allowlists]]
description = "Pydantic enrollment secrets container declaration, no secret value"
paths = ['backend/app/schemas/enrollment\.py$']
regexTarget = "line"
regexes = ['secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1\(\)']
[[allowlists]]
description = "Synthetic noncredential test encryption key and idempotency/reason fixtures"
paths = ['backend/tests/test_(repopulse_adoption_runner|deployment_control_plane|enrollment_plane|mcp_rag_n8n_integrations)\.py$']
regexTarget = "line"
regexes = ['appops-test-key-with-at-least-32-bytes', 'apply-wsbc-(direct|evidence)-0001', 'enroll-wsbc-(interrupted|compensate)-0001', 'N8N-REASON-SYNTHETIC']
+313
View File
@@ -0,0 +1,313 @@
# Changelog
## Unreleased
### Added
- A root-only local `--revalidate-delivery` controller operation can create one
additive, fresh exact-SHA validation attempt for an immutable
`validation_failed` or `validation_timeout` webhook record. It preserves the
original audit bytes, refuses other terminal states and changed policy or
branch head, exposes no HTTP mutation route, and rechecks exact branch head
immediately before deployment.
### Fixed
- The fixed two-runner Gitea Actions fleet now has a guarded, rollbackable
in-place upgrade from the affected 2.0.0 runner baseline to the immutable
3.5.0 image. Future fleet bootstrap also uses that reviewed pin; the upgrade
preserves separate runner credentials, labels and private-DinD topology and
recreates no production application, Gitea or DinD container.
- Gitea Actions scheduler recovery now proves it has a complete, bounded runner
inventory and fails closed on malformed, truncated or duplicate fixed-runner
state. Any disabled or busy registration vetoes recovery of that runner.
- Gitea Actions fleet recovery now treats the fixed private `gitea-runner-dind`
daemon as a bounded recovery target, persists `unless-stopped`, counts
start/restart attempts against one recovery budget and avoids querying DinD
networks while the daemon is down.
- A controller restart while a durable webhook delivery is `deploying` now
reconciles the exact repository/revision receipt. A verified receipt closes
the delivery as `deployed`; missing proof closes it as
`deployment_interrupted` without starting an unsafe duplicate deploy.
- Deployment-controller, enrollment and reconciliation writers now serialize
exclusively on the physical cache lock. This prevents an Unraid `shfs`
alias from making one process lock the same inode twice and deadlock itself.
- Controller promotion stops the old writer before acquiring the cache lock,
then holds that lock throughout backup, state reconciliation, controller
replacement and health verification.
## 1.8.0 - 2026-09-14
### Added
- Capability-based CI runner fleet telemetry, queue explanations and the
AppOps Runner Fleet view. The view joins Gitea scheduler facts with a
private, read-only controller projection; no Docker socket or token reaches
AppOps.
- A root-owned, fixed-identity CI fleet service with health, storage and
address-pool telemetry, bounded stale Gitea-task-network collection and a
three-attempt recovery budget per runner over 30 minutes.
- A rolling runner-fleet promotion procedure: two single-slot general
validation runners, separate workspaces, a private DinD daemon and a
`198.19.0.0/16` pool allocated as `/27` task networks (2,048 possible
networks).
### Changed
- Managed validation now asks for `linux-validation`; `unraid-deploy` is no
longer configured as an AppOps CI restart/deployment target.
- Automatic cache cleanup is restricted to dangling or expired cache inside
the dedicated CI daemon and only under its critical disk watermark. Generic
host Docker, network and volume prunes remain forbidden.
- The root-managed 30-minute exact-SHA validation window explicitly covers
scheduler queueing on the bounded two-slot CI fleet. A terminal delivery
record remains immutable; recovery always starts with a new protected
revision rather than bypassing the gate.
### Security
- CI code retains no production Docker socket or deployment credential. Safe
cleanup accepts only empty, older, unlabelled Gitea task networks in the
dedicated CI daemon; unknown, attached and production resources fail closed.
## 1.7.0 - 2026-09-14
### Added
- Runnerless Validated Delivery: signed Gitea main pushes are durably queued,
await explicit exact-SHA push validation, and call the existing receipt-bound
controller only after every required context succeeds.
- Durable `awaiting_validation`, validation failure/timeout and superseded
delivery states, bounded Gitea status polling, crash-safe resume and a
secret-free Delivery Reliability view in AppOps.
- Root-owned validation-token/config checks, controller backup manifests and
delivery inspection tooling.
### Changed
- Removed AppOps' `runs-on: unraid-deploy` production workflow. Managed
validation remains an ordinary `linux-validation` CI job and now runs for
protected-main pushes as well as pull requests.
### Security
- The validation credential stays controller-side, read-only and mode `0600`.
Exact repository identity, branch, SHA, HMAC and receipt checks remain
authoritative; missing policy/status/API reachability never deploys open.
## 1.6.0 - 2026-09-14
### Added
- Enrollment & Provisioning Plane v1 with strict `EnrollmentPlanV1`, trusted
server-side profiles, durable enrollment jobs, machine-readable readiness,
typed operator APIs and typed MCP tools.
- Controller bootstrap that registers one validated target through HMAC, creates
its bounded appdata layout and per-target credential, and seals only
profile-owned runtime values.
- Exact-target `deployment.apply` and `deployment.rollback` policies with
enrollment provenance; policy defaults remain deny-by-default.
### Security
- New enrollments refuse container, hostname, Authentik and storage takeover;
no arbitrary Compose, environment, callback, Docker, Portainer-exec or shell
capability was added.
- Enrollment credentials and OIDC client secrets are generated server-side,
encrypted at rest, write-only, and excluded from evidence, UI and MCP.
- Failed enrollment compensates only resources that the same execution proved
it created; incomplete compensation stays explicitly visible.
- Controller HMAC requests now bind exact method and endpoint, use a
single-use durable nonce, and fail closed for the legacy request format.
- Enrollment rejects the development secret-store fallback, mismatched OIDC
role claims/models, unsafe controller registries and unverified NPM ownership
markers.
- Changed local fallback operator authentication from reload-fragile frontend memory to a revocable, process-local HttpOnly browser session. Raw operator keys are exchanged once, never stored in JavaScript/browser storage, and cookie mutations require an explicit CSRF header.
- Fixed managed deployment provenance by forwarding the broker's exact revision, repository, tree,
build ID and build time into immutable OCI image labels while retaining the application version
in a separate vendor label.
## 1.5.2 - 2026-08-27
- Measure active Gitea Actions age from `started_at`, so an old queued job that
just acquired a runner is no longer reported as a stale active execution.
## 1.5.1 - 2026-08-27
### Fixed
- Canonical Compose now binds the persistent `/data/secret-key` through
`APPOPS_SECRET_KEY_FILE`, keeping encrypted secret storage production-ready
after the isolated data-mount migration.
- The MCP handshake now derives its advertised version from the same central
application version used by the API and runtime status.
## 1.5.0 - 2026-08-27
### Added
- Cached, read-only Gitea Actions runner and queue observability in the Overview, private API and MCP surface.
- Explicit runner-label-to-container guidance for stalled queues; restart remains an approval-gated Command Center action and never executes automatically.
### Changed
- SBOM generation now uses pnpm 11's native CycloneDX 1.6 command, removing the incompatible npm-specific generator and its transitive dependency tree.
- Canonical Compose now mounts the dedicated `APPOPS_DATA_PATH` instead of using the Git checkout as `/data`; Gitea observer configuration references only the mounted token file.
### Security
- Gitea admin credentials support mounted secret files and are never returned by status, UI or MCP responses.
- Fixed Gitea admin endpoints, bounded job samples and a cache that clients cannot bypass prevent generic admin access and polling amplification.
## Unreleased - 2026-08-11
### Added
- Governed MCP server operations: ChatGPT can request an exact typed action, follow approval/job state, execute only an operator-approved fingerprint and cancel a queued/running job record.
- Server-side execution concurrency is capped at one operation; work and post-checks run in AppOps rather than in the browser.
- Private MCP 2.0 Streamable HTTP server with authenticated read-only AppOps tools and a capability resource that explicitly excludes mutation, shell and Docker socket access.
- Persistent local, extractive RAG retrieval with citations across allowlisted documentation and redacted live AppOps context; AI Chat and MCP share the retrieval service.
- Dedicated n8n machine authentication plus cursor events, policy preflight, idempotent approval requests and approval/job status reads. No n8n execution endpoint is exposed.
- n8n can query the shared local RAG service with citations through its dedicated machine-authenticated integration path.
- An `/integrations` cockpit for readiness, endpoint discovery, RAG indexing and cited query verification.
### Security
- Every MCP mutation requires a separate expiring human approval even when the ordinary manual policy would allow it. MCP cannot approve requests, change the approved context, invoke an unknown action, open a shell or access the Docker socket.
- MCP, n8n, operator and voice identities remain separate. Production bootstraps distinct mode-`0600` MCP and n8n token files and never exposes their values in the UI or status API.
- Hardened the private machine interfaces with recursive cross-boundary redaction, pre-parse body limits, n8n middleware authentication and mutation throttling, SQL-bounded event cursors, serialized idempotency and bounded reservation retention.
- Destructive action defaults remain authoritative; automation can request governance but cannot approve or execute actions.
### Fixed
- The integrations cockpit no longer reports healthy MCP/n8n services as stopped or RAG as empty when an operator tab is locked; unavailable state is explicit and protected controls remain disabled until re-authentication.
- Unraid parity monitoring now uses the live `array.parityCheckStatus` and `array.parities` GraphQL fields instead of unsupported `parityStatus`/`system` queries.
- The parity endpoint distinguishes an active or paused check from healthy and unhealthy parity disks and exposes progress, speed, correction mode and sync-error metadata.
- The Unraid connector safely follows the appliance's same-host HTTP-to-HTTPS API redirect while retaining the API key; redirects to other hosts or non-HTTPS targets are refused.
## 1.3.0 - 2026-07-29
### Added
- Private operator authentication for all mutations and sensitive administrative reads, with an in-memory-only frontend unlock session.
- Persistent server-side operator-token bootstrap through a mode-`0600` file in `/data`, avoiding any workstation runtime dependency.
- Approval fingerprints and actor attribution, centralized audit redaction, connector URL SSRF controls and strict high-risk request contracts.
- Backup checksums, retention, free-space protection and disposable restore verification.
- CI gates for frontend/backend tests, safety validation, container smoke testing and a CycloneDX 1.6 SBOM artifact.
### Changed
- TLS verification is secure by default and production secret/token strength is enforced.
- Mutation requests have bounded bodies and rate limiting; production images require operator authentication.
- Responsive dashboard layouts, dependency compatibility and repository hygiene were hardened.
### Security
- The private deployment model explicitly separates operator and voice credentials and keeps destructive actions blocked by policy.
- Tracked test databases and the duplicate Dockerfile were removed; the root Dockerfile is canonical.
### Verified
- Backend regression suite: `381 passed`; 28 remaining warnings are deprecated timestamp calls in test fixtures.
- Frontend type-check, lint, all `243` tests and the Vite production build are green.
- Ruff, dependency audits, diff validation, secret-pattern scanning, CycloneDX SBOM generation and private safety validation are green.
- Docker image verification remains delegated to CI/a Docker-capable private host because Docker is unavailable on the current workstation.
## Unreleased - 2026-07-19
### Added
- Guarded Unraid VM start/resume and approval-aware pause with live state prechecks, JobRuns, post-checks and Audit; destructive stop/reboot/reset/force-stop remain blocked.
- Live Home Assistant/Gemini Assist readiness for API reachability, installed AppOps scripts and loaded conversation components.
- Read-only re-verification and `no-store` download of existing SQLite backups.
### Changed
- Authentik outpost status now distinguishes API-reported health from credential-free reachability and never forwards the Authentik bearer token to the configured outpost origin.
- Live audit copy now distinguishes saved Voice bridge configuration from actual Home Assistant reachability, and the route smoke explicitly covers the VM manager surface.
- Vite and the ESLint toolchain now use patched compatible releases; the remaining transitive `flatted` advisory is resolved with a scoped workspace override, and pnpm is pinned for reproducible local/container builds.
- The production container is now presented as `Unraid-AppOps-Gateway`, with a stable hostname, OCI metadata, Unraid WebUI/icon labels and matching SVG/PNG/ICO/PWA assets. The existing `/srv/example/appdata/unraid-appops-gateway-1216` data path remains unchanged.
- Obsolete admin-token examples and the unused browser voice-token bridge are removed; `APP_OPS_VOICE_TOKEN` remains server-side and is only supplied by Home Assistant to protected voice endpoints.
- Patched compatible transitive versions are pinned for the dependency-audit findings in `lodash`, `glob`, `minimatch`, `brace-expansion`, `picomatch`, `postcss` and `yaml`.
- Backend security pins move to FastAPI 0.139.0/Starlette 1.3.1, cryptography 48.0.1 and pytest 9.0.3 to clear the Python dependency audit.
- Active setup, deployment, API and media-pipeline documentation now consistently describes the live-only frontend instead of the removed demo/mock runtime.
- Canonical upgrade post-checks allow 30 seconds for the one-time cold migration read, preventing a false rollback while an oversized legacy inventory is being pruned.
- The one-time container migration strips obsolete admin/browser token variables while preserving all current connector, Unraid, Portainer and Home Assistant voice configuration.
- Upgrade post-check failures now propagate correctly through Bash conditionals and retry three times instead of logging a timed-out endpoint as successful.
- Production discovery retains detailed rows for the newest 48 successful runs. Unchanged missing containers are recorded once instead of being copied into every run, preventing unbounded SQLite growth and multi-second setup/runtime reads.
- Synchronous runtime and setup status aggregation now runs in FastAPI's worker pool instead of blocking the async event loop during cold inventory reads.
- The alternate Dockerfile now defaults the frontend to live API mode, matching the root production Dockerfile. Compose requires externally supplied voice and encryption keys instead of runnable placeholder secrets.
- Inventory discovery reconciles existing same-name/same-app mappings when Docker recreates a container with a new ID, removes duplicate stale mappings and writes a metadata-only audit event. Genuine unknown containers remain in review.
- The latest-discovery cache now uses weak Session object keys instead of reusable numeric object IDs, preventing detached ORM rows from leaking into later requests.
- Launchpad and Media Codec Dashboard receive evidence-based classifications from their live names/images instead of remaining generic unknowns.
- Action Registry renders 30 rows per page with filter-aware pagination instead of mounting all 181 actions at once.
- Exposure rows now include concrete risk evidence such as missing Authentik/access-list coverage, missing certificates and non-forced SSL; high-risk rows sort first in the UI.
- Inventory change detection canonicalizes Portainer port, mount and network ordering, so identical runtime snapshots no longer reopen reviewed apps as false drift. Historical missing containers remain visible in the ledger but are no longer presented as bulk import/edit work.
- Setup status now coalesces discovery history by current container name, preventing recreated Docker IDs from inflating discovered/review counts. `needsReview` reports the actual current workload while `reviewBlockers` separately indicates whether that workload still blocks first-run completion.
- Completed setup labels the accepted first-run review step as `Reviewbaseline vastgelegd`; later/current review workload is shown separately as `open inventory review`.
- Apps without a registered AppOps connector now report connector status `none`; Jellyfin no longer advertises missing connector configuration or an unavailable scan executor.
- Action Registry responses now distinguish real Command Center execution, approval execution, dedicated surfaces, governance-only actions and blocked actions.
- Approval decisions use the correct `PATCH` API and a strict persisted state machine. Approved Deluge pause-all/resume-all and Plex scan-all requests can be executed separately through audited JobRuns; unsupported approvals stay non-executable.
- Overview and TopStatusBar use shared React Query caching with independent fail-soft polling intervals, focus refresh and no background-tab polling.
- Inventory review now coalesces recreated Docker container IDs by unique container name, preferring the current non-missing instance while retaining per-run history for audit and drift. The review grid renders 24 cards per page instead of mounting the complete ledger at once.
- Overview action cards use a balanced two-column desktop layout before expanding to four columns on ultrawide screens.
- Overview starts its primary runtime, app, activity, chart and storage snapshot in parallel with secondary Safety/setup/notification intelligence, but no longer blocks first paint on the connector-heavy secondary reads.
- Inventory latest-state and drift fallback queries now use indexed SQLite window functions, the mapping UI loads the compact canonical app catalog, and no-op startup classification avoids full history scans.
- FastAPI database initialization now uses the supported application lifespan hook.
- Backend persistence timestamps now use one Python-forward-compatible UTC helper while preserving existing SQLite semantics; audit listing no longer uses SQLAlchemy's legacy query API.
- Successful canonical upgrades retain the newest 20 timestamped backups and remove only scoped legacy AppOps image tags after health validation.
- Canonical upgrades automatically migrate the former `unraid-appops-gateway-1216` name to `Unraid-AppOps-Gateway`, roll back from the previous immutable image ID and never retain versioned `pre`/`failed` containers. Production uses the stable `unraid-appops-gateway:production` tag with one `rollback` image tag.
- Runtime and OpenAPI version reporting now share release version `1.2.1`.
- Safety refreshes expired persistent snapshots before falling back to stale data.
- Notification evaluation prefers the most specific matching rule and retires exact duplicates plus generic media umbrella overlaps from the active inbox.
- Conservative app classification patterns reduce canonical `Unknown` noise without overriding explicit categories.
- Canonical classification now uses app IDs, mapped container names and discovered images, and normalizes humanized names consistently.
- Historical AppOps rollback registrations and the superseded unqualified deployment record no longer pollute active app, inventory, setup, operations-map, Safety or AI views; history remains stored.
- AI Security's unavailable Media Pipeline fallback now captures and redacts its initialization error safely instead of retaining an expired exception reference.
- The secondary top-bar activity chip now waits for a true wide-desktop viewport, preventing it from colliding with infrastructure status around 1280px.
- AI Security excludes current and rollback AppOps deployment containers from discovery-review findings and invalidates pre-fix cached reviews.
- Managed canonical apps can now be normalized from App Detail through an audited metadata-only editor for name, category and criticality.
- Locally documented custom apps now receive evidence-based categories and protection defaults; canonical `Unknown` coverage reaches zero without generic guessing.
- App category filters are generated from the live catalog, and App Detail derives credential-free Open links from configured connector URLs or mapped published ports.
- Inventory baseline updates now persist the current live ledger, reset stale acknowledgements, drive subsequent drift comparisons and write a real audit event instead of returning a placeholder success.
- Discovery's short-lived latest-container cache is scoped to the active database session so ORM rows cannot leak across requests as detached or stale runtime data.
- The configured Home Assistant notification channel was validated end to end against the live API and a delivered test notification; the existing Home Assistant container now uses `unless-stopped` restart policy.
- Overview formats Unraid boot timestamps as compact uptime durations so Server Health remains readable at five-column desktop widths.
- Inventory loads runs, review records, apps, mappings and drift independently; a slow source now produces a partial warning without clearing successful live ledger data or fabricating an empty-discovery state.
- Large inventory ledger reads use a dedicated 30-second request budget, and the empty-discovery prompt only appears after review records successfully load as empty.
- The read-only container list also uses a 30-second cold-start budget, keeping Inventory's optional runtime comparison from timing out behind simultaneous ledger reads.
- Exposure Center and Containers now progressively enrich canonical live data with optional connector details and Portainer telemetry.
- Exposure Center no longer flashes false `0` or `Unknown` detail metrics while NPM and Authentik enrichment is still loading; canonical exposure counts remain available immediately and optional values are labelled as loading or unavailable.
- Authentik outposts that exist but provide no runtime health field are labelled `Unverified` instead of being overstated as unavailable.
- Overview quick actions use a two-column mobile grid and expand to a wrapped desktop row, preventing clipped controls on narrow screens.
### Verified
- Backend regression suite: `378 passed`; 28 remaining warnings are deprecated timestamp calls in test fixtures.
- Frontend type-check, lint and Vite production build are green on the patched toolchain.
- Ruff, Python compileall, diff validation and tracked secret-pattern scanning are green.
- Python dependency audit reports no known vulnerabilities; pnpm reports no high-severity advisories.
## 1.2.1 - 2026-07-02
### Added
- SQLite-safe online backup flow through `POST /api/admin/backups/sqlite` and Settings > Backups.
- Server-side backup listing for `/data/backups/appops-db-*.db`.
- Backup audit event `runtime.sqlite_backup` with integrity-check metadata and no secret values.
- Root `SECURITY.md` with no-secrets, local-first and exposure guidance.
### Changed
- `stack.redeploy` is documented as approval-only V1 behavior.
- Connector binding lookup now computes latest discovery state once per request, fixing live `/connectors` loading timeouts.
- Legacy AppOps deployment artifacts are filtered from operator inventory lists.
- Current roadmap and implementation status now distinguish active 1216 production status from historical validation notes.
### Removed
- Un-routed legacy AI pages with static/demo controls under `src/pages/ai`.
### Verified
- Live deployment on canonical `http://198.18.10.150:1216`.
- `scripts/validate_live_deployment.ps1` result: `PASS=39 WARN=1 FAIL=0`.
- Production container image: `unraid-appops-gateway:connector-audit-34cc5a6-1216`.
## 1.2.0 - 2026-07-02
### Added
- Live route hardening for AppDetail, Apps, Containers, Stacks and Overview action controls.
- Policy action fallback handling so missing handlers no longer silently no-op.
- Active roadmap source at `docs/ROADMAP_CURRENT.md`.
### Changed
- App lifecycle controls now route to real restart, approval or review flows.
- Read-only or unsupported actions are explicitly disabled in V1.
## Historical Notes
Detailed sprint history, validation snapshots and superseded deployment notes remain in `docs/IMPLEMENTATION_STATUS.md` and `docs/LIVE_VALIDATION_REPORT.md`. Those files are archives unless repeated in `docs/ROADMAP_CURRENT.md` or this changelog.
+73
View File
@@ -0,0 +1,73 @@
FROM node:22-bookworm-slim AS frontend-builder
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY index.html postcss.config.js tailwind.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts components.json ./
COPY public ./public
COPY src ./src
ARG VITE_APPOPS_API_MODE=live
ARG VITE_APPOPS_API_BASE_URL=
ENV VITE_APPOPS_API_MODE=${VITE_APPOPS_API_MODE} \
VITE_APPOPS_API_BASE_URL=${VITE_APPOPS_API_BASE_URL}
RUN pnpm run build
FROM python:3.13-slim AS runtime
ARG APPOPS_BUILD_ID=development
ARG APPOPS_SOURCE_REVISION=development
ARG APPOPS_SOURCE_REPOSITORY=local/unraid-appops-gateway
ARG APPOPS_SOURCE_TREE=development
ARG APPOPS_BUILD_DATE=1970-01-01T00:00:00Z
LABEL org.opencontainers.image.title="Unraid AppOps Gateway" \
org.opencontainers.image.description="Local-first, policy-gated operations gateway for Unraid and connected services" \
org.opencontainers.image.version="${APPOPS_BUILD_ID}" \
org.opencontainers.image.revision="${APPOPS_SOURCE_REVISION}" \
org.opencontainers.image.source="${APPOPS_SOURCE_REPOSITORY}" \
org.opencontainers.image.source-tree="${APPOPS_SOURCE_TREE}" \
org.opencontainers.image.created="${APPOPS_BUILD_DATE}" \
io.itworx.app.version="1.8.0" \
io.itworx.app.operator-auth="http-only-session" \
org.opencontainers.image.vendor="AppOps" \
net.unraid.docker.managed="dockerman" \
net.unraid.docker.webui="http://[IP]:[PORT:1216]/" \
net.unraid.docker.icon="/boot/config/plugins/dockerMan/images/unraid-appops-gateway.png"
ENV APPOPS_ENV=production \
APPOPS_DEPLOYMENT_MODE=private \
APPOPS_REQUIRE_OPERATOR_AUTH=false \
APPOPS_OPERATOR_TOKEN_FILE=/data/operator-token \
APP_OPS_VOICE_TOKEN_FILE=/data/voice-token \
APPOPS_BIND_HOST=0.0.0.0 \
APPOPS_PORT=1216 \
APPOPS_DATA_DIR=/data \
APPOPS_DB_PATH=/data/appops.db \
APPOPS_STATIC_DIR=/app/dist \
APPOPS_LOG_LEVEL=info \
APPOPS_DISCOVERY_HISTORY_RUNS=48 \
APPOPS_ENABLE_MCP=false \
APPOPS_MCP_TOKEN_FILE=/data/mcp-token \
APPOPS_MCP_ALLOWED_HOSTS=localhost:*,127.0.0.1:* \
APPOPS_ENABLE_N8N=false \
APPOPS_N8N_TOKEN_FILE=/data/n8n-token \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN mkdir -p /etc/appops && printf '%s\n' "$APPOPS_SOURCE_REPOSITORY" "$APPOPS_SOURCE_REVISION" "$APPOPS_SOURCE_TREE" > /etc/appops/source-metadata && chmod 0444 /etc/appops/source-metadata
RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/* \
&& addgroup --system appops && adduser --system --ingroup appops appops \
&& adduser appops users \
&& mkdir -p /data \
&& chown -R appops:appops /data /app
COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY backend/app ./backend/app
COPY docs ./docs
COPY README.md CHANGELOG.md ./
COPY --from=frontend-builder /app/dist ./dist
COPY docker/entrypoint.sh /entrypoint.sh
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
EXPOSE 1216
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:1216/api/health', timeout=3).read()"
ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "1216"]
+3
View File
@@ -0,0 +1,3 @@
# Public source export
This source snapshot was generated from the private canonical repository at revision `3bd6f6fee310125bfbb4d4650389a817f498724d`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
# Unraid AppOps Gateway
A self-hosted operations dashboard that brings application discovery, connector health, policy-controlled actions and an auditable activity history together. Designed for operators managing Unraid and Portainer installations.
## Capabilities
- Discover services, review inventory and deliberately bring applications under management.
- Configure Portainer, optional Unraid and application-specific connectors through `/setup`.
- Inspect health, notification rules, diagnostics and SQLite-safe backups.
- Apply typed action policies; blocked destructive operations remain blocked.
- Integrate optional Home Assistant voice commands and separately authenticated MCP/n8n clients.
- Use deployment and enrollment control planes with pre-registered identities and immutable revisions.
The gateway does not require direct Docker socket access and exposes no arbitrary shell endpoint. Connector credentials are write-only through the API and encrypted at rest. Optional integrations require their own endpoints and scoped credentials.
## Local evaluation
The public Compose configuration is isolated from the owner's deployment. Requirements: Docker Engine with Compose.
```bash
cp .env.example .env
# Set APPOPS_OPERATOR_TOKEN and APPOPS_SECRET_KEY to independent random values.
docker compose up --build -d
```
Open `http://localhost:1216/setup`. Keep the operator token and encryption key private. The stack binds only to loopback, persists `./data`, enables operator authentication, and disables MCP/n8n until explicitly configured. It has no production host mounts, proxy networks or broker endpoints. Configure Portainer in the setup wizard; missing optional connectors remain visible as partial coverage.
Before production use, put the application behind a trusted authenticated TLS reverse proxy or VPN. Never expose it directly to the internet. Preserve both `/data` and its encryption key, test restore, and review every connector's permissions. See [Security policy](SECURITY.md).
## Development
React 19, TypeScript, Vite, Tailwind and TanStack Query form the frontend. FastAPI provides the backend and serves the built frontend; SQLite persists state. Python dependencies are pinned in `backend/requirements.txt`.
```bash
corepack enable
pnpm install --frozen-lockfile
pnpm dev
pnpm check-types
pnpm lint
pnpm test:frontend
pnpm build
python -m pip install -r backend/requirements.txt
python -m pytest backend/tests -q
```
The current frontend uses the live backend facade; the historical mock-mode setting is no longer an offline demo switch. Evaluate the full local container with an empty data directory and no live connectors to explore setup safely.
## Publication and licensing
`Unraid-AppOps-Gateway-Public` is a curated snapshot maintained from the private canonical repository. Updates are published automatically after changes merge to canonical `main`; private history, operational evidence and internal Actions are excluded. The public source manifest records the exact source revision.
No project-wide license has been selected or included. Public visibility does not grant an open-source license. Third-party dependencies retain their own terms. Contact the owner before redistributing or reusing the project.
+43
View File
@@ -0,0 +1,43 @@
# Security Policy
## Supported Deployment
The supported production deployment is the canonical single-container instance on port `1216`.
AppOps is designed for trusted homelab/LAN operation behind network controls such as VPN, reverse-proxy authentication or trusted internal access. Do not expose the gateway directly to the public internet.
## No-Secrets Policy
- Do not commit API keys, tokens, passwords, cookies, private keys or production database files.
- Do not paste raw secrets into issues, changelogs, AI prompts, logs or screenshots.
- Connector secrets must be stored through encrypted secret storage or environment/key-file configuration.
- API responses and UI surfaces must show only write-only state such as `hasSecret`, `missing`, `secretRef` or redacted metadata.
- Backups may contain encrypted local state. Protect `/data`, `/data/backups` and the `APPOPS_SECRET_KEY` or `APPOPS_SECRET_KEY_FILE` together.
## Local-First Data Warning
AppOps stores operational state locally in SQLite under `/data/appops.db`. This can include encrypted connector secrets, audit events, inventory metadata, notification events and operator decisions.
SQLite online backups are written to `/data/backups`. They can be integrity-checked and downloaded from Settings, so treat downloaded copies as sensitive local state even though connector secrets remain encrypted. Restore is a stop-container/manual operation in V1. Losing the secret key means encrypted connector secrets in a restored database cannot be decrypted.
## Exposure Guidance
- Keep AppOps behind trusted LAN/VPN/reverse-proxy auth.
- Use TLS at the reverse proxy if accessing outside localhost/LAN.
- Do not mount `/var/run/docker.sock`.
- Do not run the container privileged.
- Do not use host networking for the default deployment.
- Keep destructive actions such as delete, prune, format and secret-show blocked unless a future reviewed policy/approval/audit/rollback design is implemented.
- Enrollment accepts only the reviewed `EnrollmentPlanV1` profiles. It cannot
claim an existing container, hostname, Authentik application, appdata path or
controller target, and it never accepts arbitrary Compose, callbacks,
environment values or controller commands.
- Automatic production delivery accepts only an HMAC-verified Gitea main push
after the controller has independently read the configured exact-SHA,
push-only validation contexts with its root-owned read credential. Webhook
payloads, Actions jobs and UI clients cannot claim validation success or
supply the credential; Gitea API outages fail closed.
## Reporting
Report suspected security issues privately to the project owner/operator. Include the affected route, version, deployment context and redacted reproduction steps. Never include raw secrets in the report.
+3
View File
@@ -0,0 +1,3 @@
from .core.config import Settings
settings = Settings()
+1
View File
@@ -0,0 +1 @@
# API submodule exports
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import Depends
from sqlmodel import Session
from ..db.session import get_session
from ..services.setup_service import apply_stored_connector_settings
def load_connector_runtime(session: Session = Depends(get_session)) -> None:
apply_stored_connector_settings(session)
+510
View File
@@ -0,0 +1,510 @@
from __future__ import annotations
import hashlib
import json
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session, select
from ..db.session import get_session
from ..db.models import App, AppSetting, utc_now
from ..services.audit_logger import log_event
from ..services.container_restart_service import execute_container_restart
from ..services.policy_engine import evaluate_policy
from ..services.action_registry import (
ACTIONS,
ACTION_RISK,
action_blast_radius,
action_capability,
action_category,
action_default_target,
action_label,
action_operator_guidance,
action_safeguards,
)
from ..services.approval_service import create_approval
from ..services.action_execution import action_execution_profile, execute_governed_action
from ..services.preflight_service import build_preflight
router = APIRouter(prefix='/api/actions')
def _idempotency_context(session: Session, key: str | None, payload: dict) -> tuple[AppSetting | None, str | None]:
if not key:
return None, None
normalized = key.strip()
if not normalized or len(normalized) > 128:
raise HTTPException(status_code=400, detail='Idempotency-Key must contain 1 to 128 characters.')
request_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
setting_key = f'action-idempotency:{hashlib.sha256(normalized.encode()).hexdigest()}'
existing = session.get(AppSetting, setting_key)
if existing:
if existing.value_json.get('requestHash') != request_hash:
raise HTTPException(status_code=409, detail='Idempotency-Key was already used for a different request.')
return existing, request_hash
return AppSetting(key=setting_key, is_secret=False), request_hash
def _idempotent_response(session: Session, row: AppSetting | None, request_hash: str | None, response: dict) -> dict:
if row is None or request_hash is None:
return response
if row.value_json.get('response') is not None:
return {**row.value_json['response'], 'idempotentReplay': True}
row.value_json = {'requestHash': request_hash, 'response': response}
row.updated_at = utc_now()
session.add(row)
session.commit()
return response
class ActionRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
input: str | None = Field(default=None, max_length=2000)
action: str | None = Field(default=None, max_length=160)
intent: str | None = Field(default=None, max_length=160)
target: str | None = Field(default=None, max_length=240)
source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$')
risk: str | None = Field(default=None, pattern='^(low|medium|high|critical)$')
policy: str | None = Field(default=None, pattern='^(allowed|approval_required|blocked)$')
reason: str | None = Field(default=None, max_length=1000)
plan: list[str] | dict | None = None
postChecks: list[str] = Field(default_factory=list, max_length=30)
def _request_payload(payload: ActionRequest) -> dict:
return payload.model_dump(exclude_none=True)
def _extract_plan(payload: dict) -> dict:
plan = payload.get('plan')
if isinstance(plan, dict):
return plan
if isinstance(payload.get('intent'), str):
return {
'intent': payload.get('intent'),
'target': payload.get('target'),
'source': payload.get('source', 'manual'),
'risk': payload.get('risk', 'low'),
'plan': payload.get('steps', []) or payload.get('plan', []),
'postChecks': payload.get('postChecks', []),
'policy': payload.get('policy'),
}
return {}
def _build_heuristic_plan(payload: dict, session: Session) -> dict | None:
input_text = str(payload.get('input', '')).lower()
source = str(payload.get('source', 'manual')).strip() or 'manual'
apps = session.exec(select(App.id)).all()
available_apps = [app_id for app_id in apps]
def has_app(name: str) -> bool:
return name in available_apps
if ('restart' in input_text or 'herstart' in input_text):
if has_app('widefrog') and 'widefrog' in input_text:
return {
'intent': 'container.restart',
'target': 'widefrog',
'source': source,
'risk': 'medium',
'policy': 'allowed',
'reason': 'Media app restart is low-medium risk.',
'plan': ['Verify container status', 'Restart widefrog container', 'Health check'],
'postChecks': ['HTTP 200 at port 1207', 'Container running'],
}
if has_app('authentik') and 'authentik' in input_text:
return {
'intent': 'container.restart',
'target': 'authentik',
'source': source,
'risk': 'high',
'policy': 'approval_required',
'reason': 'Auth service is critical and requires explicit approval.',
'plan': ['Check current sessions', 'Stop if maintenance mode', 'Restart container'],
'postChecks': ['SSO endpoint responds', 'Login flow returns'],
}
health_terms = {'health', 'check', 'controleer'}
if any(term in input_text for term in health_terms) and ('unraid' in input_text or 'array' in input_text):
return {
'intent': 'unraid.health_summary',
'target': 'unraid',
'source': source,
'risk': 'low',
'policy': 'allowed',
'reason': 'Read-only health summary.',
'plan': ['Collect array status', 'Collect disk warnings', 'Collect cache usage'],
'postChecks': ['Sanity of returned summary'],
}
if 'volume' in input_text and 'delete' in input_text:
return {
'intent': 'volume.delete',
'target': 'volume',
'source': source,
'risk': 'critical',
'policy': 'blocked',
'reason': 'Destructive action is blocked by default.',
'plan': [],
'postChecks': [],
}
aliases = {
'scan plex': 'plex.scan_all_libraries',
'scan bibliotheek': 'plex.scan_all_libraries',
'pauzeer deluge': 'deluge.pause_all',
'pause deluge': 'deluge.pause_all',
'hervat deluge': 'deluge.resume_all',
'resume deluge': 'deluge.resume_all',
'controleer media pipeline': 'media_pipeline.check',
'check media pipeline': 'media_pipeline.check',
'diagnose media pipeline': 'media_pipeline.diagnose',
'controleer veiligheid': 'safety.summary',
'safety summary': 'safety.summary',
'notificatie overzicht': 'notifications.summary',
'notification summary': 'notifications.summary',
'setup status': 'setup.status',
}
matched_action = next((action for phrase, action in aliases.items() if phrase in input_text and action in ACTIONS), None)
if not matched_action:
matched_action = next((action for action in ACTIONS if action.lower() in input_text), None)
if not matched_action:
input_terms = set(input_text.replace('.', ' ').replace('_', ' ').split())
candidates = []
for action in ACTIONS:
label_terms = set(action_label(action).lower().replace('/', ' ').split())
overlap = len(input_terms & label_terms)
if overlap >= 2:
candidates.append((overlap, action))
if candidates:
matched_action = max(candidates)[1]
if matched_action:
target = action_default_target(matched_action)
matched_app = next((app_id for app_id in available_apps if app_id.lower() in input_text), None)
if matched_app and matched_action.startswith('container.'):
target = matched_app
policy = evaluate_policy(session, action=matched_action, target=target, source=source)
capability = action_capability(matched_action)
return {
'intent': matched_action,
'target': target,
'source': source,
'risk': str(policy.get('risk') or ACTION_RISK.get(matched_action, 'medium')),
'policy': policy['decision'],
'reason': policy['reason'],
'plan': [
f'Validate current state of {target}',
('Collect read-only result' if capability == 'read_only' else f'Run {action_label(matched_action)} through its guarded executor'),
],
'postChecks': [f'Confirm {target} state and record the result in Audit'],
}
return None
def _registry_item(action: str, session: Session) -> dict:
target = action_default_target(action)
policy = evaluate_policy(session, action=action, target=target, source='manual')
decision = str(policy.get('decision', 'blocked'))
risk = str(policy.get('risk') or ACTION_RISK.get(action, 'medium'))
capability = action_capability(action)
execution = action_execution_profile(action, decision, capability)
return {
'id': action,
'label': action_label(action),
'category': action_category(action),
'target': target,
'risk': risk,
'capability': capability,
'policyDecision': decision,
'policyReason': policy.get('reason') or 'Policy preview.',
**execution,
}
@router.get('/registry')
def action_registry(session: Session = Depends(get_session)):
items = []
summary = {
'total': 0,
'allowed': 0,
'approvalRequired': 0,
'blocked': 0,
'readOnly': 0,
'operator': 0,
'destructive': 0,
'highRisk': 0,
'commandExecutable': 0,
'approvalExecutable': 0,
'dedicatedSurface': 0,
'governanceOnly': 0,
}
for action in sorted(ACTIONS):
item = _registry_item(action, session)
decision = item['policyDecision']
risk = item['risk']
capability = item['capability']
summary['total'] += 1
if decision == 'allowed':
summary['allowed'] += 1
elif decision == 'approval_required':
summary['approvalRequired'] += 1
elif decision == 'blocked':
summary['blocked'] += 1
if capability == 'read_only':
summary['readOnly'] += 1
elif capability == 'destructive':
summary['destructive'] += 1
else:
summary['operator'] += 1
if risk in {'high', 'critical'}:
summary['highRisk'] += 1
execution_mode = item['executionMode']
if execution_mode == 'command_center':
summary['commandExecutable'] += 1
elif execution_mode == 'approval_then_execute':
summary['approvalExecutable'] += 1
elif execution_mode == 'dedicated_surface':
summary['dedicatedSurface'] += 1
elif execution_mode == 'governance_only':
summary['governanceOnly'] += 1
items.append(item)
return {'summary': summary, 'items': items}
@router.get('/registry/{action_id}')
def action_registry_detail(action_id: str, session: Session = Depends(get_session)):
if action_id not in ACTIONS:
raise HTTPException(status_code=404, detail='Unknown action id.')
item = _registry_item(action_id, session)
return {
**item,
'blastRadius': action_blast_radius(action_id, item['capability']),
'safeguards': action_safeguards(action_id, item['policyDecision'], item['capability']),
'operatorGuidance': action_operator_guidance(action_id, item['policyDecision'], item['capability']),
'relatedSurfaces': [
'/command-center',
'/policies',
'/audit',
],
}
@router.post('/plan')
def action_plan(payload: ActionRequest, session: Session = Depends(get_session)):
payload = _request_payload(payload)
source = str(payload.get('source', 'manual')).strip() or 'manual'
plan = _extract_plan(payload)
if not plan:
plan = _build_heuristic_plan(payload, session)
if not plan:
return {'decision': 'blocked', 'reason': 'No actionable intent detected.'}
return {
'intent': plan['intent'],
'target': plan['target'],
'source': source,
'risk': plan['risk'],
'policy': plan['policy'],
'reason': plan['reason'],
'plan': plan['plan'],
'postChecks': plan['postChecks'],
}
@router.post('/preflight')
def preflight(payload: ActionRequest, session: Session = Depends(get_session)):
payload = _request_payload(payload)
action = str(payload.get('action', '')).strip()
target = str(payload.get('target', '')).strip()
source = str(payload.get('source', 'manual')).strip() or 'manual'
if not action or not target:
if plan := _build_heuristic_plan(payload, session):
action = plan['intent']
target = plan['target']
source = plan['source']
else:
action = payload.get('action', '')
target = payload.get('target', '')
policy = evaluate_policy(session, action=action, target=target, source=source)
risk = policy.get('risk', 'medium')
return build_preflight(action, target, policy['decision'], risk)
@router.post('/execute')
def execute(payload: ActionRequest, session: Session = Depends(get_session), idempotency_key: str | None = Header(default=None, alias='Idempotency-Key')):
payload = _request_payload(payload)
idempotency_row, request_hash = _idempotency_context(session, idempotency_key, payload)
if idempotency_row is not None and idempotency_row.value_json.get('response') is not None:
return _idempotent_response(session, idempotency_row, request_hash, {})
plan = _extract_plan(payload)
if not plan:
return _idempotent_response(session, idempotency_row, request_hash, {'success': False, 'message': 'No valid action plan provided.'})
action = str(plan.get('intent', '')).strip()
target = str(plan.get('target', '')).strip()
source = str(plan.get('source', 'manual')).strip() or 'manual'
steps = plan.get('plan', []) if isinstance(plan.get('plan', []), list) else []
if action == 'deployment.target.promote_workmesh':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk='high',
policy_decision='blocked',
reason='WorkMesh promotion requires its exact-revision deployment preflight endpoint.',
result='blocked',
execution_plan={'plan': steps, 'executor': '/api/deployments/workmesh/promotions/preflight'},
)
return _idempotent_response(session, idempotency_row, request_hash, {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': 'blocked',
'result': 'preflight_required',
'message': 'Use the fixed WorkMesh revision preflight and approval workflow.',
'auditId': audit.id,
})
decision = evaluate_policy(session, action=action, target=target, source=source)
risk = str(decision.get('risk', 'medium')).strip() or 'medium'
# Always audit every request.
if decision['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision['decision'],
reason=decision['reason'],
result='blocked',
execution_plan={'plan': steps, 'policyReason': decision['reason']},
)
return _idempotent_response(session, idempotency_row, request_hash, {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': decision['decision'],
'result': 'blocked',
'message': decision['reason'],
'auditId': audit.id,
})
if decision['decision'] == 'approval_required':
approval = create_approval(
session,
action=action,
target=target,
source=source,
risk=risk,
reason=decision['reason'],
requester=source,
)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision['decision'],
reason=decision['reason'],
result='approval_requested',
execution_plan={'plan': steps, 'approvalId': approval.id},
redacted_metadata={'approval_id': approval.id},
)
return _idempotent_response(session, idempotency_row, request_hash, {
'success': False,
'decision': 'approval_required',
'status': 'failed',
'policy_decision': decision['decision'],
'result': 'approval_required',
'message': 'Action requires approval before execution.',
'approvalId': approval.id,
'auditId': audit.id,
})
if action in {
'deployment.targets.sync',
'deployment.target.deploy_workmesh',
'deployment.target.register_portfolio',
'deployment.target.register_privatemuse',
}:
response = execute_governed_action(
session,
action=action,
target=target,
source=source,
risk=risk,
reason=str(decision.get('reason') or 'Execute the fixed deployment target operation.'),
original_policy_decision=str(decision['decision']),
)
return _idempotent_response(session, idempotency_row, request_hash, response)
if action == 'container.restart':
response = execute_container_restart(
session=session,
action=action,
target=target,
source=source,
risk=risk,
steps=steps if isinstance(steps, list) else None,
)
return _idempotent_response(session, idempotency_row, request_hash, response)
# Fail closed for allowed actions without a real executor. This avoids
# presenting read-only checks or future actions as successfully executed.
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision['decision'],
reason='No live executor is registered for this action.',
result='unsupported',
execution_plan={'plan': steps, 'executor': None},
)
return _idempotent_response(session, idempotency_row, request_hash, {
'success': False,
'decision': 'allowed',
'status': 'failed',
'policy_decision': decision['decision'],
'result': 'unsupported',
'message': 'No live executor is registered for this action.',
'auditId': audit.id,
})
@router.post('/container/{app_id}/restart')
def restart_container(app_id: str, session: Session = Depends(get_session)):
return execute_container_restart(
session=session,
action='container.restart',
target=app_id,
source='manual',
risk='medium',
steps=['Resolve container', 'Restart container', 'Post-check status'],
)
+210
View File
@@ -0,0 +1,210 @@
from datetime import UTC, datetime
from typing import Any
import uuid
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..core.redaction import redact_message
from ..db.session import get_session
from ..services import ai_security_reviewer, knowledge_service
router = APIRouter(prefix='/api/ai')
_AI_TOOLS = [
{
'id': 'read_container_status',
'name': 'read_container_status',
'description': 'Leest containerstatus via de Portainer read facade.',
'type': 'read',
'permissionLevel': 1,
'requiresApproval': False,
'rateLimit': '60/min',
'tool': 'read_container_status',
'enabled': True,
'allowedByDefault': True,
'scope': 'read',
},
{
'id': 'read_unraid_health',
'name': 'read_unraid_health',
'description': 'Leest Unraid array, cache, parity en docker health zonder mutaties.',
'type': 'read',
'permissionLevel': 1,
'requiresApproval': False,
'rateLimit': '30/min',
'tool': 'read_unraid_health',
'enabled': True,
'allowedByDefault': True,
'scope': 'read',
},
{
'id': 'read_audit',
'name': 'read_audit',
'description': 'Leest geredigeerde audit events voor analyse en incidentcontext.',
'type': 'read',
'permissionLevel': 1,
'requiresApproval': False,
'rateLimit': '30/min',
'tool': 'read_audit',
'enabled': True,
'allowedByDefault': True,
'scope': 'read',
},
{
'id': 'query_knowledge',
'name': 'query_knowledge',
'description': 'Zoekt lokale documentatie en actuele, geredigeerde AppOps-context met bronverwijzingen.',
'type': 'read',
'permissionLevel': 1,
'requiresApproval': False,
'rateLimit': '30/min',
'tool': 'query_knowledge',
'enabled': True,
'allowedByDefault': True,
'scope': 'read',
},
{
'id': 'draft_playbook_run',
'name': 'draft_playbook_run',
'description': 'Maakt alleen een voorstel voor playbook-uitvoering; uitvoering loopt via policy en approval.',
'type': 'mutate',
'permissionLevel': 3,
'requiresApproval': True,
'rateLimit': '5/min',
'tool': 'draft_playbook_run',
'enabled': True,
'allowedByDefault': False,
'scope': 'mutate',
},
]
def _redact_payload(value: Any) -> Any:
if isinstance(value, str):
return redact_message(value)
if isinstance(value, list):
return [_redact_payload(item) for item in value]
if isinstance(value, dict):
return {str(key): _redact_payload(item) for key, item in value.items()}
return value
@router.get('/status')
def status(session: Session = Depends(get_session)):
summary = ai_security_reviewer.get_summary(session)
current_risks = int(summary.get('criticalFindings') or 0) + int(summary.get('highFindings') or 0) + int(summary.get('mediumFindings') or 0)
recommended_actions = int(summary.get('recommendationsCount') or 0)
data_completeness = summary.get('dataCompleteness') if isinstance(summary.get('dataCompleteness'), dict) else {}
missing_sources = data_completeness.get('missingSources') or []
if missing_sources:
storage_forecast = f"Coverage partial; ontbrekende bronnen: {', '.join(str(item) for item in missing_sources[:4])}."
else:
storage_forecast = 'Geen storage forecast beschikbaar in de advisory AI status.'
return {
'dailySummary': redact_message(
f"AI advisor draait read-only. Security status: {summary.get('status', 'unknown')}; "
f"{current_risks} open risico(s), {recommended_actions} aanbeveling(en)."
),
'currentRisks': current_risks,
'recommendedActions': recommended_actions,
'storageForecast': redact_message(storage_forecast),
'securityScore': int(summary.get('score') or 0),
'autopilotStatus': 'off',
}
@router.post('/chat')
def chat(payload: dict, session: Session = Depends(get_session)):
content = redact_message(str(payload.get('content') or ''))
try:
grounded = knowledge_service.query(session, content, limit=5)
except ValueError:
grounded = {
'answer': 'Stel een concrete vraag over AppOps, policies, apps, audit of platformwerking.',
'citations': [],
'confidence': 'none',
}
citations = grounded.get('citations') or []
evidence = '; '.join(
f"[{item.get('rank')}] {item.get('title')} — {item.get('uri')}"
for item in citations[:5]
if isinstance(item, dict)
)
return {
'id': f"chat-{uuid.uuid4().hex[:12]}",
'role': 'assistant',
'content': grounded['answer'],
'timestamp': datetime.now(UTC).isoformat(),
'metadata': {
'evidence': evidence or 'Geen relevante bron gevonden; er is niet gegokt.',
'risk': 'low',
'policyResult': 'allowed',
'nextStep': 'RAG blijft read-only. Uitvoering loopt uitsluitend via Command Center, policy en approvals.',
'citations': citations,
'confidence': grounded.get('confidence', 'none'),
},
}
@router.get('/recommendations')
def recommendations(session: Session = Depends(get_session)):
results: list[dict[str, Any]] = []
for recommendation in ai_security_reviewer.get_recommendations(session):
evidence = recommendation.get('evidence') or []
evidence_text = '; '.join(
redact_message(f"{item.get('source', 'source')}:{item.get('label', 'evidence')}={item.get('value', item.get('observed', ''))}")
for item in evidence[:3]
if isinstance(item, dict)
) or redact_message(str(recommendation.get('description') or 'Security reviewer recommendation.'))
status = recommendation.get('status') or 'open'
if status == 'accepted':
status = 'applied'
confidence = recommendation.get('confidence') or 'medium'
confidence_score = {'low': 0.45, 'medium': 0.7, 'high': 0.9}.get(str(confidence), 0.7)
severity = str(recommendation.get('severity') or 'medium')
if severity == 'info':
severity = 'low'
results.append({
'id': recommendation.get('id'),
'title': redact_message(str(recommendation.get('title') or 'AI recommendation')),
'severity': severity,
'confidence': confidence_score,
'evidence': evidence_text,
'affectedApps': [str(item) for item in recommendation.get('affectedResources', [])],
'suggestedAction': redact_message(str(recommendation.get('suggestedAction') or 'Review in AI Security Reviewer')),
'status': status,
})
return results
@router.post('/analyze/incident/{incident_id}')
def analyze_incident(incident_id: str):
return {'incident': incident_id, 'analysis': 'No blocking issues.'}
@router.post('/summarize/logs')
def summarize_logs(payload: dict):
return {'summary': 'No critical events.'}
@router.get('/tools')
def tools():
return _AI_TOOLS
@router.get('/history')
def history():
return []
@router.get('/memory')
def memory():
return []
@router.post('/memory')
def create_memory(payload: dict):
return {'success': True, 'memory': _redact_payload(payload)}
@router.patch('/memory/{memory_id}')
def update_memory(memory_id: str, payload: dict):
return {'success': True, 'id': memory_id, 'memory': _redact_payload(payload)}
@router.delete('/memory/{memory_id}')
def delete_memory(memory_id: str):
return {'success': True, 'id': memory_id}
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from ..db.session import get_session
from ..services import ai_remediation_planner
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/ai/remediation', dependencies=[Depends(load_connector_runtime)])
@router.get('/status')
def status(session: Session = Depends(get_session)):
plans = ai_remediation_planner.get_plans(session).plans
return {
'enabled': True,
'mode': 'deterministic_rule_based',
'externalAiRequired': False,
'canExecuteActions': False,
'totalPlans': len(plans),
'openPlans': sum(1 for plan in plans if plan.status == 'draft'),
'reviewedPlans': sum(1 for plan in plans if plan.status == 'reviewed'),
'dismissedPlans': sum(1 for plan in plans if plan.status == 'dismissed'),
}
@router.get('/plans')
def plans(session: Session = Depends(get_session)):
return ai_remediation_planner.get_plans(session).model_dump()
@router.get('/plans/{plan_id}')
def plan_detail(plan_id: str, session: Session = Depends(get_session)):
plan = ai_remediation_planner.get_plan(session, plan_id)
if not plan:
raise HTTPException(status_code=404, detail='plan_not_found')
return plan.model_dump()
@router.post('/plans/generate')
def generate(session: Session = Depends(get_session)):
return ai_remediation_planner.generate_plans(session, audit_run=True).model_dump()
@router.post('/plans/from-finding/{finding_id}')
def from_finding(finding_id: str, session: Session = Depends(get_session)):
return ai_remediation_planner.generate_from_finding(session, finding_id).model_dump()
@router.post('/plans/from-recommendation/{recommendation_id}')
def from_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
return ai_remediation_planner.generate_from_recommendation(session, recommendation_id).model_dump()
@router.post('/plans/{plan_id}/review')
def review(plan_id: str, session: Session = Depends(get_session)):
if not ai_remediation_planner.get_plan(session, plan_id):
raise HTTPException(status_code=404, detail='plan_not_found')
return ai_remediation_planner.review_plan(session, plan_id)
@router.post('/plans/{plan_id}/dismiss')
def dismiss(plan_id: str, session: Session = Depends(get_session)):
if not ai_remediation_planner.get_plan(session, plan_id):
raise HTTPException(status_code=404, detail='plan_not_found')
return ai_remediation_planner.dismiss_plan(session, plan_id)
@router.post('/plans/{plan_id}/create-approval-proposal')
def create_approval_proposal(plan_id: str, session: Session = Depends(get_session)):
proposal = ai_remediation_planner.create_approval_proposal(session, plan_id)
if not proposal:
raise HTTPException(status_code=404, detail='plan_not_found')
return proposal.model_dump()
@router.post('/plans/{plan_id}/dry-run')
def dry_run(plan_id: str, session: Session = Depends(get_session)):
result = ai_remediation_planner.dry_run(session, plan_id)
if not result:
raise HTTPException(status_code=404, detail='plan_not_found')
return result.model_dump()
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from ..db.session import get_session
from ..schemas.ai_security import SecurityReviewScope
from ..services import ai_security_reviewer
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/ai/security', dependencies=[Depends(load_connector_runtime)])
@router.get('/status')
def status(session: Session = Depends(get_session)):
summary = ai_security_reviewer.get_summary(session)
return {
'enabled': True,
'mode': 'deterministic_rule_based',
'externalAiRequired': False,
'canExecuteActions': False,
'status': summary['status'],
'score': summary['score'],
'generatedAt': summary['generatedAt'],
}
@router.get('/summary')
def summary(session: Session = Depends(get_session)):
return ai_security_reviewer.get_summary(session)
@router.get('/findings')
def findings(scope: SecurityReviewScope = 'all', session: Session = Depends(get_session)):
return ai_security_reviewer.get_findings(session, scope)
@router.get('/findings/{finding_id}')
def finding_detail(finding_id: str, session: Session = Depends(get_session)):
finding = ai_security_reviewer.get_finding(session, finding_id)
if not finding:
raise HTTPException(status_code=404, detail='finding_not_found')
return finding
@router.get('/recommendations')
def recommendations(session: Session = Depends(get_session)):
return ai_security_reviewer.get_recommendations(session)
@router.post('/review')
def review(session: Session = Depends(get_session)):
return ai_security_reviewer.generate_review(session, audit_run=True).model_dump()
@router.post('/review/scope/{scope}')
def review_scope(scope: SecurityReviewScope, session: Session = Depends(get_session)):
return ai_security_reviewer.generate_review(session, scope=scope, audit_run=True).model_dump()
@router.post('/findings/{finding_id}/acknowledge')
def acknowledge_finding(finding_id: str, session: Session = Depends(get_session)):
return ai_security_reviewer.set_finding_status(session, finding_id, 'acknowledged')
@router.post('/findings/{finding_id}/dismiss')
def dismiss_finding(finding_id: str, session: Session = Depends(get_session)):
return ai_security_reviewer.set_finding_status(session, finding_id, 'dismissed')
@router.post('/recommendations/{recommendation_id}/snooze')
def snooze_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'snoozed')
@router.post('/recommendations/{recommendation_id}/dismiss')
def dismiss_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'dismissed')
@router.post('/recommendations/{recommendation_id}/accept')
def accept_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'accepted')
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from ..db.session import get_session
from ..services.audit_logger import log_event
from ..services.action_execution import approval_execution_supported, execute_approved_action
from ..services.approval_service import (
ApprovalStateError,
approval_fingerprint,
create_approval,
dismiss_expired_approvals,
get_approval,
list_approvals,
mark_approval_executed,
update_approval_status,
)
router = APIRouter(prefix='/api/approvals')
class ApprovalCreateRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
action: str = Field(min_length=1, max_length=160)
target: str = Field(min_length=1, max_length=240)
source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$')
risk: str = Field(default='medium', pattern='^(low|medium|high|critical)$')
reason: str | None = Field(default=None, max_length=1000)
requester: str = Field(default='manual', max_length=120)
plan: list[str] = Field(default_factory=list, max_length=30)
class ApprovalDecisionRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
status: str = Field(min_length=1, max_length=32)
@router.get('')
def list_all(include_dismissed: bool = Query(default=False), session=Depends(get_session)):
return [
{
**approval.model_dump(),
'executionSupported': approval_execution_supported(approval.action),
'executionReason': None if approval_execution_supported(approval.action) else 'Geen rollback-veilige executor geregistreerd.',
}
for approval in list_approvals(session, include_dismissed=include_dismissed)
]
@router.post('/cleanup')
def cleanup_expired(session=Depends(get_session)):
dismissed = dismiss_expired_approvals(session)
audit = log_event(
session,
source='manual',
identity='operator',
action_id='approval.cleanup',
target='approval-queue',
target_type='approval',
risk='low',
policy_decision='allowed',
reason=f'{len(dismissed)} expired approval(s) archived; active approvals were retained.',
result='success',
execution_plan={'steps': ['expire overdue pending approvals', 'archive expired approvals', 'retain approval and audit records']},
redacted_metadata={'dismissedCount': len(dismissed)},
)
return {
'success': True,
'message': f'{len(dismissed)} verlopen approval(s) opgeruimd.',
'dismissedCount': len(dismissed),
'auditId': audit.id,
}
@router.post('')
def create(payload: ApprovalCreateRequest, session=Depends(get_session)):
approval = create_approval(
session,
action=payload.action,
target=payload.target,
source=payload.source,
risk=payload.risk,
reason=payload.reason,
requester=payload.requester,
context={'plan': payload.plan},
)
return {'success': True, 'message': 'Approval created', 'id': approval.id}
@router.patch('/{approval_id}')
def update(approval_id: str, payload: ApprovalDecisionRequest, session=Depends(get_session)):
status = payload.status
try:
updated = update_approval_status(session, approval_id, status)
except ApprovalStateError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if not updated:
return {'success': False, 'message': 'Approval not found', 'id': approval_id}
audit = log_event(
session,
source='manual',
identity='operator',
action_id=f'approval.{status}',
target=updated.target,
target_type='approval',
risk=updated.risk,
policy_decision='approval_required',
reason=updated.reason or f'Approval {approval_id} marked {status}.',
result='success',
execution_plan={'steps': ['load approval', f'mark {status}', 'write audit']},
redacted_metadata={'approvalId': updated.id, 'approvalStatus': updated.status, 'requestedAction': updated.action},
)
return {
'success': True,
'id': updated.id,
'status': updated.status,
'auditId': audit.id,
'message': f'Approval {updated.status}',
}
@router.post('/{approval_id}/execute')
def execute_approved(approval_id: str, session=Depends(get_session)):
approval = get_approval(session, approval_id)
if not approval:
raise HTTPException(status_code=404, detail='Approval not found.')
if approval.status != 'approved':
raise HTTPException(status_code=409, detail='Only an approved request can be executed.')
expected = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json)
if not approval.action_fingerprint or approval.action_fingerprint != expected:
raise HTTPException(status_code=409, detail='Approval action context changed after review.')
result = execute_approved_action(session, approval)
if not result.get('success'):
return result
mark_approval_executed(
session,
approval,
job_id=str(result['jobId']),
audit_event_id=str(result['auditId']),
)
return {
**result,
'approvalId': approval.id,
'approvalStatus': 'executed',
}
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
import re
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..db.models import App, AppContainerMapping, DiscoveredContainer
from ..db.session import get_session
from ..core.config import settings
from ..services import safety_center_service
from ..services.app_registry import category_label, list_active_apps
from ..services.audit_logger import log_event
from ..services.inventory_discovery import serialize_inventory_app, sync_app_connector_statuses
router = APIRouter(prefix='/api/apps')
def _published_port(values: list | None) -> int | None:
for value in values or []:
match = re.match(r'^(\d+):\d+', str(value))
if match:
return int(match.group(1))
return None
def _credential_free_url(raw: str) -> str | None:
raw = str(raw or '').strip()
if not raw:
return None
parsed = urlsplit(raw if '://' in raw else f'http://{raw}')
try:
hostname = parsed.hostname
parsed_port = parsed.port
except ValueError:
return None
if parsed.scheme not in {'http', 'https'} or not hostname:
return None
host = f'[{hostname}]' if ':' in hostname else hostname
port = f':{parsed_port}' if parsed_port else ''
path = parsed.path.rstrip('/')
return f'{parsed.scheme}://{host}{port}{path}'
def _base_runtime_url() -> str | None:
safe = _credential_free_url(str(settings.apis.get('UNRAID_URL') or settings.apis.get('PORTAINER_URL') or ''))
if not safe:
return None
parsed = urlsplit(safe)
return f'{parsed.scheme}://{parsed.netloc}'
def _runtime_hints_by_app(session: Session) -> dict[str, dict]:
from ..services.inventory_discovery import _latest_by_container
configured_urls = {
'sonarr': 'SONARR_URL',
'radarr': 'RADARR_URL',
'prowlarr': 'PROWLARR_URL',
'deluge': 'DELUGE_URL',
'plex': 'PLEX_URL',
'tautulli': 'TAUTULLI_URL',
'nginx_proxy_manager': 'NPM_URL',
'authentik': 'AUTHENTIK_URL',
'home_assistant': 'HOME_ASSISTANT_URL',
'widefrog': 'WIDEFROG_URL',
'portainer': 'PORTAINER_URL',
'unraid': 'UNRAID_URL',
}
hints: dict[str, dict] = {}
for app_id, env_name in configured_urls.items():
value = _credential_free_url(str(settings.apis.get(env_name) or ''))
if value:
hints[app_id] = {'url': value}
base_url = _base_runtime_url()
latest = _latest_by_container(session)
mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.is_primary.desc())).all()
for mapping in mappings:
if mapping.app_id in hints or not base_url:
continue
item = latest.get(mapping.container_id)
port = _published_port(item.ports_json if item else None)
if port:
hints[mapping.app_id] = {'url': base_url, 'port': port}
return hints
def _serialize_app(app: App, hints: dict | None = None) -> dict:
return {
'id': app.id,
'name': app.name,
'category': app.category,
'status': app.status,
'criticality': app.criticality,
'connectorStatus': app.connector_status,
'containerStatus': app.container_status,
'voiceEnabled': app.voice_enabled,
'lastAction': app.last_action,
'protected': app.protected,
'discoveryStatus': app.discovery_status,
'discoverySource': app.discovery_source,
'needsReview': app.needs_review,
**(hints or {}),
}
def _serialize_discovered_app(item: DiscoveredContainer) -> dict:
return serialize_inventory_app(item)
@router.get('')
def list_apps(include_discovered: bool = False, session: Session = Depends(get_session)):
sync_app_connector_statuses(session)
runtime_hints = _runtime_hints_by_app(session)
apps = [_serialize_app(app, runtime_hints.get(app.id)) for app in list_active_apps(session)]
if include_discovered:
managed_container_ids = {
mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all()
}
latest_by_container: dict[str, DiscoveredContainer] = {}
for item in session.exec(select(DiscoveredContainer)).all():
latest_by_container[item.container_id] = item
for item in latest_by_container.values():
if item.container_id in managed_container_ids or item.import_status == 'managed':
continue
if item.import_status in {'discovered', 'unmapped', 'changed', 'ignored'}:
apps.append(_serialize_discovered_app(item))
return apps
@router.get('/{app_id}')
def get_app(app_id: str, session: Session = Depends(get_session)):
sync_app_connector_statuses(session)
app = session.get(App, app_id)
if app:
return _serialize_app(app, _runtime_hints_by_app(session).get(app.id))
if app_id.startswith('discovered-'):
discovered_id = app_id.replace('discovered-', '', 1)
item = session.get(DiscoveredContainer, discovered_id)
if item:
return _serialize_discovered_app(item)
return None
@router.patch('/{app_id}/metadata')
def update_app_metadata(app_id: str, payload: dict, session: Session = Depends(get_session)):
app = session.get(App, app_id)
if not app:
return {'success': False, 'status': 'not_found', 'message': 'app_not_found'}
name = str(payload.get('name') or '').strip()[:80]
category = re.sub(r'[^a-zA-Z0-9_\- ]', '', str(payload.get('category') or '')).strip()[:40]
criticality = str(payload.get('criticality') or '').strip().lower()
if not name or not category or criticality not in {'low', 'medium', 'high', 'critical'}:
return {'success': False, 'status': 'validation_error', 'message': 'invalid_app_metadata'}
before = {'name': app.name, 'category': app.category, 'criticality': app.criticality}
app.name = name
app.category = category_label(category)
app.criticality = criticality
session.add(app)
session.commit()
session.refresh(app)
safety_center_service.invalidate_cache()
log_event(
session,
source='manual',
identity='operator',
action_id='app.metadata.update',
target=app.id,
target_type='app',
risk='low',
policy_decision='allowed',
reason='Operator updated AppOps registry metadata only; no container or external service was mutated.',
result='success',
before_state=before,
after_state={'name': app.name, 'category': app.category, 'criticality': app.criticality},
redacted_metadata={'fields': ['name', 'category', 'criticality']},
)
return {'success': True, 'status': 'success', 'message': 'app_metadata_updated', 'app': _serialize_app(app, _runtime_hints_by_app(session).get(app.id))}
@router.patch('/{app_id}/container-mapping')
def update_container_mapping(app_id: str, payload: dict, session: Session = Depends(get_session)):
container_id = str(payload.get('containerId') or '')
container_name = str(payload.get('containerName') or container_id)
if not session.get(App, app_id):
return {'success': False, 'message': 'app_not_found'}
if not container_id:
return {'success': False, 'message': 'container_id_required'}
mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == container_id)).first()
if not mapping:
mapping = AppContainerMapping(id=f'map-{container_id}', app_id=app_id, container_id=container_id, container_name=container_name)
else:
mapping.app_id = app_id
mapping.container_name = container_name
session.add(mapping)
session.commit()
safety_center_service.invalidate_cache()
return {'success': True, 'message': 'mapping_updated', 'mappingId': mapping.id}
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from ..db.session import get_session
from ..services.audit_logger import list_events
router = APIRouter(prefix='/api/audit')
@router.get('')
def list_audit(
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
session=Depends(get_session),
):
return list_events(session, limit=limit, offset=offset)
@router.post('/export')
def export_audit(payload: dict):
return {
'success': True,
'message': f"Exporting audit in {str(payload.get('format', 'json')).upper()} format",
}
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from ..connectors.authentik import AuthentikConnector
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/authentik', dependencies=[Depends(load_connector_runtime)])
def _shape(payload: dict[str, Any]) -> dict[str, Any]:
data = payload.get('data')
if not isinstance(data, dict):
data = {key: value for key, value in payload.items() if key not in {'success', 'status', 'message'}}
return {
**payload,
'success': bool(payload.get('success')),
'status': str(payload.get('status', 'ok')),
'message': str(payload.get('message', 'ok')),
'data': data,
}
def _connector() -> AuthentikConnector:
return AuthentikConnector()
@router.get('/health')
def health():
return _shape(_connector().health())
@router.get('/system')
def system_status():
return _shape(_connector().system_status())
@router.get('/applications')
def applications():
return _shape(_connector().applications())
@router.get('/providers')
def providers():
return _shape(_connector().providers())
@router.get('/providers/proxy')
def proxy_providers():
return _shape(_connector().proxy_providers())
@router.get('/providers/oauth2')
def oauth2_providers():
return _shape(_connector().oauth2_providers())
@router.get('/outposts')
def outposts():
return _shape(_connector().outposts())
@router.get('/outposts/health')
def outpost_health():
return _shape(_connector().outpost_health())
@router.get('/flows')
def flows_summary():
return _shape(_connector().flows_summary())
@router.get('/policies')
def policies_summary():
return _shape(_connector().policies_summary())
@router.get('/events')
def events(limit: int = 25):
return _shape(_connector().events(limit))
@router.get('/auth-coverage')
def auth_coverage():
return _shape(_connector().auth_coverage())
@router.get('/security-warnings')
def security_warnings():
return _shape(_connector().security_warnings())
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session, select
# Kept imported here for compatibility with tests and callers that patch the
# route module's Portainer connector class.
from ..connectors.portainer import PortainerConnector
from ..core.redaction import redact_message
from ..db.models import Connector
from ..db.session import get_session
from ..services.connector_health import refresh_connector_statuses, test_and_persist_connector
from ..services.inventory_discovery import (
bind_connector_to_discovered,
list_connector_bindings,
list_connector_candidates,
unbind_connector_from_app,
)
router = APIRouter(prefix='/api/connectors')
class ConnectorBindRequest(BaseModel):
discoveredContainerId: str
class ConnectorUnbindRequest(BaseModel):
appId: str
def _serialize_connector(connector: Connector) -> dict:
return {
'id': connector.id,
'name': connector.name,
'status': connector.status,
'lastTest': connector.last_test,
'capabilities': connector.capabilities,
'type': connector.type,
'endpoint': None,
'secretRef': connector.secret_ref,
}
def _required_env_vars(connector_id: str) -> list[str]:
mapping = {
'portainer': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'],
'sonarr': ['SONARR_URL', 'SONARR_API_KEY'],
'radarr': ['RADARR_URL', 'RADARR_API_KEY'],
'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'],
'plex': ['PLEX_URL', 'PLEX_TOKEN'],
'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'],
'nginx_proxy_manager': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'],
'authentik': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'],
'widefrog': ['WIDEFROG_URL'],
'unraid': ['UNRAID_URL', 'UNRAID_API_KEY'],
'home_assistant': ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'],
}
return mapping.get(connector_id, [])
@router.get('')
def list_connectors(refresh: bool = False, session: Session = Depends(get_session)):
if refresh:
refresh_connector_statuses(session)
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
return [_serialize_connector(connector) for connector in connectors]
@router.get('/{connector_id}')
def connector_status(connector_id: str, session: Session = Depends(get_session)):
connector = session.get(Connector, connector_id)
if not connector:
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
return _serialize_connector(connector)
@router.get('/{connector_id}/candidates')
def connector_candidates(connector_id: str, session: Session = Depends(get_session)):
connector = session.get(Connector, connector_id)
if not connector:
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
return list_connector_candidates(session, connector_id)
@router.get('/{connector_id}/bindings')
def connector_bindings(connector_id: str, session: Session = Depends(get_session)):
connector = session.get(Connector, connector_id)
if not connector:
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
return list_connector_bindings(session, connector_id)
@router.post('/{connector_id}/bind')
def bind_connector(connector_id: str, payload: ConnectorBindRequest, session: Session = Depends(get_session)):
connector = session.get(Connector, connector_id)
if not connector:
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
return bind_connector_to_discovered(session, connector_id, payload.discoveredContainerId)
@router.post('/{connector_id}/unbind')
def unbind_connector(connector_id: str, payload: ConnectorUnbindRequest, session: Session = Depends(get_session)):
connector = session.get(Connector, connector_id)
if not connector:
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
return unbind_connector_from_app(session, connector_id, payload.appId)
@router.post('/{connector_id}/test')
def test_connector(connector_id: str, session: Session = Depends(get_session)):
connector = session.get(Connector, connector_id)
if not connector:
return {'success': False, 'status': 'disconnected', 'message': f'Connector {connector_id} not found'}
result = test_and_persist_connector(session, connector_id)
status = str(result.get('status', 'disconnected'))
reason = result.get('reason')
success = status == 'connected'
payload: dict[str, object] = {
'success': success,
'status': status,
'id': connector_id,
'message': redact_message(str(reason)) if reason else ('Connector test succeeded.' if success else 'Connector test failed.'),
'missing_config': status == 'missing_config',
'required_env_vars': _required_env_vars(connector_id),
'secretRef': connector.secret_ref,
}
if reason:
payload['reason'] = redact_message(str(reason))
return payload
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from ..connectors.portainer import PortainerConnector
from ..core.redaction import redact_message
from ..db.session import get_session
from ..services.setup_service import apply_stored_connector_settings
router = APIRouter(prefix='/api/containers')
def _raise_connector_error(response: dict):
status = str(response.get('status', 'error'))
reason = redact_message(str(response.get('reason', 'connection_failed')))
status_code = 503 if status == 'missing_config' else 502
raise HTTPException(
status_code=status_code,
detail={
'status': status,
'reason': reason,
'connector': 'portainer',
},
)
@router.get('')
def list_containers(
include_stats: bool = Query(False),
stats_limit: int = Query(200, ge=1, le=500),
session: Session = Depends(get_session),
):
apply_stored_connector_settings(session)
response = PortainerConnector().list_containers(include_stats=include_stats, stats_limit=stats_limit)
if response.get('status') == 'ok':
return response.get('containers', [])
_raise_connector_error(response)
@router.get('/{container_name}')
def get_container(container_name: str, session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
response = PortainerConnector().get_container_status(container_name)
status = response.get('status')
if status == 'ok':
return response.get('container')
if status == 'not_found':
return None
_raise_connector_error(response)
@router.get('/{container_name}/logs')
def get_container_logs(container_name: str, session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
response = PortainerConnector().get_container_logs(container_name)
return {
'container': response.get('container', container_name),
'status': response.get('status', 'unknown'),
'reason': redact_message(str(response.get('reason', ''))) if response.get('reason') else None,
'logs': [
redact_message(str(line))
for line in response.get('logs', [])
],
}
+208
View File
@@ -0,0 +1,208 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.deluge import DelugeConnector
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
from ..services.policy_engine import evaluate_policy
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/deluge', dependencies=[Depends(load_connector_runtime)])
_PAUSE_STEPS = [
'Validate torrent id',
'Evaluate policy',
'Check Deluge health/session',
'Pause torrent',
'Fetch torrent status',
'Complete audit/job',
]
_RESUME_STEPS = [
'Validate torrent id',
'Evaluate policy',
'Check Deluge health/session',
'Resume torrent',
'Fetch torrent status',
'Complete audit/job',
]
_BULK_STEPS = [
'Validate action scope',
'Evaluate policy',
'Require operator approval',
'Call Deluge bulk operation',
'Complete audit/job',
]
@router.get('/health')
def health():
return DelugeConnector().health()
@router.get('/session')
def session_status():
return DelugeConnector().session_status()
@router.get('/torrents')
def torrents():
return DelugeConnector().torrent_status()
@router.get('/active')
def active():
return DelugeConnector().active_downloads()
@router.get('/stalled')
def stalled():
return DelugeConnector().stalled_downloads()
@router.get('/paused')
def paused():
return DelugeConnector().paused_downloads()
@router.get('/speeds')
def speeds():
return DelugeConnector().speed_status()
@router.post('/torrents/{torrent_id}/pause')
def pause_torrent(torrent_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_deluge_action(
session=session,
payload=payload or {},
action='deluge.pause_torrent',
steps=_PAUSE_STEPS,
connector_call=lambda connector: connector.pause_torrent(torrent_id),
default_success_message='torrent_paused',
metadata={'torrentId': torrent_id},
)
@router.post('/torrents/{torrent_id}/resume')
def resume_torrent(torrent_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_deluge_action(
session=session,
payload=payload or {},
action='deluge.resume_torrent',
steps=_RESUME_STEPS,
connector_call=lambda connector: connector.resume_torrent(torrent_id),
default_success_message='torrent_resumed',
metadata={'torrentId': torrent_id},
)
@router.post('/pause-all')
def pause_all(payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_deluge_action(
session=session,
payload=payload or {},
action='deluge.pause_all',
steps=_BULK_STEPS,
connector_call=lambda connector: connector.pause_all(),
default_success_message='all_torrents_paused',
metadata={'scope': 'all'},
)
@router.post('/resume-all')
def resume_all(payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_deluge_action(
session=session,
payload=payload or {},
action='deluge.resume_all',
steps=_BULK_STEPS,
connector_call=lambda connector: connector.resume_all(),
default_success_message='all_torrents_resumed',
metadata={'scope': 'all'},
)
def _execute_deluge_action(
*,
session: Session,
payload: dict[str, Any],
action: str,
steps: list[str],
connector_call: Callable[[DelugeConnector], dict[str, Any]],
default_success_message: str,
metadata: dict[str, Any],
) -> dict[str, Any]:
target = 'deluge'
source = str(payload.get('source', 'manual')).strip() or 'manual'
policy = evaluate_policy(session=session, action=action, target=target, source=source)
risk = str(policy.get('risk', 'medium'))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
execution_plan={'plan': steps, 'policyReason': policy['reason']},
redacted_metadata=metadata,
)
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
if policy['decision'] == 'approval_required':
approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': steps, 'approvalId': approval.id},
redacted_metadata={**metadata, 'approval_id': approval.id},
)
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps)
start_job(session, job)
set_job_progress(session, job, 35, 'Policy allowed; calling Deluge connector.')
connector_result = connector_call(DelugeConnector())
success = bool(connector_result.get('success'))
message = str(connector_result.get('message', default_success_message if success else 'deluge_failed'))
completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if success else 'failed',
execution_plan={'plan': steps, 'jobId': completed.id},
after_state={'status': connector_result.get('status'), 'message': message},
redacted_metadata={**metadata, 'connector': 'deluge'},
)
if not success:
return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')}
return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})}
+140
View File
@@ -0,0 +1,140 @@
"""Operator API for Deployment Control Plane V1.
All mutations accept a typed plan and an idempotency key. There is no endpoint
for command execution, Compose upload, arbitrary host paths or raw secrets.
"""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session
from ..core.config import settings
from ..core.security import require_operator_request
from ..db.session import get_session
from ..schemas.deployment import DeploymentPlanV1
from ..services.deployment_control_plane import (
DeploymentControlError,
DeploymentControlPlane,
)
from ..services.deployment_broker import DeploymentBrokerClient
def _require_deployment_operator(request: Request) -> str:
if not settings.operator_auth_required:
raise HTTPException(
status_code=503,
detail="Deployment Control Plane requires the configured operator authentication boundary.",
)
return require_operator_request(request)
router = APIRouter(
prefix="/api/deployments",
tags=["deployments"],
dependencies=[Depends(_require_deployment_operator)],
)
class DeploymentRequestV1(BaseModel):
model_config = ConfigDict(extra="forbid")
plan: DeploymentPlanV1
operation: Literal["preflight", "apply", "rollback"] = "preflight"
class WorkMeshPromotionPreflightRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[0-9a-f]{40}$")
DeploymentSession = Annotated[Session, Depends(get_session)]
DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
DeploymentLimit = Annotated[int, Query(ge=1, le=250)]
def _idempotency_header(value: str | None) -> str:
if not value:
raise HTTPException(status_code=400, detail="Idempotency-Key is required for deployment operations.")
return value.strip()
@router.post("")
def request_deployment(
payload: DeploymentRequestV1,
session: DeploymentSession,
idempotency_key: DeploymentIdempotencyKey = None,
):
try:
return DeploymentControlPlane().execute(
session,
plan=payload.plan,
idempotency_key=_idempotency_header(idempotency_key),
operation=payload.operation,
)
except DeploymentControlError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/workmesh/promotions/preflight")
def preflight_workmesh_promotion(
payload: WorkMeshPromotionPreflightRequest,
session: DeploymentSession,
idempotency_key: DeploymentIdempotencyKey = None,
):
from ..services.workmesh_promotion import (
WorkMeshPromotionError,
WorkMeshPromotionService,
)
try:
return WorkMeshPromotionService().preflight(
session,
revision=payload.revision,
idempotency_key=_idempotency_header(idempotency_key),
)
except WorkMeshPromotionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/workmesh/promotions/{promotion_id}")
def get_workmesh_promotion(promotion_id: str, session: DeploymentSession):
from ..services.workmesh_promotion import WorkMeshPromotionService
result = WorkMeshPromotionService().refresh(session, promotion_id)
if result is None:
raise HTTPException(status_code=404, detail="WorkMesh promotion not found.")
return result
@router.get("/workmesh/promotions")
def list_workmesh_promotions(
session: DeploymentSession,
limit: DeploymentLimit = 20,
):
from ..services.workmesh_promotion import WorkMeshPromotionService
return {"items": WorkMeshPromotionService.list_promotions(session, limit=limit)}
@router.get("")
def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100):
return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)}
@router.get("/delivery-status")
def get_delivery_status():
"""Expose the controller's bounded, secret-free runnerless delivery view."""
return DeploymentBrokerClient().delivery_status()
@router.get("/{job_id}")
def get_deployment(job_id: str, session: DeploymentSession):
try:
return DeploymentControlPlane().refresh(session, job_id)
except DeploymentControlError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+102
View File
@@ -0,0 +1,102 @@
"""Operator API for the bounded Enrollment & Provisioning Plane v1."""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlmodel import Session
from ..core.config import settings
from ..core.security import require_operator_request
from ..db.session import get_session
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
from ..services.enrollment_plane import EnrollmentError, EnrollmentPlane
from ..services.repopulse_adoption_journal import (
RepoPulseAdoptionJournalError,
get_repopulse_adoption_status,
)
def _require_operator(request: Request) -> str:
if not settings.operator_auth_required:
raise HTTPException(status_code=503, detail="Enrollment requires the configured operator authentication boundary.")
return require_operator_request(request)
router = APIRouter(
prefix="/api/enrollments",
tags=["enrollments"],
dependencies=[Depends(_require_operator)],
)
class EnrollmentRequestV1(BaseModel):
model_config = ConfigDict(extra="forbid")
plan: EnrollmentPlanV1
operation: Literal["preflight", "enroll"] = "preflight"
EnrollmentSession = Annotated[Session, Depends(get_session)]
EnrollmentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
EnrollmentLimit = Annotated[int, Query(ge=1, le=250)]
def _idempotency(value: str | None) -> str:
if not value:
raise HTTPException(status_code=400, detail="Idempotency-Key is required for enrollment mutations.")
return value.strip()
@router.get("/capabilities")
def enrollment_capabilities(session: EnrollmentSession):
return EnrollmentPlane().capabilities(session)
@router.get("/trust")
def enrollment_trust_status(session: EnrollmentSession):
return EnrollmentPlane.trust_status(session)
@router.put("/trust")
def update_enrollment_trust(payload: EnrollmentTrustConfigV1, session: EnrollmentSession):
return EnrollmentPlane.set_trust_config(session, payload)
@router.post("")
def request_enrollment(
payload: EnrollmentRequestV1,
session: EnrollmentSession,
idempotency_key: EnrollmentIdempotencyKey = None,
):
plane = EnrollmentPlane()
try:
if payload.operation == "preflight":
return plane.preflight(session, payload.plan)
return plane.enroll(session, payload.plan, idempotency_key=_idempotency(idempotency_key))
except EnrollmentError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("")
def list_enrollments(session: EnrollmentSession, limit: EnrollmentLimit = 100):
return {"items": EnrollmentPlane().list_enrollments(session, limit=limit)}
@router.get("/repopulse/adoption")
def repopulse_adoption_status(session: EnrollmentSession):
"""Read the sole fixed migration journal through the operator boundary."""
try:
return get_repopulse_adoption_status(session)
except RepoPulseAdoptionJournalError as exc:
raise HTTPException(status_code=503, detail=exc.code) from None
@router.get("/{enrollment_id}")
def get_enrollment(enrollment_id: str, session: EnrollmentSession):
try:
return EnrollmentPlane().get_enrollment(session, enrollment_id)
except EnrollmentError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+37
View File
@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.gitea_actions import GiteaActionsConnector
from ..db.session import get_session
from ..services.gitea_project_auto_registration import GiteaProjectAutoRegistration
from ..services.runner_fleet import RunnerFleetService
router = APIRouter(prefix="/api/gitea/actions", tags=["gitea-actions"])
@router.get("/status")
def gitea_actions_status():
"""Return bounded runner and queue state without exposing the admin credential."""
return GiteaActionsConnector().snapshot()
@router.get("/fleet")
def gitea_actions_fleet():
"""Return the joined, secret-free CI runner fleet projection."""
return RunnerFleetService().snapshot()
@router.get("/projects")
def gitea_project_catalog(session: Session = Depends(get_session)):
"""Return the persisted owned-project registration catalog."""
return GiteaProjectAutoRegistration.catalog(session)
@router.post("/projects/reconcile")
def reconcile_gitea_project_catalog(session: Session = Depends(get_session)):
"""Refresh all owned Gitea projects and safely activate proven deploy targets."""
return GiteaProjectAutoRegistration().sync(session, force=True)
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get('/api/health')
def health():
return {'status': 'ok'}
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session
from ..core.config import settings
from ..core.security import n8n_auth, operator_auth
from ..db.session import get_session
from ..mcp_server import runtime_status as mcp_runtime_status
from ..services import knowledge_service, n8n_integration
router = APIRouter(prefix="/api/integrations")
n8n_router = APIRouter(prefix="/api/integrations/n8n", dependencies=[Depends(n8n_auth)])
class PreflightRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: str = Field(min_length=1, max_length=160)
target: str = Field(min_length=1, max_length=240)
class ApprovalRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: str = Field(min_length=1, max_length=160)
target: str = Field(min_length=1, max_length=240)
reason: str = Field(min_length=3, max_length=1000)
plan: list[str] = Field(default_factory=list, max_length=30)
class KnowledgeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
question: str = Field(min_length=2, max_length=500)
limit: int = Field(default=6, ge=1, le=10)
class HeartbeatRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
workflowId: str = Field(min_length=1, max_length=120)
workflowVersion: str = Field(default="1", max_length=40)
branch: str = Field(default="unknown", max_length=40)
processedEvents: int = Field(default=0, ge=0, le=100_000)
backlogRemaining: bool = False
confidence: str = Field(default="", max_length=20)
@router.get("/status", dependencies=[Depends(operator_auth)])
def status(session: Session = Depends(get_session)):
rag = knowledge_service.index_status(session)
mcp_runtime = mcp_runtime_status()
n8n_runtime = n8n_integration.heartbeat_status(session)
return {
"mcp": {
"enabled": settings.mcp_enabled,
"authenticated": bool(settings.mcp_token),
"transport": "streamable_http",
"endpoint": "/mcp",
"scope": "governed_operations",
"canRequestApproval": True,
"canExecuteApproved": True,
"requiresHumanApproval": True,
"maxConcurrentExecutions": 1,
**mcp_runtime,
},
"rag": rag,
"n8n": {
"enabled": settings.n8n_enabled,
"authenticated": bool(settings.n8n_token),
"endpoint": "/api/integrations/n8n",
"canExecute": False,
"capabilities": [
"event_feed",
"knowledge_query",
"preflight",
"create_approval",
"read_approval",
"read_job",
],
**n8n_runtime,
},
}
@n8n_router.post("/heartbeat")
def heartbeat(payload: HeartbeatRequest, session: Session = Depends(get_session)):
return n8n_integration.record_heartbeat(session, payload.model_dump())
@n8n_router.get("/events")
def events(
cursor: str | None = Query(default=None, max_length=600),
limit: int = Query(default=100, ge=1, le=250),
session: Session = Depends(get_session),
):
try:
return n8n_integration.event_feed(session, cursor=cursor, limit=limit)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@n8n_router.post("/knowledge/query")
def knowledge_query(payload: KnowledgeRequest, session: Session = Depends(get_session)):
try:
return knowledge_service.query(session, payload.question, limit=payload.limit)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@n8n_router.post("/preflight")
def preflight(payload: PreflightRequest, session: Session = Depends(get_session)):
return n8n_integration.preflight(session, payload.action, payload.target)
@n8n_router.post("/approvals")
def create_approval(
payload: ApprovalRequest,
idempotency_key: str = Header(
alias="Idempotency-Key", min_length=16, max_length=128
),
session: Session = Depends(get_session),
):
try:
return n8n_integration.request_approval(
session,
action=payload.action,
target=payload.target,
reason=payload.reason,
plan=payload.plan,
idempotency_key=idempotency_key,
)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@n8n_router.get("/approvals/{approval_id}")
def approval(approval_id: str, session: Session = Depends(get_session)):
result = n8n_integration.approval_status(session, approval_id)
if not result:
raise HTTPException(status_code=404, detail="approval_not_found")
return result
@n8n_router.get("/jobs/{job_id}")
def job(job_id: str, session: Session = Depends(get_session)):
result = n8n_integration.job_status(session, job_id)
if not result:
raise HTTPException(status_code=404, detail="job_not_found")
return result
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session
from ..db.session import get_session
from ..services.inventory_discovery import (
calculate_drift,
acknowledge_drift,
complete_app_review,
create_drift_incident,
get_run_detail,
ignore_discovered_container,
import_discovered_container,
list_discovered,
list_inventory_apps,
list_inventory_containers,
list_mappings,
list_runs,
list_suggestions,
list_unknown,
list_unmapped,
map_discovered_to_existing_app,
prune_historical_discovery_rows,
run_discovery,
sync_app_connector_statuses,
update_discovered_review_metadata,
update_inventory_baseline,
)
router = APIRouter(prefix='/api/inventory')
@router.post('/discover')
def discover(session: Session = Depends(get_session)):
return run_discovery(session=session)
@router.get('/runs')
def runs(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
session: Session = Depends(get_session),
):
return list_runs(session, limit=limit, offset=offset)
@router.get('/runs/{run_id}')
def run_detail(run_id: str, session: Session = Depends(get_session)):
result = get_run_detail(session, run_id)
if not result:
return {'success': False, 'message': 'discovery_run_not_found', 'id': run_id}
return result
@router.get('/discovered-containers')
def discovered_containers(session: Session = Depends(get_session)):
return list_discovered(session)
@router.get('/containers')
def inventory_containers(include_ignored: bool = True, session: Session = Depends(get_session)):
return list_inventory_containers(session, include_ignored=include_ignored)
@router.get('/apps')
def inventory_apps(include_unmanaged: bool = True, include_ignored: bool = False, session: Session = Depends(get_session)):
return list_inventory_apps(session, include_unmanaged=include_unmanaged, include_ignored=include_ignored)
@router.get('/suggestions')
def suggestions(session: Session = Depends(get_session)):
return list_suggestions(session)
@router.get('/unmapped')
def unmapped(include_ignored: bool = False, session: Session = Depends(get_session)):
return list_unmapped(session, include_ignored=include_ignored)
@router.get('/unknown')
def unknown(include_ignored: bool = False, session: Session = Depends(get_session)):
return list_unknown(session, include_ignored=include_ignored)
@router.get('/mappings')
def mappings(session: Session = Depends(get_session)):
return list_mappings(session)
@router.get('/drift')
def drift(session: Session = Depends(get_session)):
return calculate_drift(session)
@router.post('/drift/{drift_id}/acknowledge')
def acknowledge_inventory_drift(drift_id: str, session: Session = Depends(get_session)):
return acknowledge_drift(session, drift_id)
@router.post('/drift/{drift_id}/incident')
def create_inventory_drift_incident(drift_id: str, session: Session = Depends(get_session)):
return create_drift_incident(session, drift_id)
@router.post('/import')
def import_container(payload: dict, session: Session = Depends(get_session)):
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
return import_discovered_container(session, discovered_id)
@router.post('/ignore')
def ignore_container(payload: dict, session: Session = Depends(get_session)):
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
return ignore_discovered_container(session, discovered_id)
@router.post('/review/update')
def update_review_metadata(payload: dict, session: Session = Depends(get_session)):
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
return update_discovered_review_metadata(session, discovered_id, payload)
@router.post('/map-existing')
def map_existing(payload: dict, session: Session = Depends(get_session)):
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
app_id = str(payload.get('appId') or '')
return map_discovered_to_existing_app(session, discovered_id, app_id)
@router.post('/review/complete')
def complete_review(payload: dict | None = None, session: Session = Depends(get_session)):
app_ids = payload.get('appIds') if isinstance(payload, dict) else None
if not isinstance(app_ids, list):
app_ids = None
return complete_app_review(session, app_ids=[str(item) for item in app_ids] if app_ids else None)
@router.post('/maintenance/sync-status')
def sync_status(session: Session = Depends(get_session)):
return sync_app_connector_statuses(session)
@router.post('/maintenance/prune-discovery-history')
def prune_discovery_history(session: Session = Depends(get_session)):
return prune_historical_discovery_rows(session)
@router.post('/baseline/update')
def update_baseline(session: Session = Depends(get_session)):
return update_inventory_baseline(session)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from ..db.session import get_session
from ..services.audit_logger import log_event
from ..services.job_runner import cancel_job, get_job, list_jobs
router = APIRouter(prefix='/api/jobs')
@router.get('')
def list_all(session=Depends(get_session)):
return list_jobs(session)
@router.get('/{job_id}')
def get_single(job_id: str, session=Depends(get_session)):
job = get_job(session, job_id)
if not job:
return {'success': False, 'message': f'Job {job_id} not found'}
return job
@router.post('/{job_id}/cancel')
def cancel(job_id: str, session=Depends(get_session)):
job = get_job(session, job_id)
if not job:
return {'success': False, 'message': 'Job not found', 'id': job_id}
from ..services.privatemuse_deployment_recovery import (
privatemuse_recovery_job_cancellation_reason,
)
cancellation_reason = privatemuse_recovery_job_cancellation_reason(session, job)
if not cancellation_reason:
from ..services.workmesh_first_deployment import (
workmesh_first_deploy_job_cancellation_reason,
)
cancellation_reason = workmesh_first_deploy_job_cancellation_reason(session, job)
if cancellation_reason:
return {
'success': False,
'message': cancellation_reason,
'id': job.id,
'status': job.status,
}
if job.status in {'succeeded', 'failed', 'cancelled'}:
return {'success': False, 'message': f'Job already {job.status}', 'id': job.id}
previous_status = job.status
updated = cancel_job(session, job)
audit = log_event(
session,
source='manual',
identity='operator',
action_id='job.cancel',
target=updated.id,
target_type='job',
risk='low',
policy_decision='allowed',
reason='Operator cancelled an AppOps job record.',
result='success',
before_state={'status': previous_status},
after_state={'status': updated.status},
redacted_metadata={'jobId': updated.id, 'actionId': updated.action_id},
)
return {'success': True, 'id': updated.id, 'status': updated.status, 'auditId': audit.id}
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session
from ..core.security import operator_auth
from ..db.session import get_session
from ..services import knowledge_service
router = APIRouter(prefix="/api/ai/rag", dependencies=[Depends(operator_auth)])
class RagQueryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
question: str = Field(min_length=2, max_length=500)
limit: int = Field(default=6, ge=1, le=10)
@router.get("/status")
def status(session: Session = Depends(get_session)):
return knowledge_service.index_status(session)
@router.post("/reindex")
def reindex(session: Session = Depends(get_session)):
return knowledge_service.rebuild_index(session)
@router.post("/query")
def query(payload: RagQueryRequest, session: Session = Depends(get_session)):
try:
return knowledge_service.query(session, payload.question, limit=payload.limit)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.get("/evaluate")
def evaluate(session: Session = Depends(get_session)):
return knowledge_service.evaluate_retrieval(session)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..db.session import get_session
from ..services import media_pipeline_service
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/media-pipeline', dependencies=[Depends(load_connector_runtime)])
@router.get('/summary')
def summary(refresh: bool = False, session: Session = Depends(get_session)):
return media_pipeline_service.summary(session, force_refresh=refresh)
@router.get('/health')
def health(refresh: bool = False, session: Session = Depends(get_session)):
return media_pipeline_service.summary(session, force_refresh=refresh)
@router.get('/flow')
def flow(session: Session = Depends(get_session)):
return media_pipeline_service.flow(session)
@router.get('/issues')
def issues(session: Session = Depends(get_session)):
return media_pipeline_service.issues(session)
@router.get('/downloads')
def downloads(session: Session = Depends(get_session)):
return media_pipeline_service.downloads(session)
@router.get('/library')
def library(session: Session = Depends(get_session)):
return media_pipeline_service.library(session)
@router.get('/activity')
def activity():
return media_pipeline_service.activity()
@router.get('/recommendations')
def recommendations(session: Session = Depends(get_session)):
return media_pipeline_service.recommendations(session)
@router.post('/check')
def check(session: Session = Depends(get_session)):
return media_pipeline_service.check(session)
@router.post('/diagnose')
def diagnose(payload: dict | None = None, session: Session = Depends(get_session)):
return media_pipeline_service.diagnose(session, payload or {})
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/nginx-proxy-manager', dependencies=[Depends(load_connector_runtime)])
alias_router = APIRouter(prefix='/api/apps/npm', dependencies=[Depends(load_connector_runtime)])
def _shape(payload: dict[str, Any]) -> dict[str, Any]:
data = payload.get('data')
if not isinstance(data, dict):
data = {key: value for key, value in payload.items() if key not in {'success', 'status', 'message'}}
return {
**payload,
'success': bool(payload.get('success')),
'status': str(payload.get('status', 'ok')),
'message': str(payload.get('message', 'ok')),
'data': data,
}
def _connector() -> NginxProxyManagerConnector:
return NginxProxyManagerConnector()
@router.get('/health')
def health():
return _shape(_connector().health())
@router.get('/proxy-hosts')
def proxy_hosts():
return _shape(_connector().proxy_hosts())
@router.get('/certificates')
def certificates():
return _shape(_connector().certificates())
@router.get('/certificates/expiring')
def expiring_certificates(days: int = 30):
return _shape(_connector().expiring_certificates(days))
@router.get('/access-lists')
def access_lists():
return _shape(_connector().access_lists())
@router.get('/exposure-summary')
def exposure_summary():
return _shape(_connector().exposure_summary())
@router.get('/host-reachability')
def host_reachability():
return _shape(_connector().host_reachability())
@router.get('/security-warnings')
def security_warnings():
return _shape(_connector().security_warnings())
@alias_router.get('/health')
def alias_health():
return health()
@alias_router.get('/proxy-hosts')
def alias_proxy_hosts():
return proxy_hosts()
@alias_router.get('/certificates')
def alias_certificates():
return certificates()
@alias_router.get('/certificates/expiring')
def alias_expiring_certificates(days: int = 30):
return expiring_certificates(days)
@alias_router.get('/access-lists')
def alias_access_lists():
return access_lists()
@alias_router.get('/exposure-summary')
def alias_exposure_summary():
return exposure_summary()
@alias_router.get('/host-reachability')
def alias_host_reachability():
return host_reachability()
@alias_router.get('/security-warnings')
def alias_security_warnings():
return security_warnings()
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from ..db.session import get_session
from ..services import notification_service
router = APIRouter(prefix='/api/notifications')
@router.get('/rules')
def rules(session: Session = Depends(get_session)):
return notification_service.list_rules(session)
@router.post('/rules')
def create_rule(payload: dict, session: Session = Depends(get_session)):
return notification_service.create_rule(session, payload)
@router.patch('/rules/{rule_id}')
def update_rule(rule_id: str, payload: dict, session: Session = Depends(get_session)):
result = notification_service.update_rule(session, rule_id, payload)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='rule_not_found')
return result
@router.delete('/rules/{rule_id}')
def delete_rule(rule_id: str, session: Session = Depends(get_session)):
result = notification_service.delete_rule(session, rule_id)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='rule_not_found')
return result
@router.get('/events')
def events(
limit: int = Query(default=250, ge=1, le=500),
offset: int = Query(default=0, ge=0),
include_dismissed: bool = Query(default=True),
session: Session = Depends(get_session),
):
return notification_service.list_events(
session,
include_dismissed=include_dismissed,
limit=limit,
offset=offset,
)
@router.get('/events/unread')
def unread_events(
limit: int = Query(default=250, ge=1, le=500),
offset: int = Query(default=0, ge=0),
session: Session = Depends(get_session),
):
return notification_service.unread_events(session, limit=limit, offset=offset)
@router.post('/events/{event_id}/dismiss')
def dismiss_event(event_id: str, session: Session = Depends(get_session)):
result = notification_service.dismiss_event(session, event_id)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='event_not_found')
return result
@router.post('/events/{event_id}/mark-read')
def mark_read(event_id: str, session: Session = Depends(get_session)):
result = notification_service.mark_read(session, event_id)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='event_not_found')
return result
@router.post('/events/mark-all-read')
def mark_all_read(session: Session = Depends(get_session)):
return notification_service.mark_all_read(session)
@router.get('/channels')
def channels(session: Session = Depends(get_session)):
return notification_service.list_channels(session)
@router.post('/channels')
def create_channel(payload: dict, session: Session = Depends(get_session)):
return notification_service.create_channel(session, payload)
@router.patch('/channels/{channel_id}')
def update_channel(channel_id: str, payload: dict, session: Session = Depends(get_session)):
result = notification_service.update_channel(session, channel_id, payload)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='channel_not_found')
return result
@router.post('/channels/{channel_id}/test')
def test_channel(channel_id: str, session: Session = Depends(get_session)):
result = notification_service.test_channel(session, channel_id)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='channel_not_found')
return result
@router.post('/channels/{channel_id}/test-connection')
def test_channel_connection(channel_id: str, session: Session = Depends(get_session)):
result = notification_service.test_channel_connection(session, channel_id)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='channel_not_found')
return result
@router.post('/evaluate')
def evaluate(session: Session = Depends(get_session)):
return notification_service.evaluate_source(session, 'all')
@router.post('/evaluate/safety')
def evaluate_safety(session: Session = Depends(get_session)):
return notification_service.evaluate_source(session, 'safety')
@router.post('/evaluate/media')
def evaluate_media(session: Session = Depends(get_session)):
return notification_service.evaluate_source(session, 'media')
@router.post('/evaluate/connectors')
def evaluate_connectors(session: Session = Depends(get_session)):
return notification_service.evaluate_source(session, 'connectors')
@router.get('/summary')
def summary(session: Session = Depends(get_session)):
return notification_service.summary(session)
+633
View File
@@ -0,0 +1,633 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
import hashlib
import json
import os
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.portainer import PortainerConnector
from ..connectors.unraid import UnraidConnector
from ..core.config import settings
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..db.models import AppSetting, AuditEvent, Connector, JobRun, Policy, Secret
from ..db.session import get_session
from ..services.incident_service import list_incidents as list_live_incidents, update_incident_status as update_live_incident_status
from ..services.app_registry import list_active_apps
from ..services.setup_service import apply_stored_connector_settings
router = APIRouter(prefix='/api')
NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION = 1
NETWORK_EXPOSURE_CACHE_KEY = 'network_exposure:cache'
NETWORK_EXPOSURE_CACHE_TTL_SECONDS = 60
NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS = 300
STATISTICS_HISTORY_MAX_POINTS = 48
_STATISTICS_HISTORY: list[dict[str, Any]] = []
@router.get('/incidents')
def list_incidents(session: Session = Depends(get_session)):
return list_live_incidents(session)
@router.post('/incidents/{incident_id}/status')
def update_incident_status(incident_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
status = str(payload.get('status') or 'acknowledged') if isinstance(payload, dict) else 'acknowledged'
return update_live_incident_status(session, incident_id, status)
@router.get('/operations-map')
def operations_map(session: Session = Depends(get_session)):
apps = {app.id: app for app in list_active_apps(session, order_by='id')}
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
policies = session.exec(select(Policy).order_by(Policy.id)).all()
secrets_count = len(session.exec(select(Secret.id)).all())
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
nodes: set[str] = {'Connector Registry', 'Policy Engine', 'Secret Store'}
edges: set[tuple[str, str]] = {('Secret Store', 'Connector Registry')}
for connector in connectors:
app = apps.get(connector.id)
if app is None:
continue
nodes.add(app.name)
edges.add(('Connector Registry', app.name))
if connector.secret_ref:
edges.add(('Secret Store', app.name))
for policy in policies:
if policy.target == '*':
continue
app = apps.get(policy.target)
if app is None:
continue
nodes.add(app.name)
edges.add(('Policy Engine', app.name))
return {
'generatedAt': generated_at,
'sources': {
'apps': {'status': 'ok', 'count': len(apps)},
'connectors': {'status': 'ok', 'count': len(connectors)},
'policies': {'status': 'ok', 'count': len(policies)},
'secrets': {'status': 'metadata_only', 'count': secrets_count},
},
'nodes': sorted(nodes),
'edges': [
{'from': source, 'to': target}
for source, target in sorted(edges)
if source in nodes and target in nodes
],
}
def _nullable_int(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int | float):
return int(round(float(value)))
if isinstance(value, str):
stripped = value.strip().strip('%')
if not stripped:
return None
try:
return int(round(float(stripped)))
except ValueError:
return None
return None
def _record_statistics_sample(generated_at: str, cpu_usage: int | None, ram_usage: int | None) -> list[dict[str, Any]]:
if cpu_usage is None and ram_usage is None:
return list(_STATISTICS_HISTORY)
_STATISTICS_HISTORY.append({'time': generated_at, 'cpu': cpu_usage, 'ram': ram_usage})
if len(_STATISTICS_HISTORY) > STATISTICS_HISTORY_MAX_POINTS:
del _STATISTICS_HISTORY[:-STATISTICS_HISTORY_MAX_POINTS]
return list(_STATISTICS_HISTORY)
@router.get('/statistics/summary')
def statistics_summary(session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
metrics = UnraidConnector().get_system_metrics()
cpu_usage = _nullable_int(metrics.get('cpuUsage'))
ram_usage = _nullable_int(metrics.get('ramUsage'))
samples = _record_statistics_sample(generated_at, cpu_usage, ram_usage)
source_warnings = []
if metrics.get('status') != 'ok':
source_warnings.append({
'source': 'unraid',
'surface': 'metrics',
'status': metrics.get('status', 'unknown'),
'reason': redact_message(str(metrics.get('reason', 'metrics_unavailable')))[:240],
})
audit_events = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all()
jobs = session.exec(select(JobRun).order_by(JobRun.started_at.desc())).all()
restarts: dict[str, int] = {}
durations_ms: list[int] = []
for job in jobs:
if job.action_id == 'container.restart':
restarts[job.target] = restarts.get(job.target, 0) + 1
if job.status == 'succeeded' and job.duration_ms is not None:
durations_ms.append(int(job.duration_ms))
blocked_actions = sum(
1
for event in audit_events
if event.policy_decision == 'blocked' or event.result == 'blocked'
)
voice_action_count = sum(1 for event in audit_events if event.source == 'voice' or event.identity == 'voice')
mttr_hours = None
if durations_ms:
mttr_hours = round((sum(durations_ms) / len(durations_ms)) / 1000 / 60 / 60, 2)
return {
'generatedAt': generated_at,
'sources': {'unraid': metrics.get('status', 'unknown'), 'audit': 'ok', 'jobs': 'ok'},
'sourceWarnings': source_warnings,
'cpuHistory': [{'time': sample['time'], 'value': sample.get('cpu')} for sample in samples],
'ramHistory': [{'time': sample['time'], 'value': sample.get('ram')} for sample in samples],
'restartsByApp': [
{'appId': app_id, 'count': count}
for app_id, count in sorted(restarts.items(), key=lambda item: (-item[1], item[0]))
],
'blockedActions': blocked_actions,
'voiceActionCount': voice_action_count,
'mttrHours': mttr_hours,
}
@router.get('/storage/summary')
def storage_summary(session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
unraid = UnraidConnector()
portainer = PortainerConnector()
cache_usage = 0
storage_pools: list[dict] = []
shares: list[dict] = []
disks: list[dict] = []
app_data_usage: list[dict] = []
source_warnings: list[dict] = []
cache_result = unraid.get_cache_status()
if cache_result.get('status') == 'ok':
cache_usage = int(cache_result.get('cacheUsage', 0) or 0)
storage_pools = [
{
'name': str(pool.get('name', 'cache')),
'status': str(pool.get('status', 'unknown')),
'healthy': pool.get('healthy'),
'type': pool.get('type'),
'usedGiB': pool.get('usedGiB'),
'sizeGiB': pool.get('sizeGiB'),
'freeGiB': pool.get('freeGiB'),
'usagePercent': pool.get('usagePercent'),
}
for pool in cache_result.get('pools', [])
if isinstance(pool, dict)
]
elif cache_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'unraid',
'surface': 'cache',
'status': cache_result.get('status', 'error'),
'reason': redact_message(str(cache_result.get('reason', 'cache_unavailable')))[:240],
})
share_result = unraid.get_share_summary()
if share_result.get('status') == 'ok':
shares = [
{
'name': str(share.get('name', 'unknown')),
'usedGiB': float(share.get('usedGiB', 0) or 0),
'sizeGiB': float(share.get('sizeGiB', 0) or 0),
'critical': bool(share.get('critical', False)),
}
for share in share_result.get('shares', [])
if isinstance(share, dict)
]
elif share_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'unraid',
'surface': 'shares',
'status': share_result.get('status', 'error'),
'reason': redact_message(str(share_result.get('reason', 'shares_unavailable')))[:240],
})
disk_result = unraid.get_disk_warnings()
if disk_result.get('status') == 'ok':
disks = [
{
'name': str(disk.get('name', 'unknown')),
'status': str(disk.get('status', 'unknown')),
'healthy': disk.get('healthy'),
'type': disk.get('type'),
'usedGiB': disk.get('usedGiB'),
'sizeGiB': disk.get('sizeGiB'),
'freeGiB': disk.get('freeGiB'),
'temperature': disk.get('temperature'),
}
for disk in disk_result.get('disks', [])
if isinstance(disk, dict)
]
elif disk_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'unraid',
'surface': 'disks',
'status': disk_result.get('status', 'error'),
'reason': redact_message(str(disk_result.get('reason', 'disks_unavailable')))[:240],
})
share_names = {
str(share.get('name', '')).strip('/').lower(): share
for share in shares
if str(share.get('name', '')).strip()
}
containers_result = portainer.list_containers()
if containers_result.get('status') == 'ok':
seen: set[str] = set()
for container in containers_result.get('containers', []):
if not isinstance(container, dict):
continue
app_id = str(container.get('appId') or container.get('name') or '').strip()
if not app_id:
continue
container_name = str(container.get('name') or app_id).strip()
mounts = container.get('mounts', [])
if not isinstance(mounts, list) or not mounts:
key = f'{app_id}:{container_name}:container'
if key in seen:
continue
seen.add(key)
app_data_usage.append({
'appId': app_id,
'container': container_name,
'usedGiB': None,
'volume': container_name,
'source': None,
'destination': None,
'type': 'container',
'share': None,
'measurement': 'not_available',
})
continue
if isinstance(mounts, list):
for mount in mounts:
if not isinstance(mount, dict):
continue
source = str(mount.get('source') or '').strip()
destination = str(mount.get('destination') or '').strip()
mount_type = str(mount.get('type') or '').strip() or 'unknown'
if not source and not destination:
continue
key = f'{app_id}:{container_name}:{source}:{destination}'
if key in seen:
continue
seen.add(key)
parts = [part for part in source.replace('\\', '/').split('/') if part]
share_name = parts[2] if len(parts) >= 3 and parts[0] == 'mnt' and parts[1] in {'user', 'cache'} else None
share = share_names.get(str(share_name or '').lower())
app_data_usage.append({
'appId': app_id,
'container': container_name,
'usedGiB': None,
'volume': f'{source or mount_type} -> {destination or "container"}',
'source': source or None,
'destination': destination or None,
'type': mount_type,
'share': share_name,
'shareUsagePercent': round((float(share.get('usedGiB', 0) or 0) / float(share.get('sizeGiB', 0) or 1)) * 100) if share and float(share.get('sizeGiB', 0) or 0) > 0 else None,
'measurement': 'mount_discovered',
})
elif containers_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'portainer',
'surface': 'containers',
'status': containers_result.get('status', 'error'),
'reason': redact_message(str(containers_result.get('reason', 'containers_unavailable')))[:240],
})
total_size = sum(float(share.get('sizeGiB', 0) or 0) for share in shares)
total_used = sum(float(share.get('usedGiB', 0) or 0) for share in shares)
array_usage = round((total_used / total_size) * 100) if total_size > 0 else 0
return {
'cacheUsage': cache_usage,
'arrayUsage': array_usage,
'disks': disks,
'storagePools': storage_pools,
'shares': shares,
'appDataUsage': app_data_usage,
'orphanedVolumes': [],
'forecastDays': None,
'sources': {
'unraid': share_result.get('status', cache_result.get('status', 'unknown')),
'unraidDisks': disk_result.get('status', 'unknown'),
'portainer': containers_result.get('status', 'unknown'),
},
'sourceWarnings': source_warnings,
}
def _parse_utc(value: str) -> datetime:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
def _network_cache_ttl_seconds() -> int:
raw = os.getenv('NETWORK_EXPOSURE_CACHE_TTL_SECONDS', str(NETWORK_EXPOSURE_CACHE_TTL_SECONDS))
try:
return max(1, int(raw))
except (TypeError, ValueError):
return NETWORK_EXPOSURE_CACHE_TTL_SECONDS
def _network_cache_max_stale_seconds() -> int:
raw = os.getenv('NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS', str(NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS))
try:
return max(0, int(raw))
except (TypeError, ValueError):
return NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS
def _redact(value):
if isinstance(value, str):
return redact_message(value)
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, dict):
return {
str(key): _redact(item)
for key, item in value.items()
if 'secret' not in str(key).lower() and 'token' not in str(key).lower() and 'password' not in str(key).lower()
}
return value
def _network_exposure_signature(session: Session) -> str:
connectors = [
{
'id': item.id,
'status': item.status,
'lastTest': item.last_test,
'secretRef': item.secret_ref,
'capabilities': item.capabilities,
}
for item in session.exec(select(Connector)).all()
if item.id in {'nginx_proxy_manager', 'authentik'}
]
secret_rows = [
{
'id': item.id,
'name': item.name,
'updatedAt': item.updated_at.isoformat() if item.updated_at else None,
'usedBy': item.used_by_json,
}
for item in session.exec(select(Secret)).all()
if any(connector_id in item.used_by_json for connector_id in ('nginx_proxy_manager', 'authentik'))
]
signature_payload = {
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
'connectors': sorted(connectors, key=lambda item: item['id']),
'secrets': sorted(secret_rows, key=lambda item: item['id']),
}
serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':'))
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
def _persistent_network_exposure(session: Session, signature: str) -> list[dict] | None:
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
if not row or not isinstance(row.value_json, dict):
return None
value = row.value_json
if value.get('schemaVersion') != NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION or value.get('signature') != signature:
return None
payload = value.get('payload')
expires_at_raw = value.get('expiresAt')
if not isinstance(payload, list) or not isinstance(expires_at_raw, str):
return None
expires_at = _parse_utc(expires_at_raw)
now_dt = datetime.now(UTC)
stale_until = expires_at.timestamp() + _network_cache_max_stale_seconds()
if now_dt >= expires_at and now_dt.timestamp() >= stale_until:
return None
return [item for item in payload if isinstance(item, dict)]
def _store_persistent_network_exposure(session: Session, signature: str, payload: list[dict]) -> None:
ttl_seconds = _network_cache_ttl_seconds()
expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z')
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
if not row:
row = AppSetting(key=NETWORK_EXPOSURE_CACHE_KEY, is_secret=False)
row.value_json = {
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
'signature': signature,
'payload': _redact(payload),
'generatedAt': datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'),
'expiresAt': expires_at,
}
row.updated_at = utc_now()
session.add(row)
session.commit()
def _build_network_exposure() -> list[dict]:
with ThreadPoolExecutor(max_workers=3) as executor:
hosts_future = executor.submit(lambda: NginxProxyManagerConnector().proxy_hosts())
certs_future = executor.submit(lambda: NginxProxyManagerConnector().certificates())
coverage_future = executor.submit(_auth_coverage_by_host)
hosts_payload = hosts_future.result()
certs_payload = certs_future.result()
coverage_by_host = coverage_future.result()
if not hosts_payload.get('success'):
return []
certs = certs_payload.get('items', []) if certs_payload.get('success') else []
cert_by_id = {item.get('id'): item for item in certs if isinstance(item, dict)}
exposures = []
for host in hosts_payload.get('items', []):
if not isinstance(host, dict):
continue
domains = host.get('domainNames') if isinstance(host.get('domainNames'), list) else []
label = ', '.join(str(item) for item in domains if str(item).strip()) or f"proxy-host-{host.get('id', 'unknown')}"
certificate = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None
has_certificate = bool(host.get('certificateId'))
certificate_expired = bool(certificate and certificate.get('isExpired'))
certificate_expiring = bool(certificate and certificate.get('isExpiringSoon'))
coverage = coverage_by_host.get(str(host.get('id')))
auth_status = str((coverage or {}).get('coverageStatus') or 'unknown')
auth_protected = auth_status in {'protected', 'likely_protected'}
npm_protected = bool(host.get('accessListId'))
protected = npm_protected or auth_protected
protection_source = 'authentik' if auth_protected else ('npm_access_list' if npm_protected else 'none')
enabled = bool(host.get('enabled'))
ssl_forced = bool(host.get('sslForced'))
risk_reasons: list[str] = []
if enabled and not protected:
risk_reasons.append('No NPM access list or Authentik match')
if enabled and not has_certificate:
risk_reasons.append('No certificate assigned')
if certificate_expired:
risk_reasons.append('Certificate expired')
elif certificate_expiring:
risk_reasons.append('Certificate expiring soon')
if enabled and has_certificate and not ssl_forced:
risk_reasons.append('SSL is not forced')
if not enabled:
risk = 'low'
elif certificate_expired:
risk = 'critical'
elif not protected or not has_certificate:
risk = 'high'
elif not ssl_forced or certificate_expiring:
risk = 'medium'
else:
risk = 'low'
exposures.append({
'source': 'reverse_proxy',
'host': label,
'port': int(host.get('forwardPort') or 0),
'protocol': str(host.get('forwardScheme') or 'http'),
'protected': protected,
'protectionSource': protection_source,
'authCoverageStatus': auth_status,
'authentikMatches': (coverage or {}).get('authentikMatches') or [],
'appId': str(host.get('forwardHost') or ''),
'risk': risk,
'riskReasons': risk_reasons,
'externalChangeRequired': bool(risk_reasons),
})
return exposures
@router.get('/network/exposure')
def network_exposure(refresh: bool = False, session: Session = Depends(get_session)):
apply_stored_connector_settings(session, {'nginx_proxy_manager', 'authentik'})
signature = _network_exposure_signature(session)
if not refresh:
persisted = _persistent_network_exposure(session, signature)
if persisted is not None:
return persisted
exposures = _build_network_exposure()
_store_persistent_network_exposure(session, signature, exposures)
return exposures
def _auth_coverage_by_host() -> dict[str, dict]:
try:
payload = AuthentikConnector().auth_coverage()
except Exception:
return {}
if not payload.get('success'):
return {}
result: dict[str, dict] = {}
for item in payload.get('publicHosts', []):
if not isinstance(item, dict):
continue
host_id = item.get('hostId')
if host_id is None:
continue
result[str(host_id)] = item
return result
@router.get('/healthchecks')
def healthchecks(session: Session = Depends(get_session)):
checked_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
connectors = {connector.id: connector for connector in session.exec(select(Connector)).all()}
return [
{'service': 'API Gateway', 'status': 'pass', 'checkedAt': checked_at, 'details': 'DB en route registratie OK'},
_connector_healthcheck('Portainer', connectors.get('portainer'), checked_at),
_connector_healthcheck('Unraid', connectors.get('unraid'), checked_at),
_voice_bridge_healthcheck(connectors.get('home_assistant'), checked_at),
]
def _connector_healthcheck(service: str, connector: Connector | None, checked_at: str) -> dict:
if connector is None:
return {
'service': service,
'status': 'warn',
'checkedAt': checked_at,
'details': 'Niet aanwezig in connector registry.',
}
status = str(connector.status or 'unknown').lower()
if status == 'connected':
return {
'service': service,
'status': 'pass',
'checkedAt': checked_at,
'details': 'Connector registry meldt verbonden.',
}
if status in {'missing_config', 'disconnected', 'unknown'}:
return {
'service': service,
'status': 'warn',
'checkedAt': checked_at,
'details': f'Connector registry meldt {status}.',
}
return {
'service': service,
'status': 'fail',
'checkedAt': checked_at,
'details': f'Connector registry meldt {status}.',
}
def _voice_bridge_healthcheck(home_assistant: Connector | None, checked_at: str) -> dict:
if not settings.voice_token:
return {
'service': 'Voice Bridge',
'status': 'warn',
'checkedAt': checked_at,
'details': 'Voice Bridge is niet geactiveerd.',
}
if home_assistant is None:
return {
'service': 'Voice Bridge',
'status': 'warn',
'checkedAt': checked_at,
'details': 'Home Assistant connector ontbreekt.',
}
status = str(home_assistant.status or 'unknown').lower()
if status == 'connected':
return {
'service': 'Voice Bridge',
'status': 'pass',
'checkedAt': checked_at,
'details': 'Home Assistant connector beschikbaar voor voice exports.',
}
if status in {'missing_config', 'disconnected', 'unknown'}:
return {
'service': 'Voice Bridge',
'status': 'warn',
'checkedAt': checked_at,
'details': f'Home Assistant connector meldt {status}.',
}
return {
'service': 'Voice Bridge',
'status': 'fail',
'checkedAt': checked_at,
'details': f'Home Assistant connector meldt {status}.',
}
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..db.models import Connector, Policy
from ..db.session import get_session
router = APIRouter(prefix='/api')
def _connector_names(connectors: list[Connector], ids: set[str]) -> list[str]:
return [connector.name for connector in connectors if connector.id in ids]
def _policy_risk(session: Session, action: str, target: str, fallback: str = 'medium') -> str:
policy = session.exec(select(Policy).where(Policy.action == action, Policy.target == target)).first()
if policy and policy.risk in {'low', 'medium', 'high', 'critical'}:
return policy.risk
return fallback
def _connector_labels(connectors: list[Connector], ids: set[str]) -> list[str]:
labels = _connector_names(connectors, ids)
known = {connector.id for connector in connectors}
for connector_id in sorted(ids - known):
labels.append(connector_id.replace('_', ' ').title())
return labels
def _success_rate(connectors: dict[str, Connector], ids: set[str], fallback: int = 80) -> int:
if not ids:
return fallback
present = [connectors.get(connector_id) for connector_id in ids]
connected = sum(1 for connector in present if connector and connector.status == 'connected')
return round((connected / len(ids)) * 100)
@router.get('/playbooks')
def list_playbooks(session: Session = Depends(get_session)):
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
degraded = [
connector
for connector in connectors
if connector.status in {'missing_config', 'disconnected', 'error', 'auth_failed', 'unreachable'}
]
connector_by_id = {connector.id: connector for connector in connectors}
playbooks: list[dict] = []
playbooks.append({
'id': 'storage-pressure-review',
'name': 'Storage Pressure Review',
'description': 'Brengt array-, cache-, disk-, share- en container-mountdruk samen zonder storage-mutaties te starten.',
'risk': 'medium',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': _connector_labels(connectors, {'unraid', 'portainer'}),
'steps': [
'Open Storage & Volumes and review array/cache pressure',
'Inspect disks and critical shares above threshold',
'Compare Portainer mount hints with Unraid share usage',
'Open Safety or Remediation Planner for non-executing follow-up notes',
'Keep mover, prune and storage mutations outside this playbook',
],
'successRate': _success_rate(connector_by_id, {'unraid', 'portainer'}),
})
playbooks.append({
'id': 'exposure-auth-coverage-review',
'name': 'Exposure & Auth Coverage Review',
'description': 'Controleert NPM public hosts, SSL-force, certificaten en Authentik coverage als één securityroute.',
'risk': 'high',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': _connector_labels(connectors, {'nginx_proxy_manager', 'authentik'}),
'steps': [
'Open Exposure Center and sort high-risk public hosts first',
'Check SSL-force, certificate status and access-list evidence',
'Open matching Authentik coverage details for unprotected hosts',
'Create or review a remediation draft before touching external systems',
'Re-run Safety refresh after operator-side NPM/Auth changes',
],
'successRate': _success_rate(connector_by_id, {'nginx_proxy_manager', 'authentik'}),
})
playbooks.append({
'id': 'media-stalled-download-review',
'name': 'Media Pipeline Stalled Download Review',
'description': 'Volgt Sonarr/Radarr wanted items, Prowlarr health en Deluge stalled downloads zonder data te verwijderen.',
'risk': 'medium',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': _connector_labels(connectors, {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}),
'steps': [
'Open Media Pipeline summary and review stalled/wanted counters',
'Check Prowlarr indexers and Sonarr/Radarr queue status',
'Inspect Deluge stalled downloads and pause/resume policy guidance',
'Review Plex/Tautulli activity before starting scans',
'Use approval-gated actions only for non-destructive connector operations',
],
'successRate': _success_rate(connector_by_id, {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}),
})
playbooks.append({
'id': 'unhealthy-container-triage',
'name': 'Unhealthy Container Triage',
'description': 'Richt de operator op ongezonde of ongemapte containers met logs, Portainer deep-link en policy-aware restartpad.',
'risk': 'medium',
'allowedSources': ['manual'],
'requiredConnectors': _connector_labels(connectors, {'portainer'}),
'steps': [
'Open Containers with attention filter',
'Inspect health, import status, app mapping and redacted logs',
'Open Portainer deep-link only when live context is available',
'Request approval before restarting protected apps',
'Verify the container and app detail after any approved action',
],
'successRate': _success_rate(connector_by_id, {'portainer'}),
})
playbooks.append({
'id': 'app-inventory-normalization',
'name': 'App Inventory Normalization',
'description': 'Werkt Unknown, needs-review en no-connector apps systematisch weg zodat Apps minder ruis tonen.',
'risk': 'low',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': _connector_labels(connectors, {'portainer'}),
'steps': [
'Open Apps with attention filter',
'Review Unknown and needs-review app cards first',
'Map discovered containers to canonical apps where the match is clear',
'Bind connector candidates only after connector config has tested connected',
'Re-run inventory discovery and compare drift after review',
],
'successRate': _success_rate(connector_by_id, {'portainer'}),
})
playbooks.append({
'id': 'backup-readiness-check',
'name': 'Backup Readiness Check',
'description': 'Controleert SQLite backup posture, restore-notes en release hygiene zonder restore uit te voeren.',
'risk': 'low',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': ['AppOps DB'],
'steps': [
'Open Settings backups section',
'Review latest backup metadata and integrity status',
'Confirm restore remains manual stop-container flow',
'Open Audit for recent backup events',
'Update release notes after verified production changes',
],
'successRate': 100,
})
playbooks.append({
'id': 'notification-delivery-review',
'name': 'Notification Delivery Review',
'description': 'Controleert dashboard- en Home Assistant notificatiebereik zonder automatisch testberichten te verzenden.',
'risk': 'low',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': _connector_labels(connectors, {'home_assistant'}),
'steps': [
'Open Notifications and review active rules, channels and unread events',
'Validate Home Assistant readiness only when the operator enabled the channel',
'Run explicit evaluation for safety/media events when stale notifications appear',
'Keep external delivery opt-in and audited',
],
'successRate': _success_rate(connector_by_id, {'home_assistant'}, fallback=70),
})
if degraded:
degraded_names = [connector.name for connector in degraded]
playbooks.append({
'id': 'connector-readiness-review',
'name': 'Connector Readiness Review',
'description': 'Controleert welke connectors configuratie, binding of health-herstel nodig hebben zonder wijzigingen uit te voeren.',
'risk': 'medium',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': degraded_names,
'steps': [
f"Review {len(degraded)} degraded connector(s): {', '.join(degraded_names[:8])}",
'Open connector setup for missing_config entries',
'Run read-only connector tests',
'Review policy and audit status before any follow-up action',
],
'successRate': max(0, 100 - (len(degraded) * 10)),
})
if {'portainer', 'widefrog'} <= set(connector_by_id):
widefrog_ready = connector_by_id['widefrog'].status == 'connected'
portainer_ready = connector_by_id['portainer'].status == 'connected'
playbooks.append({
'id': 'fix-widefrog',
'name': 'Fix Widefrog',
'description': 'Bereidt een veilige Widefrog herstelprocedure voor met read-only checks en policy-gated vervolgstappen.',
'risk': _policy_risk(session, 'widefrog.retry_failed', 'widefrog', 'medium'),
'allowedSources': ['manual', 'voice'],
'requiredConnectors': _connector_names(connectors, {'portainer', 'widefrog'}),
'steps': [
'Read Portainer container status',
'Read Widefrog health, queue and failed jobs',
'Inspect recent redacted logs when available',
'Draft follow-up action only when policy allows it',
'Require approval for retry or restart when policy demands it',
'Write audit result after an approved execution path',
],
'successRate': 90 if widefrog_ready and portainer_ready else 50,
})
if 'unraid' in connector_by_id:
unraid_ready = connector_by_id['unraid'].status == 'connected'
playbooks.append({
'id': 'unraid-health-scan',
'name': 'Unraid Health Scan',
'description': 'Leest array, disk, cache en docker-status uit zonder muterende acties te starten.',
'risk': 'low',
'allowedSources': ['manual', 'automation'],
'requiredConnectors': _connector_names(connectors, {'unraid'}),
'steps': [
'Read array status',
'Read parity status',
'Check disk warning count',
'Check cache usage',
'Check docker service status',
'Write read-only audit summary',
],
'successRate': 100 if unraid_ready else 60,
})
return playbooks
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.plex import PlexConnector
from ..db.session import get_session
from .connector_runtime import load_connector_runtime
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
from ..services.policy_engine import evaluate_policy
router = APIRouter(prefix='/api/apps/plex', dependencies=[Depends(load_connector_runtime)])
_SCAN_LIBRARY_STEPS = [
'Validate library id',
'Evaluate policy',
'Check Plex health',
'Fetch library status',
'Trigger scan library',
'Verify accepted or return status',
'Complete audit/job',
]
_SCAN_ALL_STEPS = [
'Evaluate policy',
'Check Plex health',
'Fetch libraries',
'Trigger scan for each library',
'Complete audit/job',
]
@router.get('/health')
def health():
return PlexConnector().health()
@router.get('/server')
def server():
return PlexConnector().server_status()
@router.get('/libraries')
def libraries():
return PlexConnector().library_status()
@router.get('/sessions')
def sessions():
return PlexConnector().active_sessions()
@router.get('/recent')
def recent():
return PlexConnector().recent_media()
@router.get('/streams')
def streams():
return PlexConnector().stream_summary()
@router.post('/libraries/{library_id}/scan')
def scan_library(library_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_plex_action(
session=session,
payload=payload or {},
action='plex.scan_library',
steps=_SCAN_LIBRARY_STEPS,
connector_call=lambda connector: connector.scan_library(library_id),
default_success_message='scan_started',
metadata={'libraryId': library_id},
)
@router.post('/libraries/scan-all')
def scan_all_libraries(payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_plex_action(
session=session,
payload=payload or {},
action='plex.scan_all_libraries',
steps=_SCAN_ALL_STEPS,
connector_call=lambda connector: connector.scan_all_libraries(),
default_success_message='scan_all_started',
metadata={'scope': 'all'},
)
def _execute_plex_action(
*,
session: Session,
payload: dict[str, Any],
action: str,
steps: list[str],
connector_call: Callable[[PlexConnector], dict[str, Any]],
default_success_message: str,
metadata: dict[str, Any],
) -> dict[str, Any]:
target = 'plex'
source = str(payload.get('source', 'manual')).strip() or 'manual'
policy = evaluate_policy(session=session, action=action, target=target, source=source)
risk = str(policy.get('risk', 'medium'))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
execution_plan={'plan': steps, 'policyReason': policy['reason']},
redacted_metadata=metadata,
)
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
if policy['decision'] == 'approval_required':
approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': steps, 'approvalId': approval.id},
redacted_metadata={**metadata, 'approval_id': approval.id},
)
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps)
start_job(session, job)
set_job_progress(session, job, 35, 'Policy allowed; calling Plex connector.')
connector_result = connector_call(PlexConnector())
success = bool(connector_result.get('success'))
message = str(connector_result.get('message', default_success_message if success else 'plex_failed'))
completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if success else 'failed',
execution_plan={'plan': steps, 'jobId': completed.id},
after_state={'status': connector_result.get('status'), 'message': message},
redacted_metadata={**metadata, 'connector': 'plex'},
)
if not success:
return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')}
return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})}
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, select
from ..db.session import get_session
from ..db.models import Policy
from ..services.policy_engine import evaluate_policy
from ..services.action_registry import action_capability
from ..services.audit_logger import log_event
router = APIRouter(prefix='/api/policies')
DECISIONS = {'allowed', 'approval_required', 'blocked'}
RISKS = {'low', 'medium', 'high', 'critical'}
class PolicyUpdate(BaseModel):
voice: str
manual: str
risk: str
reason: str
@router.get('')
def list_policies(session: Session = Depends(get_session)):
return session.exec(select(Policy).order_by(Policy.action, Policy.target)).all()
@router.patch('/{policy_id}')
def update_policy(policy_id: str, payload: PolicyUpdate, session: Session = Depends(get_session)):
policy = session.get(Policy, policy_id)
if not policy:
raise HTTPException(status_code=404, detail='Policy not found.')
if payload.voice not in DECISIONS or payload.manual not in DECISIONS or payload.risk not in RISKS:
raise HTTPException(status_code=422, detail='Invalid policy decision or risk value.')
if not payload.reason.strip():
raise HTTPException(status_code=422, detail='A policy reason is required.')
capability = action_capability(policy.action)
if capability == 'destructive' and (payload.voice != 'blocked' or payload.manual != 'blocked'):
raise HTTPException(status_code=409, detail='Destructive actions remain blocked by the platform guardrail.')
before = {'voice': policy.voice, 'manual': policy.manual, 'risk': policy.risk, 'reason': policy.reason}
policy.voice = payload.voice
policy.manual = payload.manual
policy.risk = 'critical' if capability == 'destructive' else payload.risk
policy.reason = payload.reason.strip()
session.add(policy)
session.commit()
session.refresh(policy)
after = {'voice': policy.voice, 'manual': policy.manual, 'risk': policy.risk, 'reason': policy.reason}
log_event(
session,
source='manual',
identity='operator',
action_id='policy.update',
target=policy.id,
target_type='policy',
risk='medium',
policy_decision='allowed',
reason='Operator updated policy decisions within enforced platform guardrails.',
result='success',
before_state=before,
after_state=after,
redacted_metadata={'protectedGuardrail': capability == 'destructive', 'policyAction': policy.action},
)
return {
'id': policy.id,
'action': policy.action,
'target': policy.target,
'voice': policy.voice,
'manual': policy.manual,
'risk': policy.risk,
'reason': policy.reason,
}
@router.post('/evaluate')
def evaluate(payload: dict, session: Session = Depends(get_session)):
action = str(payload.get('action', '')).strip()
target = str(payload.get('target', '')).strip()
source = str(payload.get('source', 'manual')).strip() or 'manual'
result = evaluate_policy(session, action=action, target=target, source=source)
return {
'decision': result['decision'],
'reason': result['reason'],
'risk': result['risk'],
'policyId': result['policy_id'],
}
@@ -0,0 +1,139 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session
from ..db.session import get_session
from ..services.audit_logger import log_event
from ..services.policy_engine import evaluate_policy
from ..services.privatemuse_artifact_installer import (
ArtifactInstallError,
install,
validate_request,
)
router = APIRouter(prefix="/api/privatemuse/artifacts")
class ArtifactInstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
artifact_type: str = Field(pattern="^(lora|checkpoint|unet|vae|clip|gguf)$")
source_url: str = Field(min_length=9, max_length=2048)
sha256: str = Field(pattern="^[0-9a-f]{64}$")
filename: str = Field(min_length=13, max_length=192)
source_id: str = Field(min_length=1, max_length=240)
license: str = Field(min_length=1, max_length=240)
reason: str = Field(min_length=1, max_length=1000)
@router.post("/preflight")
def preflight(
payload: ArtifactInstallRequest,
session: Session = Depends(get_session),
):
action = "privatemuse.comfyui.install_artifact"
target = "privatemuse/comfyui"
decision = evaluate_policy(
session,
action=action,
target=target,
source="manual",
)
try:
normalized = validate_request(payload.model_dump())
except ArtifactInstallError as exc:
raise HTTPException(422, str(exc)) from exc
return {
"ready": decision["decision"] != "blocked",
"policy": decision,
"artifact": {
"artifact_type": normalized["artifact_type"],
"filename": normalized["filename"],
"sha256": normalized["sha256"],
"source_id": normalized["source_id"],
"source_host": normalized["source_url"].split("/")[2],
"license": normalized["license"],
},
}
@router.post("/install")
def install_artifact(
payload: ArtifactInstallRequest,
session: Session = Depends(get_session),
):
action = "privatemuse.comfyui.install_artifact"
target = "privatemuse/comfyui"
decision = evaluate_policy(
session,
action=action,
target=target,
source="manual",
)
if decision["decision"] != "allowed":
raise HTTPException(
409,
"Artifact installation requires an explicitly allowed policy/approved execution context.",
)
model_root = Path(
os.environ.get(
"APPOPS_PRIVATEMUSE_COMFYUI_MODEL_ROOT",
"/mnt/privatemuse-comfyui-models",
)
)
receipt_root = Path(
os.environ.get(
"APPOPS_PRIVATEMUSE_ARTIFACT_RECEIPT_ROOT",
"/data/privatemuse-artifact-receipts",
)
)
try:
result = install(
payload.model_dump(),
model_root=model_root,
receipt_root=receipt_root,
)
except ArtifactInstallError as exc:
log_event(
session,
source="action",
identity="manual",
action_id=action,
target=target,
risk="high",
policy_decision="allowed",
reason=payload.reason,
result="failed",
redacted_metadata={
"filename": payload.filename,
"sha256": payload.sha256,
"source_id": payload.source_id,
},
)
raise HTTPException(409, str(exc)) from exc
audit = log_event(
session,
source="action",
identity="manual",
action_id=action,
target=target,
risk="high",
policy_decision="allowed",
reason=payload.reason,
result="success",
after_state={
"status": result["status"],
"sha256": result["sha256"],
"bytes": result["bytes"],
},
redacted_metadata={
"filename": payload.filename,
"source_id": payload.source_id,
"license": payload.license,
},
)
return {**result, "auditId": audit.id}
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.prowlarr import ProwlarrConnector
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.policy_engine import evaluate_policy
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/prowlarr', dependencies=[Depends(load_connector_runtime)])
@router.get('/health')
def health():
return ProwlarrConnector().health()
@router.get('/indexers')
def indexers():
return ProwlarrConnector().indexer_status()
@router.get('/applications')
def applications():
return ProwlarrConnector().application_status()
@router.get('/indexer-health')
def indexer_health():
return ProwlarrConnector().indexer_health()
@router.get('/sync-status')
def sync_status():
return ProwlarrConnector().sync_status()
@router.get('/search-test')
def search_test(q: str = '', source: str = 'manual', session: Session = Depends(get_session)):
query = q.strip()
if not query:
return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []}
identity = source.strip() or 'manual'
policy = evaluate_policy(session=session, action='prowlarr.search_test', target='prowlarr', source=identity)
risk = str(policy.get('risk', 'medium'))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=identity,
action_id='prowlarr.search_test',
target='prowlarr',
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
redacted_metadata={'query': query, 'downloadAllowed': False},
)
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
if policy['decision'] == 'approval_required':
approval = create_approval(session, action='prowlarr.search_test', target='prowlarr', source=identity, risk=risk, reason=policy['reason'], requester=identity)
audit = log_event(
session,
source='action',
identity=identity,
action_id='prowlarr.search_test',
target='prowlarr',
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_requested',
redacted_metadata={'query': query, 'approval_id': approval.id, 'downloadAllowed': False},
)
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
result = ProwlarrConnector().search_test(query)
success = bool(result.get('success'))
audit = log_event(
session,
source='action',
identity=identity,
action_id='prowlarr.search_test',
target='prowlarr',
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if success else 'failed',
redacted_metadata={'query': query, 'resultCount': int(result.get('total', 0)), 'downloadAllowed': False},
)
result['auditId'] = audit.id
result['downloadAllowed'] = False
return result
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.radarr import RadarrConnector
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
from ..services.policy_engine import evaluate_policy
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/radarr', dependencies=[Depends(load_connector_runtime)])
_ADD_MOVIE_STEPS = [
'Validate payload',
'Evaluate policy',
'Resolve root folder / quality profile defaults',
'Add movie in Radarr',
'Optional search movie',
'Verify movie exists',
'Complete audit/job',
]
@router.get('/health')
def health():
return RadarrConnector().health()
@router.get('/queue')
def queue():
return RadarrConnector().queue_status()
@router.get('/wanted')
def wanted():
return RadarrConnector().wanted_missing()
@router.get('/search')
def search(q: str = ''):
query = q.strip()
if not query:
return {'success': False, 'status': 'error', 'message': 'query_required', 'items': []}
return RadarrConnector().search_movie(query)
@router.post('/movie')
def add_movie(payload: dict, session: Session = Depends(get_session)):
action = 'radarr.add_movie'
target = 'radarr'
source = str(payload.get('source', 'manual')).strip() or 'manual'
policy = evaluate_policy(session=session, action=action, target=target, source=source)
risk = str(policy.get('risk', 'low'))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
execution_plan={'plan': _ADD_MOVIE_STEPS, 'policyReason': policy['reason']},
)
return {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': 'blocked',
'result': 'blocked',
'message': policy['reason'],
'auditId': audit.id,
}
if policy['decision'] == 'approval_required':
approval = create_approval(
session,
action=action,
target=target,
source=source,
risk=risk,
reason=policy['reason'],
requester=source,
)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': _ADD_MOVIE_STEPS, 'approvalId': approval.id},
redacted_metadata={'approval_id': approval.id},
)
return {
'success': False,
'decision': 'approval_required',
'status': 'failed',
'policy_decision': 'approval_required',
'result': 'approval_required',
'message': 'Action requires approval before execution.',
'approvalId': approval.id,
'auditId': audit.id,
}
job = create_job(
session,
action_id=action,
target=target,
source=source,
risk=risk,
policy_decision='allowed',
steps=_ADD_MOVIE_STEPS,
)
start_job(session, job)
set_job_progress(session, job, 35, 'Payload validated and policy allowed.')
connector_result = RadarrConnector().add_movie(payload)
success = bool(connector_result.get('success'))
result_status = 'succeeded' if success else 'failed'
message = str(connector_result.get('message', 'movie_added' if success else 'radarr_failed'))
completed = complete_job(
session,
job,
status=result_status,
result_summary=message,
)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if success else 'failed',
execution_plan={'plan': _ADD_MOVIE_STEPS, 'jobId': completed.id},
after_state={'status': connector_result.get('status'), 'message': message},
redacted_metadata={'connector': 'radarr'},
)
if not success:
return {
'success': False,
'decision': 'failed',
'status': 'failed',
'policy_decision': 'allowed',
'result': 'failed',
'message': message,
'jobId': completed.id,
'auditId': audit.id,
'data': connector_result.get('data'),
}
return {
'success': True,
'decision': 'allowed',
'status': 'success',
'policy_decision': 'allowed',
'result': 'success',
'message': message,
'jobId': completed.id,
'auditId': audit.id,
'data': connector_result.get('data', {}),
}
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from fastapi.responses import FileResponse, PlainTextResponse
from sqlmodel import Session
from ..core.security import (
OPERATOR_SESSION_COOKIE,
issue_operator_session,
operator_session_status,
require_operator_request,
revoke_operator_session,
)
from ..db.session import get_session
from ..services.audit_logger import log_event
from ..services.deployment_target_registry import DeploymentTargetRegistry
from ..services.maintenance_scheduler import maintenance_status
from ..services.request_metrics import (
request_metrics_openmetrics,
request_metrics_snapshot,
)
from ..services.runtime_service import (
checkpoint_sqlite,
create_sqlite_backup,
deep_health,
export_audit_summary,
export_config,
export_inventory,
export_policies,
get_sqlite_backup_path,
list_sqlite_backups,
operator_auth_policy,
runtime_info,
sqlite_maintenance_status,
verify_sqlite_backup,
update_operator_auth_policy,
)
router = APIRouter(prefix='/api')
class OperatorAuthPolicyRequest(BaseModel):
required: bool
class DeploymentTargetRegistrationRequest(BaseModel):
repository: str
revision: str
@router.get('/runtime/info')
def get_runtime_info(session: Session = Depends(get_session)):
return runtime_info(session)
@router.get('/operator/session')
def get_operator_session(request: Request):
return operator_session_status(request)
@router.get('/runtime/operator-auth')
def get_operator_auth_policy():
return operator_auth_policy()
@router.patch('/runtime/operator-auth')
def patch_operator_auth_policy(payload: OperatorAuthPolicyRequest, session: Session = Depends(get_session)):
return update_operator_auth_policy(session, payload.required)
@router.post('/admin/deployment-targets/single-container')
def admin_register_single_container_deployment_target(
payload: DeploymentTargetRegistrationRequest,
request: Request,
session: Session = Depends(get_session),
):
"""Register one bounded legacy Actions deployment target.
This is deliberately not a generic Docker exec or host-file API. AppOps
derives the exact-source path, Compose filename/project and container name
from a canonical Jens repository identifier. The controller-scoped bridge
validates the immutable revision and Compose file directly against the bare
Gitea repository before it updates the deployment registry. Webhook secrets
and modern runnerless webhook onboarding are explicitly out of scope here.
"""
require_operator_request(request)
result = DeploymentTargetRegistry().register_single_container(
payload.repository,
payload.revision,
)
status = str(result.get('status') or 'error')
succeeded = status == 'registered'
audit = log_event(
session,
source='operator',
identity='operator',
action_id='deployment.target.register',
target=str(payload.repository).strip().lower(),
target_type='deployment-target',
risk='high',
policy_decision='allowed',
reason='Operator requested bounded single-container deployment target onboarding.',
result='success' if succeeded else 'failed',
execution_plan={
'steps': [
'validate canonical Jens repository and immutable revision',
'derive controller-owned exact-source and Compose topology',
'verify the exact revision is on main and contains docker-compose.yml',
'atomically update deployment controller projects.json',
'leave the registry unchanged on any source or validation failure',
]
},
after_state={
'registered': succeeded,
'repository': str(result.get('repository') or str(payload.repository).strip().lower()),
'revision': str(result.get('revision') or payload.revision),
'slug': str(result.get('slug') or ''),
},
redacted_metadata={
'scope': 'legacy-actions-single-container',
'webhookSecretsCreated': False,
},
)
if not succeeded:
code = 400 if status == 'invalid' else 409 if status == 'conflict' else 502
raise HTTPException(
status_code=code,
detail={
'status': status,
'reason': str(result.get('reason') or 'deployment target registration failed'),
'auditId': audit.id,
},
)
return {
'success': True,
'status': status,
'repository': result.get('repository'),
'revision': result.get('revision'),
'slug': result.get('slug'),
'composeProject': result.get('compose_project'),
'container': result.get('container'),
'auditId': audit.id,
}
def _session_cookie_secure(request: Request) -> bool:
forwarded_proto = request.headers.get('X-Forwarded-Proto', '').split(',', 1)[0].strip().lower()
return request.url.scheme == 'https' or forwarded_proto == 'https'
@router.post('/operator/session')
def create_operator_session(request: Request, response: Response):
session_id = issue_operator_session(request)
response.set_cookie(
OPERATOR_SESSION_COOKIE,
session_id,
httponly=True,
secure=_session_cookie_secure(request),
samesite='strict',
path='/',
)
return {'authorized': True, 'mode': 'session', 'identity': None}
@router.delete('/operator/session')
def delete_operator_session(request: Request, response: Response):
require_operator_request(request)
revoke_operator_session(request)
response.delete_cookie(
OPERATOR_SESSION_COOKIE,
httponly=True,
secure=_session_cookie_secure(request),
samesite='strict',
path='/',
)
return {'authorized': False, 'mode': 'locked', 'identity': None}
@router.get('/health/deep')
def get_deep_health(session: Session = Depends(get_session)):
return deep_health(session)
@router.get('/admin/observability/requests')
def admin_request_metrics():
return request_metrics_snapshot()
@router.get('/admin/observability/metrics', response_class=PlainTextResponse)
def admin_openmetrics():
return PlainTextResponse(request_metrics_openmetrics(), media_type='text/plain; version=0.0.4')
@router.get('/admin/maintenance/status')
def admin_maintenance_status():
return maintenance_status()
@router.get('/admin/export/config')
def admin_export_config(session: Session = Depends(get_session)):
return export_config(session)
@router.get('/admin/export/policies')
def admin_export_policies(session: Session = Depends(get_session)):
return export_policies(session)
@router.get('/admin/export/inventory')
def admin_export_inventory(session: Session = Depends(get_session)):
return export_inventory(session)
@router.get('/admin/export/audit-summary')
def admin_export_audit_summary(session: Session = Depends(get_session)):
return export_audit_summary(session)
@router.get('/admin/backups/sqlite')
def admin_list_sqlite_backups():
return list_sqlite_backups()
@router.get('/admin/database/status')
def admin_database_status():
return sqlite_maintenance_status()
@router.post('/admin/database/checkpoint')
def admin_database_checkpoint(session: Session = Depends(get_session)):
return checkpoint_sqlite(session)
@router.post('/admin/backups/sqlite')
def admin_create_sqlite_backup(session: Session = Depends(get_session)):
return create_sqlite_backup(session)
@router.post('/admin/backups/sqlite/{name}/verify')
def admin_verify_sqlite_backup(name: str, session: Session = Depends(get_session)):
result = verify_sqlite_backup(session, name)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='SQLite backup not found.')
return result
@router.get('/admin/backups/sqlite/{name}/download')
def admin_download_sqlite_backup(name: str):
path = get_sqlite_backup_path(name)
if not path:
raise HTTPException(status_code=404, detail='SQLite backup not found.')
return FileResponse(
path=path,
filename=path.name,
media_type='application/vnd.sqlite3',
headers={'Cache-Control': 'no-store'},
)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from ..db.session import get_session
from ..services import safety_center_service
router = APIRouter(prefix='/api/safety')
@router.get('/dashboard')
def dashboard(refresh: bool = False, session: Session = Depends(get_session)):
return safety_center_service.get_dashboard(session, force_refresh=refresh)
@router.get('/summary')
def summary(session: Session = Depends(get_session)):
return safety_center_service.get_summary(session)
@router.get('/sections')
def sections(session: Session = Depends(get_session)):
return safety_center_service.get_sections(session)
@router.get('/findings')
def findings(session: Session = Depends(get_session)):
return safety_center_service.get_findings(session)
@router.get('/recommendations')
def recommendations(session: Session = Depends(get_session)):
return safety_center_service.get_recommendations(session)
@router.get('/score-breakdown')
def score_breakdown(session: Session = Depends(get_session)):
return safety_center_service.get_score_breakdown(session)
@router.post('/refresh')
def refresh(session: Session = Depends(get_session)):
return safety_center_service.refresh(session)
@router.post('/findings/{finding_id}/acknowledge')
def acknowledge_finding(finding_id: str, session: Session = Depends(get_session)):
return safety_center_service.acknowledge_finding(session, finding_id)
@router.post('/findings/{finding_id}/dismiss')
def dismiss_finding(finding_id: str, session: Session = Depends(get_session)):
return safety_center_service.dismiss_finding(session, finding_id)
@router.post('/recommendations/{recommendation_id}/dismiss')
def dismiss_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
return safety_center_service.dismiss_recommendation(session, recommendation_id)
@router.post('/recommendations/{recommendation_id}/create-remediation-plan')
def create_remediation_plan(recommendation_id: str, session: Session = Depends(get_session)):
result = safety_center_service.create_remediation_plan(session, recommendation_id)
if result.get('status') == 'not_found':
raise HTTPException(status_code=404, detail='recommendation_not_found')
return result
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session
from ..connectors.deluge import DelugeConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.authentik import AuthentikConnector
from ..connectors.plex import PlexConnector
from ..connectors.prowlarr import ProwlarrConnector
from ..connectors.radarr import RadarrConnector
from ..connectors.sonarr import SonarrConnector
from ..connectors.tautulli import TautulliConnector
from ..connectors.widefrog import WidefrogConnector
from ..core.redaction import redact_message
from ..db.session import get_session
from ..services.setup_service import (
complete_setup,
get_setup_status,
list_connector_settings,
mark_setup_complete,
mark_voice_configured,
reconcile_setup,
save_connector_settings,
serialize_connector_settings,
test_portainer_setup,
test_unraid_setup,
)
from ..services.secret_store import list_secret_status, secret_storage_status
from ..core.network_security import UnsafeConnectorUrl
router = APIRouter(prefix='/api')
class ConnectorSettingsRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
url: str | None = Field(default=None, max_length=2048)
endpointId: str | None = Field(default=None, max_length=80)
verifyTls: bool | None = None
apiKey: str | None = Field(default=None, max_length=8192)
token: str | None = Field(default=None, max_length=8192)
password: str | None = Field(default=None, max_length=8192)
secret: str | None = Field(default=None, max_length=8192)
identity: str | None = Field(default=None, max_length=320)
outpostUrl: str | None = Field(default=None, max_length=2048)
expectEmbeddedOutpost: bool | None = None
def _connector_payload(payload: ConnectorSettingsRequest) -> dict:
return payload.model_dump(exclude_none=True)
def _save_connector_settings(session: Session, connector_id: str, payload: dict):
try:
return save_connector_settings(session, connector_id, payload)
except UnsafeConnectorUrl as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _authentik_setup_result(result: dict, settings: dict) -> dict:
status = str(result.get('status', 'error'))
message = str(result.get('reason') or status)
required_fields: list[str] = []
if status == 'unreachable' and 'HTTP 404' in message:
status = 'url_error'
required_fields = ['AUTHENTIK_URL']
message = 'Authentik API gaf HTTP 404 terug. Controleer dat de Authentik URL de root/base URL is, niet een app-, flow- of outpost-pad.'
if status == 'auth_failed':
required_fields = ['AUTHENTIK_TOKEN']
message = 'Authentik heeft de token geweigerd: token ongeldig of verlopen. Gebruik de waarde van een Authentik API Token, niet de tokennaam, identifier of een App password, en sla die opnieuw op.'
return {
'success': status == 'connected',
'status': status,
'connected': status == 'connected',
'message': message,
'requiredFields': required_fields,
'settings': settings,
}
def _normalized_url(settings: dict) -> str:
safe_config = settings.get('safeConfig') if isinstance(settings.get('safeConfig'), dict) else {}
return str(safe_config.get('url') or '').strip().rstrip('/').lower()
def _connector_setup_result(connector_id: str, result: dict, settings: dict, session: Session) -> dict:
status = str(result.get('status', 'error'))
message = str(result.get('reason') or status)
required_fields = list(settings.get('missing') or [])
if not required_fields and status in {'error', 'unreachable', 'auth_failed'}:
required_fields = list(_CONNECTOR_REQUIRED_FIELDS.get(connector_id, []))
if connector_id == 'prowlarr' and status in {'error', 'unreachable', 'auth_failed'} and message == 'unauthorized':
prowlarr_url = _normalized_url(settings)
for sibling_id in ('radarr', 'sonarr'):
sibling = serialize_connector_settings(session, sibling_id)
if prowlarr_url and prowlarr_url == _normalized_url(sibling):
label = 'Radarr' if sibling_id == 'radarr' else 'Sonarr'
return {
'success': False,
'status': 'url_mismatch',
'connected': False,
'message': f'Prowlarr geeft unauthorized terug en gebruikt dezelfde URL als {label}. Controleer PROWLARR_URL en PROWLARR_API_KEY; Prowlarr draait meestal op poort 9696.',
'requiredFields': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
'settings': settings,
}
return {
'success': status == 'connected',
'status': status,
'connected': status == 'connected',
'message': redact_message(message),
'requiredFields': required_fields,
'settings': settings,
}
_CONNECTOR_TESTS = {
'sonarr': SonarrConnector,
'radarr': RadarrConnector,
'prowlarr': ProwlarrConnector,
'deluge': DelugeConnector,
'plex': PlexConnector,
'tautulli': TautulliConnector,
'widefrog': WidefrogConnector,
}
_CONNECTOR_REQUIRED_FIELDS = {
'sonarr': ['SONARR_URL', 'SONARR_API_KEY'],
'radarr': ['RADARR_URL', 'RADARR_API_KEY'],
'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'],
'plex': ['PLEX_URL', 'PLEX_TOKEN'],
'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'],
'widefrog': ['WIDEFROG_URL'],
}
@router.get('/setup/status')
def setup_status(session: Session = Depends(get_session)):
return get_setup_status(session)
@router.post('/setup/complete')
def setup_complete(session: Session = Depends(get_session)):
return complete_setup(session)
@router.post('/setup/reconcile')
def setup_reconcile(session: Session = Depends(get_session)):
return reconcile_setup(session)
@router.post('/setup/mark-complete')
def setup_mark_complete(payload: dict, session: Session = Depends(get_session)):
return mark_setup_complete(session, payload)
@router.post('/setup/voice/mark-configured')
def setup_voice_configured(session: Session = Depends(get_session)):
return mark_voice_configured(session)
@router.post('/setup/test/portainer')
def setup_test_portainer(session: Session = Depends(get_session)):
return test_portainer_setup(session)
@router.post('/setup/test/unraid')
def setup_test_unraid(session: Session = Depends(get_session)):
return test_unraid_setup(session)
@router.post('/setup/test/connector/{connector_id}')
def setup_test_connector(connector_id: str, session: Session = Depends(get_session)):
if connector_id == 'portainer':
return test_portainer_setup(session)
if connector_id == 'unraid':
return test_unraid_setup(session)
if connector_id == 'nginx_proxy_manager':
settings = serialize_connector_settings(session, connector_id)
if settings['missing']:
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings}
result = NginxProxyManagerConnector().test_connection()
status = str(result.get('status', 'error'))
return {'success': status == 'connected', 'status': status, 'connected': status == 'connected', 'message': str(result.get('reason') or status), 'settings': settings}
if connector_id == 'authentik':
settings = serialize_connector_settings(session, connector_id)
if settings['missing']:
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings}
result = AuthentikConnector().test_connection()
return _authentik_setup_result(result, settings)
connector_cls = _CONNECTOR_TESTS.get(connector_id)
if connector_cls:
settings = serialize_connector_settings(session, connector_id)
if settings['missing']:
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings}
result = connector_cls().test_connection()
return _connector_setup_result(connector_id, result, settings, session)
return {
'success': False,
'status': 'unsupported',
'connected': False,
'message': f'setup_test_not_implemented_for_{connector_id}',
'settings': serialize_connector_settings(session, connector_id),
}
@router.get('/settings/connectors')
def settings_connectors(session: Session = Depends(get_session)):
return list_connector_settings(session)
@router.get('/settings/secrets')
def settings_secrets(session: Session = Depends(get_session)):
return {'storage': secret_storage_status(), 'secrets': list_secret_status(session)}
@router.patch('/settings/connectors/portainer')
def update_portainer_settings(payload: ConnectorSettingsRequest, session: Session = Depends(get_session)):
return _save_connector_settings(session, 'portainer', _connector_payload(payload))
@router.patch('/settings/connectors/unraid')
def update_unraid_settings(payload: ConnectorSettingsRequest, session: Session = Depends(get_session)):
return _save_connector_settings(session, 'unraid', _connector_payload(payload))
@router.patch('/settings/connectors/{connector_id}')
def update_connector_settings(connector_id: str, payload: ConnectorSettingsRequest, session: Session = Depends(get_session)):
return _save_connector_settings(session, connector_id, _connector_payload(payload))
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.sonarr import SonarrConnector
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
from ..services.policy_engine import evaluate_policy
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/sonarr', dependencies=[Depends(load_connector_runtime)])
_ADD_SERIES_STEPS = [
'Validate payload',
'Evaluate policy',
'Resolve root folder / quality profile defaults',
'Add series in Sonarr',
'Optional search missing episodes',
'Verify series exists',
'Complete audit/job',
]
@router.get('/health')
def health():
return SonarrConnector().health()
@router.get('/queue')
def queue():
return SonarrConnector().queue_status()
@router.get('/wanted')
def wanted():
return SonarrConnector().wanted_missing()
@router.get('/search')
def search(q: str = ''):
query = q.strip()
if not query:
return {'success': False, 'status': 'error', 'message': 'query_required', 'items': []}
return SonarrConnector().search_series(query)
@router.post('/series')
def add_series(payload: dict, session: Session = Depends(get_session)):
action = 'sonarr.add_series'
target = 'sonarr'
source = str(payload.get('source', 'manual')).strip() or 'manual'
policy = evaluate_policy(session=session, action=action, target=target, source=source)
risk = str(policy.get('risk', 'low'))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
execution_plan={'plan': _ADD_SERIES_STEPS, 'policyReason': policy['reason']},
)
return {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': 'blocked',
'result': 'blocked',
'message': policy['reason'],
'auditId': audit.id,
}
if policy['decision'] == 'approval_required':
approval = create_approval(
session,
action=action,
target=target,
source=source,
risk=risk,
reason=policy['reason'],
requester=source,
)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': _ADD_SERIES_STEPS, 'approvalId': approval.id},
redacted_metadata={'approval_id': approval.id},
)
return {
'success': False,
'decision': 'approval_required',
'status': 'failed',
'policy_decision': 'approval_required',
'result': 'approval_required',
'message': 'Action requires approval before execution.',
'approvalId': approval.id,
'auditId': audit.id,
}
job = create_job(
session,
action_id=action,
target=target,
source=source,
risk=risk,
policy_decision='allowed',
steps=_ADD_SERIES_STEPS,
)
start_job(session, job)
set_job_progress(session, job, 35, 'Payload validated and policy allowed.')
connector_result = SonarrConnector().add_series(payload)
success = bool(connector_result.get('success'))
result_status = 'succeeded' if success else 'failed'
message = str(connector_result.get('message', 'series_added' if success else 'sonarr_failed'))
completed = complete_job(
session,
job,
status=result_status,
result_summary=message,
)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if success else 'failed',
execution_plan={'plan': _ADD_SERIES_STEPS, 'jobId': completed.id},
after_state={'status': connector_result.get('status'), 'message': message},
redacted_metadata={'connector': 'sonarr'},
)
if not success:
return {
'success': False,
'decision': 'failed',
'status': 'failed',
'policy_decision': 'allowed',
'result': 'failed',
'message': message,
'jobId': completed.id,
'auditId': audit.id,
'data': connector_result.get('data'),
}
return {
'success': True,
'decision': 'allowed',
'status': 'success',
'policy_decision': 'allowed',
'result': 'success',
'message': message,
'jobId': completed.id,
'auditId': audit.id,
'data': connector_result.get('data', {}),
}
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from ..connectors.portainer import PortainerConnector
from ..core.redaction import redact_message
router = APIRouter(prefix='/api/stacks')
def _raise_connector_error(response: dict):
status = str(response.get('status', 'error'))
reason = redact_message(str(response.get('reason', 'connection_failed')))
status_code = 503 if status == 'missing_config' else 502
raise HTTPException(
status_code=status_code,
detail={
'status': status,
'reason': reason,
'connector': 'portainer',
},
)
@router.get('')
def list_stacks():
response = PortainerConnector().list_stacks()
if response.get('status') == 'ok':
return response.get('stacks', [])
_raise_connector_error(response)
@router.get('/{stack_id}')
def get_stack(stack_id: str):
response = PortainerConnector().get_stack_status(stack_id)
if response.get('status') == 'ok':
return response.get('stack')
if response.get('status') == 'not_found':
return None
_raise_connector_error(response)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..connectors.unraid import UnraidConnector
from ..core.redaction import redact_message
from ..db.models import AuditEvent, Connector
from ..db.session import get_session
from ..services.incident_service import count_open_incidents
from ..services.setup_service import apply_stored_connector_settings, list_connector_settings
router = APIRouter(prefix='/api/system')
def _gateway_health(unraid_status: str, portainer_status: str, open_incidents: int) -> str:
core_statuses = {unraid_status, portainer_status}
if core_statuses & {'missing_config', 'error', 'disconnected'}:
return 'critical'
if open_incidents > 0:
return 'warning'
return 'healthy'
def _latest_action_label(session: Session) -> str | None:
event = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).first()
if event is None:
return None
action = str(event.action_id or '').strip()
target = str(event.target or '').strip()
if not action:
return None
label = f'{action} -> {target}' if target else action
return redact_message(label)[:120]
@router.get('/summary')
def system_summary(session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
connectors = session.exec(select(Connector)).all()
status_map = {connector.id: connector.status for connector in connectors}
config_map = {item["connectorId"]: item for item in list_connector_settings(session)}
portainer_config = config_map.get('portainer', {})
portainer_status = 'missing_config' if portainer_config.get('missing') else status_map.get('portainer', 'disconnected')
if portainer_status not in {'connected', 'missing_config', 'error', 'disconnected'}:
portainer_status = 'disconnected'
unraid_config = config_map.get('unraid', {})
unraid_status = 'missing_config' if unraid_config.get('missing') else status_map.get('unraid', 'disconnected')
if unraid_status not in {'connected', 'missing_config', 'error', 'disconnected'}:
unraid_status = 'disconnected'
unraid = UnraidConnector()
server_info = unraid.get_server_info() if unraid.is_configured() else {'status': 'missing_config'}
metrics = unraid.get_system_metrics() if unraid.is_configured() else {'status': 'missing_config'}
cache = unraid.get_cache_status() if unraid.is_configured() else {'status': 'missing_config'}
server = server_info.get('info', {}) if server_info.get('status') == 'ok' else {}
cpu_usage = metrics.get('cpuUsage') if isinstance(metrics.get('cpuUsage'), int | float) else 0
ram_usage = metrics.get('ramUsage') if isinstance(metrics.get('ramUsage'), int | float) else 0
cache_usage = cache.get('cacheUsage') if cache.get('status') == 'ok' and isinstance(cache.get('cacheUsage'), int | float) else 0
open_incidents = count_open_incidents(session)
last_action = _latest_action_label(session)
return {
'gatewayHealth': _gateway_health(unraid_status, portainer_status, open_incidents),
'unraidStatus': unraid_status,
'portainerStatus': portainer_status,
'maintenanceMode': False,
'openIncidents': open_incidents,
'lastAction': last_action,
'uptime': str(server.get('uptime') or 'unknown'),
'cpuUsage': int(cpu_usage),
'ramUsage': int(ram_usage),
'cacheUsage': int(cache_usage),
}
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.tautulli import TautulliConnector
from ..db.session import get_session
from ..services.audit_logger import log_event
from ..services.policy_engine import evaluate_policy
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/tautulli', dependencies=[Depends(load_connector_runtime)])
def _read(
session: Session,
*,
action: str,
source: str,
risk_default: str,
call: Callable[[TautulliConnector], dict[str, Any]],
audit_read: bool = False,
) -> dict[str, Any]:
identity = source.strip() or 'manual'
policy = evaluate_policy(session=session, action=action, target='tautulli', source=identity)
risk = str(policy.get('risk', risk_default))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='read',
identity=identity,
action_id=action,
target='tautulli',
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
redacted_metadata={'privacySensitive': audit_read},
)
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'message': policy['reason'], 'auditId': audit.id}
if policy['decision'] == 'approval_required':
audit = log_event(
session,
source='read',
identity=identity,
action_id=action,
target='tautulli',
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_required',
redacted_metadata={'privacySensitive': audit_read},
)
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'message': 'Action requires approval before execution.', 'auditId': audit.id}
result = call(TautulliConnector())
if audit_read:
log_event(
session,
source='read',
identity=identity,
action_id=action,
target='tautulli',
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if result.get('success') else 'failed',
redacted_metadata={'privacySensitive': True, 'status': result.get('status')},
)
return result
@router.get('/health')
def health():
return TautulliConnector().health()
@router.get('/status')
def status():
return TautulliConnector().status()
@router.get('/activity')
def activity():
return TautulliConnector().activity()
@router.get('/history')
def history(limit: int = 25, source: str = 'manual', session: Session = Depends(get_session)):
return _read(session, action='tautulli.history', source=source, risk_default='medium', call=lambda connector: connector.history(limit), audit_read=True)
@router.get('/users')
def users(source: str = 'manual', session: Session = Depends(get_session)):
return _read(session, action='tautulli.users', source=source, risk_default='medium', call=lambda connector: connector.users(), audit_read=True)
@router.get('/libraries')
def libraries():
return TautulliConnector().libraries()
@router.get('/recently-added')
def recently_added():
return TautulliConnector().recently_added()
@router.get('/watch-stats')
def watch_stats(source: str = 'manual', session: Session = Depends(get_session)):
return _read(session, action='tautulli.watch_stats', source=source, risk_default='medium', call=lambda connector: connector.watch_stats(), audit_read=True)
@router.get('/streams')
def streams():
return TautulliConnector().stream_summary()
@router.get('/plex-info')
def plex_info():
return TautulliConnector().plex_info()
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.unraid import UnraidConnector
from ..db.session import get_session
from ..services.unraid_vm_service import request_vm_action, vm_capabilities
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/unraid', dependencies=[Depends(load_connector_runtime)])
def _default_summary():
return {
'arrayStatus': 'stopped',
'parityStatus': 'invalid',
'cacheUsage': 0,
'diskWarnings': 0,
'uptime': 'unknown',
'dockerService': False,
'cpuUsage': 0,
'ramUsage': 0,
}
@router.get('/summary')
def summary():
connector = UnraidConnector()
data = _default_summary()
if not connector.is_configured():
return data
server_info = connector.get_server_info()
if server_info.get('status') == 'ok':
info = server_info.get('info', {})
data['uptime'] = str(info.get('uptime', 'unknown'))
metrics = connector.get_system_metrics()
if metrics.get('status') in {'ok', 'partial'}:
if isinstance(metrics.get('cpuUsage'), int | float):
data['cpuUsage'] = int(metrics.get('cpuUsage', 0))
if isinstance(metrics.get('ramUsage'), int | float):
data['ramUsage'] = int(metrics.get('ramUsage', 0))
array_status = connector.get_array_status()
if array_status.get('status') == 'ok':
data['arrayStatus'] = str(array_status.get('arrayStatus', 'stopped'))
disk_summary = connector.get_disk_warnings()
if disk_summary.get('status') == 'ok':
data['diskWarnings'] = int(disk_summary.get('diskWarnings', 0))
cache_summary = connector.get_cache_status()
if cache_summary.get('status') == 'ok':
data['cacheUsage'] = int(cache_summary.get('cacheUsage', 0))
parity_summary = connector.get_parity_status()
if parity_summary.get('status') == 'ok':
data['parityStatus'] = str(parity_summary.get('parityStatus', 'invalid'))
docker_summary = connector.get_docker_service_status()
if docker_summary.get('status') == 'ok':
data['dockerService'] = bool(docker_summary.get('running', False))
return data
@router.get('/array')
def array():
result = UnraidConnector().get_array_status()
if result.get('status') == 'ok':
return {'status': result.get('arrayStatus', 'stopped')}
if result.get('status') == 'missing_config':
return {'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
return {'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
@router.get('/disks')
def disks():
result = UnraidConnector().get_disk_warnings()
if result.get('status') == 'ok':
return {'disks': result.get('disks', []), 'diskWarnings': result.get('diskWarnings', 0)}
if result.get('status') == 'missing_config':
return {'disks': [], 'diskWarnings': 0, 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
return {'disks': [], 'diskWarnings': 0, 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
@router.get('/cache')
def cache():
result = UnraidConnector().get_cache_status()
if result.get('status') == 'ok':
return {'cacheUsage': result.get('cacheUsage', 0), 'pools': result.get('pools', [])}
if result.get('status') == 'missing_config':
return {'cacheUsage': 0, 'pools': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
return {'cacheUsage': 0, 'pools': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
@router.get('/parity')
def parity():
result = UnraidConnector().get_parity_status()
if result.get('status') == 'ok':
return {
'status': result.get('parityStatus', 'invalid'),
'checkStatus': result.get('checkStatus', 'idle'),
'running': bool(result.get('running', False)),
'paused': bool(result.get('paused', False)),
'correcting': bool(result.get('correcting', False)),
'progress': result.get('progress'),
'speed': result.get('speed'),
'errors': int(result.get('errors', 0)),
}
if result.get('status') == 'missing_config':
return {'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
return {'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
@router.get('/docker')
def docker():
result = UnraidConnector().get_docker_service_status()
if result.get('status') == 'ok':
return {
'status': result.get('serviceStatus', 'stopped'),
'running': result.get('running', False),
}
if result.get('status') == 'missing_config':
return {'status': 'missing_config', 'running': False, 'reason': result.get('reason', 'missing_config')}
return {'status': 'disconnected', 'running': False, 'reason': result.get('reason', 'connection_failed')}
@router.get('/vms')
def vms():
result = UnraidConnector().get_vm_summary()
if result.get('status') == 'ok':
return {'vms': result.get('vms', [])}
if result.get('status') == 'missing_config':
return {'vms': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
if result.get('status') in {'unavailable', 'api_unavailable'}:
return {'vms': [], 'status': result.get('status'), 'reason': result.get('reason', 'VM API unavailable')}
return {'vms': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
@router.get('/vms/capabilities')
def vms_capabilities(session: Session = Depends(get_session)):
return vm_capabilities(session)
@router.post('/vms/{vm_id}/actions/{action}')
def run_vm_action(vm_id: str, action: str, payload: dict, session: Session = Depends(get_session)):
return request_vm_action(
session,
vm_id=vm_id,
action=action,
source=str(payload.get('source') or 'manual'),
confirmed=payload.get('confirm') is True,
)
@router.get('/shares')
def shares():
result = UnraidConnector().get_share_summary()
if result.get('status') == 'ok':
return {'shares': result.get('shares', [])}
if result.get('status') == 'missing_config':
return {'shares': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
return {'shares': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
+388
View File
@@ -0,0 +1,388 @@
from __future__ import annotations
from typing import Any, Callable
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from ..connectors.authentik import AuthentikConnector
from ..connectors.deluge import DelugeConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.plex import PlexConnector
from ..connectors.prowlarr import ProwlarrConnector
from ..connectors.radarr import RadarrConnector
from ..connectors.sonarr import SonarrConnector
from ..connectors.tautulli import TautulliConnector
from ..connectors.unraid import UnraidConnector
from ..connectors.widefrog import WidefrogConnector
from ..core.redaction import redact_message
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.container_restart_service import execute_container_restart
from ..services.policy_engine import evaluate_policy
from ..services import media_pipeline_service, notification_service, safety_center_service
from ..services.setup_service import apply_stored_connector_settings, get_setup_status
from ..services import voice_catalog
router = APIRouter(prefix='/api/voice')
def _handle_voice_action(
session: Session,
action: str,
target: str,
risk: str,
steps: list[str] | None = None,
reader: Callable[[], Any] | None = None,
):
policy = evaluate_policy(session=session, action=action, target=target, source='voice')
decision = policy['decision']
risk_level = policy.get('risk', risk)
normalized_steps = steps or []
if decision == 'blocked':
audit = log_event(
session,
source='voice',
identity='voice',
action_id=action,
target=target,
risk=risk_level,
policy_decision=decision,
reason=policy['reason'],
result='blocked',
execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']},
redacted_metadata={'policyId': policy.get('policy_id')},
)
return {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': decision,
'result': 'blocked',
'message': policy['reason'],
'auditId': audit.id,
}
if decision == 'approval_required':
approval = create_approval(
session,
action=action,
target=target,
source='voice',
risk=risk_level,
reason=policy['reason'],
requester='voice',
)
audit = log_event(
session,
source='voice',
identity='voice',
action_id=action,
target=target,
risk=risk_level,
policy_decision=decision,
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': normalized_steps, 'approvalId': approval.id},
redacted_metadata={'approval_id': approval.id},
)
return {
'success': False,
'decision': 'approval_required',
'status': 'failed',
'policy_decision': decision,
'result': 'approval_requested',
'message': 'Action requires approval before execution.',
'approvalId': approval.id,
'auditId': audit.id,
}
if decision == 'allowed':
try:
data = reader() if reader else None
except Exception as exc: # noqa: BLE001 - voice must return a governed, redacted failure.
message = redact_message(str(exc))
audit = log_event(
session, source='voice', identity='voice', action_id=action, target=target,
risk=risk_level, policy_decision=decision, reason=policy['reason'], result='failed',
execution_plan={'plan': normalized_steps}, redacted_metadata={'error': message[:200]},
)
return {
'success': False, 'decision': decision, 'status': 'failed',
'policy_decision': decision, 'result': 'failed', 'message': message, 'auditId': audit.id,
}
audit = log_event(
session,
source='voice',
identity='voice',
action_id=action,
target=target,
risk=risk_level,
policy_decision=decision,
reason=policy['reason'],
result='success',
execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']},
)
response = {
'success': True,
'decision': 'allowed',
'status': 'success',
'policy_decision': decision,
'result': 'success',
'message': f'Voice action {action} allowed for {target}.',
'auditId': audit.id,
}
if data is not None:
response['data'] = data
return response
return {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': 'blocked',
'result': 'blocked',
'message': 'Action policy outcome unsupported.',
}
@router.get('/commands')
def voice_commands(session: Session = Depends(get_session)):
return voice_catalog.list_commands(session)
@router.get('/home-assistant/export')
def voice_export(request: Request, session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/'))
@router.get('/home-assistant/export/rest-commands')
def voice_export_rest_commands(request: Request, session: Session = Depends(get_session)):
return {'yaml': voice_catalog.rest_commands_yaml(voice_catalog.list_commands(session), str(request.base_url).rstrip('/'))}
@router.get('/home-assistant/export/scripts')
def voice_export_scripts(session: Session = Depends(get_session)):
return {'yaml': voice_catalog.scripts_yaml(voice_catalog.list_commands(session))}
@router.get('/home-assistant/export/dashboard')
def voice_export_dashboard(session: Session = Depends(get_session)):
return {'yaml': voice_catalog.dashboard_yaml(voice_catalog.list_commands(session))}
@router.post('/test-intent')
def test_intent(payload: dict | None = None, session: Session = Depends(get_session)):
text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip()
return voice_catalog.test_intent(session, text)
@router.post('/commands/{command_id}/dry-run')
def dry_run_command(command_id: str, session: Session = Depends(get_session)):
return voice_catalog.dry_run(session, command_id)
@router.post('/commands/{command_id}/request-approval')
def request_command_approval(command_id: str, session: Session = Depends(get_session)):
return voice_catalog.request_approval(session, command_id)
@router.post('/actions/container/{app_id}/restart')
def restart_container(app_id: str, session: Session = Depends(get_session)):
return execute_container_restart(
session=session,
action='container.restart',
target=app_id,
source='voice',
risk='medium',
steps=['Resolve container', 'Restart container', 'Voice post-check'],
)
@router.post('/actions/unraid/health')
def unraid_health(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='unraid.health_summary',
target='unraid',
risk='low',
steps=['Read unraid health summary'],
reader=lambda: {
'array': UnraidConnector().get_array_status(),
'metrics': UnraidConnector().get_system_metrics(),
'docker': UnraidConnector().get_docker_service_status(),
},
)
@router.post('/actions/setup/status')
def setup_status(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='setup.status',
target='appops',
risk='low',
steps=['Read AppOps setup status'],
reader=lambda: get_setup_status(session),
)
@router.post('/actions/safety/summary')
def safety_summary(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='safety.summary',
target='safety',
risk='low',
steps=['Read Safety Center summary'],
reader=lambda: safety_center_service.get_summary(session),
)
@router.post('/actions/media-pipeline/check')
def media_pipeline_check(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='media_pipeline.check',
target='media-pipeline',
risk='low',
steps=['Run read-only media pipeline check'],
reader=lambda: media_pipeline_service.summary(session),
)
@router.post('/actions/notifications/summary')
def notifications_summary(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='notifications.summary',
target='notifications',
risk='low',
steps=['Read notification summary'],
reader=lambda: notification_service.summary(session),
)
@router.post('/actions/notifications/evaluate/safety')
def evaluate_safety_notifications(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='notifications.evaluate_safety',
target='notifications',
risk='medium',
steps=['Evaluate safety notification rules'],
)
@router.post('/actions/apps/widefrog/health')
def widefrog_health(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='widefrog.health',
target='widefrog',
risk='low',
steps=['Read Widefrog health'],
reader=lambda: WidefrogConnector().health(),
)
@router.post('/actions/apps/deluge/status')
def deluge_status(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='deluge.session_status',
target='deluge',
risk='low',
steps=['Read Deluge session status'],
reader=lambda: DelugeConnector().session_status(),
)
@router.post('/actions/apps/plex/activity')
def plex_activity(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='plex.active_sessions',
target='plex',
risk='low',
steps=['Read Plex active sessions'],
reader=lambda: PlexConnector().active_sessions(),
)
@router.post('/actions/unraid/storage')
def unraid_storage(session: Session = Depends(get_session)):
connector = UnraidConnector()
return _handle_voice_action(
session, 'unraid.health_summary', 'unraid', 'low', ['Read storage health'],
reader=lambda: {'disks': connector.get_disk_warnings(), 'cache': connector.get_cache_status(), 'parity': connector.get_parity_status()},
)
@router.post('/actions/unraid/vms')
def unraid_vms(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'unraid.health_summary', 'unraid', 'low', ['Read VM inventory'], reader=lambda: UnraidConnector().get_vm_summary())
@router.post('/actions/apps/sonarr/health')
def sonarr_health(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'sonarr.health', 'sonarr', 'low', ['Read Sonarr health'], reader=lambda: SonarrConnector().health())
@router.post('/actions/apps/sonarr/queue')
def sonarr_queue(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'sonarr.queue_status', 'sonarr', 'low', ['Read Sonarr queue'], reader=lambda: SonarrConnector().queue_status())
@router.post('/actions/apps/radarr/health')
def radarr_health(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'radarr.health', 'radarr', 'low', ['Read Radarr health'], reader=lambda: RadarrConnector().health())
@router.post('/actions/apps/radarr/queue')
def radarr_queue(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'radarr.queue_status', 'radarr', 'low', ['Read Radarr queue'], reader=lambda: RadarrConnector().queue_status())
@router.post('/actions/apps/prowlarr/indexers')
def prowlarr_indexers(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'prowlarr.indexer_health', 'prowlarr', 'low', ['Read Prowlarr indexer health'], reader=lambda: ProwlarrConnector().indexer_health())
@router.post('/actions/apps/deluge/stalled')
def deluge_stalled(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'deluge.stalled_downloads', 'deluge', 'low', ['Read stalled downloads'], reader=lambda: DelugeConnector().stalled_downloads())
@router.post('/actions/apps/plex/recent')
def plex_recent(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'plex.recent_media', 'plex', 'low', ['Read recent Plex media'], reader=lambda: PlexConnector().recent_media())
@router.post('/actions/apps/tautulli/activity')
def tautulli_activity(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'tautulli.activity', 'tautulli', 'low', ['Read Tautulli activity'], reader=lambda: TautulliConnector().activity())
@router.post('/actions/security/exposure')
def exposure_summary(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'npm.exposure_summary', 'nginx_proxy_manager', 'low', ['Read public exposure'], reader=lambda: NginxProxyManagerConnector().exposure_summary())
@router.post('/actions/security/authentik')
def authentik_health(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'authentik.health', 'authentik', 'low', ['Read Authentik health'], reader=lambda: AuthentikConnector().health())
@router.post('/actions/playbooks/{playbook_id}/run')
def run_playbook(playbook_id: str, session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='playbook.run',
target=playbook_id,
risk='medium',
steps=['Run playbook'],
)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from ..db.session import get_session
from ..services import voice_catalog
from ..services.home_assistant_voice_readiness import build_readiness
from ..services.setup_service import apply_stored_connector_settings
router = APIRouter(prefix='/api/operator/voice')
@router.get('/commands')
def commands(session: Session = Depends(get_session)):
return voice_catalog.list_commands(session)
@router.get('/home-assistant/export')
def home_assistant_export(request: Request, session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/'))
@router.get('/home-assistant/readiness')
def home_assistant_readiness(session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
return build_readiness(session)
@router.post('/test-intent')
def test_intent(payload: dict | None = None, session: Session = Depends(get_session)):
text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip()
return voice_catalog.test_intent(session, text)
@router.post('/commands/{command_id}/dry-run')
def dry_run(command_id: str, session: Session = Depends(get_session)):
return voice_catalog.dry_run(session, command_id)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..connectors.widefrog import WidefrogConnector
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
from ..services.policy_engine import evaluate_policy
from .connector_runtime import load_connector_runtime
router = APIRouter(prefix='/api/apps/widefrog', dependencies=[Depends(load_connector_runtime)])
_RETRY_STEPS = [
'Validate connector config',
'Evaluate policy',
'Fetch failed jobs summary',
'Call retry endpoint',
'Fetch queue/activity summary',
'Complete audit/job',
]
_SCAN_STEPS = [
'Validate connector config',
'Evaluate policy',
'Check app health',
'Call scan endpoint',
'Verify accepted or status',
'Complete audit/job',
]
@router.get('/health')
def health():
return WidefrogConnector().health()
@router.get('/queue')
def queue():
return WidefrogConnector().queue_status()
@router.get('/failed')
def failed():
return WidefrogConnector().failed_jobs()
@router.get('/activity')
def activity():
return WidefrogConnector().activity()
@router.get('/download-status')
def download_status():
return WidefrogConnector().download_status()
@router.post('/retry-failed')
def retry_failed(payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_widefrog_action(
session=session,
payload=payload or {},
action='widefrog.retry_failed',
steps=_RETRY_STEPS,
connector_call=lambda connector, body: connector.retry_failed(body),
default_success_message='retry_started',
)
@router.post('/scan-library')
def scan_library(payload: dict | None = None, session: Session = Depends(get_session)):
return _execute_widefrog_action(
session=session,
payload=payload or {},
action='widefrog.scan_library',
steps=_SCAN_STEPS,
connector_call=lambda connector, body: connector.scan_library(body),
default_success_message='scan_started',
)
def _execute_widefrog_action(
*,
session: Session,
payload: dict[str, Any],
action: str,
steps: list[str],
connector_call: Callable[[WidefrogConnector, dict[str, Any]], dict[str, Any]],
default_success_message: str,
) -> dict[str, Any]:
target = 'widefrog'
source = str(payload.get('source', 'manual')).strip() or 'manual'
policy = evaluate_policy(session=session, action=action, target=target, source=source)
risk = str(policy.get('risk', 'medium'))
if policy['decision'] == 'blocked':
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='blocked',
reason=policy['reason'],
result='blocked',
execution_plan={'plan': steps, 'policyReason': policy['reason']},
)
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
if policy['decision'] == 'approval_required':
approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='approval_required',
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': steps, 'approvalId': approval.id},
redacted_metadata={'approval_id': approval.id},
)
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps)
start_job(session, job)
set_job_progress(session, job, 35, 'Policy allowed; calling Widefrog connector.')
connector_result = connector_call(WidefrogConnector(), payload)
success = bool(connector_result.get('success'))
message = str(connector_result.get('message', default_success_message if success else 'widefrog_failed'))
completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message)
audit = log_event(
session,
source='action',
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision='allowed',
reason=policy['reason'],
result='success' if success else 'failed',
execution_plan={'plan': steps, 'jobId': completed.id},
after_state={'status': connector_result.get('status'), 'message': message},
redacted_metadata={'connector': 'widefrog'},
)
if not success:
return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')}
return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})}
+817
View File
@@ -0,0 +1,817 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _AuthentikConfig:
url: str
token: str
verify_tls: bool
timeout_seconds: float
outpost_url: str | None
expect_embedded_outpost: bool
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _token_value(value: Any) -> str:
token = str(value or '').strip()
if token.lower().startswith('bearer '):
return token[7:].strip()
return token
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if value in {None, ''}:
return []
return [value]
def _safe_url(value: Any = None) -> str:
raw = str(value or settings.apis.get('AUTHENTIK_URL') or '').strip().rstrip('/')
if not raw:
return ''
parsed = urlparse(raw)
if not parsed.username and not parsed.password:
return raw
host = parsed.hostname or ''
port = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
def _domain(value: Any) -> str:
text = str(value or '').strip()
if not text:
return ''
parsed = urlparse(text if '://' in text else f'https://{text}')
return (parsed.hostname or text).lower().strip()
def _response_items(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
data = payload.get('data')
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(payload.get('results'), list):
return [item for item in payload['results'] if isinstance(item, dict)]
if isinstance(payload.get('items'), list):
return [item for item in payload['items'] if isinstance(item, dict)]
return []
def _pagination_next(payload: Any) -> str | None:
if not isinstance(payload, dict):
return None
pagination = payload.get('pagination')
if isinstance(pagination, dict) and pagination.get('next'):
return str(pagination['next'])
if payload.get('next'):
return str(payload['next'])
return None
class AuthentikConnector(BaseConnector):
name = 'Authentik'
def __init__(self):
super().__init__()
self._read_cache: dict[str, dict[str, Any]] = {}
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
if key not in self._read_cache:
self._read_cache[key] = factory()
return self._read_cache[key]
def is_configured(self) -> bool:
return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN'))
def _config(self) -> _AuthentikConfig | None:
if not self.is_configured():
return None
return _AuthentikConfig(
url=str(settings.apis['AUTHENTIK_URL']).rstrip('/'),
token=_token_value(settings.apis['AUTHENTIK_TOKEN']),
verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
)
def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
url = path if path.startswith(('http://', 'https://')) else f'{config.url}/api/v3{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(
url,
params=params,
headers={'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'},
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def _write_request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
"""Use the existing Authentik API identity for one typed mutation.
Callers in this repository pass only a DeploymentPlan-derived payload;
this method is private so routes cannot turn it into a generic Authentik
proxy.
"""
config = self._config()
if not config:
raise RuntimeError('missing_config')
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
request_kwargs: dict[str, Any] = {
'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'},
}
if payload is not None:
request_kwargs['json'] = payload
response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if not response.content:
return {}
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def _paged(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
items: list[dict[str, Any]] = []
base_path = path
base_params = dict(params or {})
next_path: str | None = path
next_params = params
for _ in range(20):
if not next_path:
break
payload = self._request(next_path, next_params)
items.extend(_response_items(payload))
next_value = _pagination_next(payload)
if not next_value:
break
if next_value.isdigit():
next_path = base_path
next_params = {**base_params, 'page': int(next_value)}
continue
if next_value.startswith(('http://', 'https://')):
parsed_base = urlparse(config.url)
parsed_next = urlparse(next_value)
if parsed_next.netloc and parsed_next.netloc != parsed_base.netloc:
break
next_path = next_value
else:
next_path = urljoin('/api/v3/', next_value).replace('/api/v3', '', 1)
next_params = None
return items
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
root = self._request('/root/config/')
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {'status': 'connected', 'version': self._version(root)}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(applicationCount=0, outpostAvailable=False)
try:
result = self.test_connection()
if result.get('status') != 'connected':
return self._error_payload(RuntimeError(str(result.get('reason', result.get('status')))), applicationCount=0, outpostAvailable=False)
apps = self.applications()
outpost = self.outpost_health()
except RuntimeError as exc:
return self._error_payload(exc, applicationCount=0, outpostAvailable=False)
return {
'success': True,
'status': 'connected',
'message': 'Authentik connector ready.',
'url': _safe_url(),
'version': str(result.get('version') or 'unknown'),
'applicationCount': len(apps.get('items', [])),
'outpostAvailable': bool(outpost.get('available')),
'data': {},
}
def system_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing()
try:
root = self._request('/root/config/')
except RuntimeError as exc:
return self._error_payload(exc)
data = {
'version': self._version(root),
'brand': root.get('brand') if isinstance(root, dict) else None,
'url': _safe_url(),
}
return {'success': True, 'status': 'ok', 'message': 'system_status_ok', **data, 'data': data}
def applications(self) -> dict[str, Any]:
return self._memo('applications', lambda: self._collection('/core/applications/', self._map_applications, 'applications_ok'))
def providers(self) -> dict[str, Any]:
return self._memo('providers', lambda: self._collection('/core/providers/', self._map_providers, 'providers_ok'))
def proxy_providers(self) -> dict[str, Any]:
return self._memo('proxy_providers', lambda: self._collection('/providers/proxy/', self._map_proxy_providers, 'proxy_providers_ok'))
def oauth2_providers(self) -> dict[str, Any]:
return self._memo('oauth2_providers', lambda: self._collection('/providers/oauth2/', self._map_oauth2_providers, 'oauth2_providers_ok'))
def groups(self) -> dict[str, Any]:
"""Read role-group identities for enrollment takeover-safe preflight."""
return self._memo(
'groups',
lambda: self._collection(
'/core/groups/',
lambda items: [
{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or '')}
for item in items
],
'groups_ok',
),
)
@staticmethod
def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]:
"""Report only whether the server-owned OIDC template is configured."""
required = (
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW',
'AUTHENTIK_OIDC_INVALIDATION_FLOW',
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING',
)
missing = [name for name in required if not str(settings.apis.get(name) or '').strip()]
configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
if configured_claim != role_claim:
missing.append('AUTHENTIK_OIDC_ROLE_CLAIM')
return {
'status': 'ready' if not missing else 'missing_config',
'missing': missing,
}
def ensure_deployment_oidc_application(
self,
*,
application_slug: str,
launch_url: str,
redirect_uri: str,
client_secret: str,
administrator_group: str,
support_agent_group: str,
read_only_group: str,
) -> dict[str, Any]:
"""Provision one Authentik OIDC app from the reviewed deployment template.
The method refuses tenant-wide group/flow creation and never returns the
supplied/generated client secret. Existing applications are accepted
only when their slug and launch URL already identify this deployment.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug):
return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'}
if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url):
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
if not redirect_uri.startswith(f'{launch_url}/') or '?' in redirect_uri or '#' in redirect_uri:
return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'}
authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip()
if not authorization_flow or not invalidation_flow or not group_claim_mapping:
return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'}
try:
groups = self._paged('/core/groups/')
group_names = {str(item.get('name') or '') for item in groups}
required_groups = {administrator_group, support_agent_group, read_only_group}
if not required_groups.issubset(group_names):
return {'success': False, 'status': 'blocked', 'message': 'required_role_groups_are_missing'}
applications = self._paged('/core/applications/')
matches = [item for item in applications if str(item.get('slug') or '') == application_slug]
if len(matches) > 1:
return {'success': False, 'status': 'conflict', 'message': 'multiple_authentik_applications_with_slug'}
if matches:
existing = matches[0]
existing_url = str(existing.get('meta_launch_url') or existing.get('launch_url') or '')
if existing_url.rstrip('/') != launch_url.rstrip('/'):
return {'success': False, 'status': 'conflict', 'message': 'application_slug_owned_by_different_launch_url'}
provider_id = existing.get('provider') or existing.get('provider_id')
providers = self._paged('/providers/oauth2/')
provider = next(
(item for item in providers if str(item.get('pk') or item.get('id')) == str(provider_id)),
None,
)
provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None
if isinstance(provider_redirects, str):
provider_redirects = [item for item in provider_redirects.splitlines() if item.strip()]
provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None
if (
not isinstance(provider, dict)
or str(provider.get('client_id') or '') != application_slug
or str(provider.get('client_type') or '') != 'confidential'
or provider_redirects != [redirect_uri]
or group_claim_mapping not in (provider_mappings or [])
or str(provider.get('authorization_flow') or '') != authorization_flow
or str(provider.get('invalidation_flow') or '') != invalidation_flow
):
return {'success': False, 'status': 'conflict', 'message': 'existing_oidc_provider_does_not_match_managed_contract'}
return {
'success': True,
'status': 'already_managed',
'message': 'oidc_application_already_matches_typed_plan',
'applicationId': existing.get('pk') or existing.get('id'),
'clientSecretCreated': False,
}
provider = self._write_request(
'POST',
'/providers/oauth2/',
{
'name': f'{application_slug} OIDC',
'authorization_flow': authorization_flow,
'invalidation_flow': invalidation_flow,
'client_type': 'confidential',
'client_id': application_slug,
'client_secret': client_secret,
'redirect_uris': [redirect_uri],
'property_mappings': [group_claim_mapping],
'include_claims_in_id_token': True,
'sub_mode': 'hashed_user_id',
'issuer_mode': 'per_provider',
},
)
provider_id = provider.get('pk') or provider.get('id') if isinstance(provider, dict) else None
if not provider_id:
return {'success': False, 'status': 'failed', 'message': 'oidc_provider_response_missing_identifier'}
try:
application = self._write_request(
'POST',
'/core/applications/',
{
'name': application_slug,
'slug': application_slug,
'provider': provider_id,
'meta_launch_url': launch_url,
},
)
except RuntimeError:
# A generated client secret must never appear in an API error,
# audit record or browser response. Compensate only the provider
# this method just created, and fail closed if that cleanup is
# not acknowledged by Authentik.
try:
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError:
return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_failed_provider_cleanup_failed'}
return {'success': False, 'status': 'failed', 'message': 'oidc_application_creation_failed_provider_removed'}
except RuntimeError:
# The provider request carries the confidential client secret. A
# remote error body is not a safe channel for returning it.
return {'success': False, 'status': 'failed', 'message': 'oidc_provider_creation_failed'}
application_id = application.get('pk') or application.get('id') if isinstance(application, dict) else None
if not application_id:
try:
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError:
return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_response_missing_identifier_provider_cleanup_failed'}
return {'success': False, 'status': 'failed', 'message': 'oidc_application_response_missing_identifier_provider_removed'}
return {
'success': True,
'status': 'created',
'message': 'oidc_application_created_from_typed_template',
'applicationId': application_id,
'providerId': provider_id,
'clientSecretCreated': True,
}
def remove_deployment_oidc_application(
self,
*,
application_id: str | int,
provider_id: str | int,
application_slug: str,
launch_url: str,
) -> dict[str, Any]:
"""Compensate only an OIDC pair created during this enrollment call.
This is intentionally private to the enrollment orchestrator. It first
re-reads both resources and refuses to delete if the identity is no
longer exactly the bounded application/provider pair it created.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug):
return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'}
if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url):
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
try:
applications = self._paged('/core/applications/')
matches = [
item for item in applications
if str(item.get('pk') or item.get('id')) == str(application_id)
]
if len(matches) != 1:
return {'success': False, 'status': 'not_found', 'message': 'managed_oidc_application_not_found'}
application = matches[0]
if (
application.get('slug') != application_slug
or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') != launch_url.rstrip('/')
or str(application.get('provider') or application.get('provider_id') or '') != str(provider_id)
):
return {'success': False, 'status': 'conflict', 'message': 'oidc_application_is_not_the_created_managed_pair'}
provider = self._write_request('GET', f'/providers/oauth2/{provider_id}/')
if not isinstance(provider, dict) or str(provider.get('client_id') or '') != application_slug:
return {'success': False, 'status': 'conflict', 'message': 'oidc_provider_is_not_the_created_managed_pair'}
self._write_request('DELETE', f'/core/applications/{application_id}/')
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError as exc:
return self._error_payload(exc)
return {
'success': True,
'status': 'removed',
'applicationId': application_id,
'providerId': provider_id,
}
def outposts(self) -> dict[str, Any]:
return self._memo('outposts', lambda: self._collection('/outposts/instances/', self._map_outposts, 'outposts_ok'))
def outpost_health(self) -> dict[str, Any]:
return self._memo('outpost_health', self._outpost_health_uncached)
def _outpost_health_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, available=False, embeddedExpected=False)
outposts = self.outposts()
if not outposts.get('success'):
return {**outposts, 'available': False}
items = outposts.get('items', [])
available = any(str(item.get('health') or '').lower() in {'healthy', 'ok', 'connected'} for item in items)
config = self._config()
embedded_expected = bool(config and config.expect_embedded_outpost)
reachability = {'status': 'reported_healthy', 'reachable': True, 'statusCode': None} if available else self._probe_outpost(config) if config and config.outpost_url else {
'status': 'not_configured',
'reachable': None,
'statusCode': None,
}
reachable = reachability.get('reachable')
status = 'ok' if available else ('reachable_unverified' if reachable else ('warning' if embedded_expected else 'unknown'))
if available:
message = 'outpost_health_ok'
elif reachable:
message = 'outpost_reachable_health_unverified'
else:
message = 'outpost_health_unknown'
data = {
'items': items,
'available': available,
'embeddedExpected': embedded_expected,
'outpostUrl': _safe_url(config.outpost_url) if config and config.outpost_url else None,
'reachable': reachable,
'reachabilityStatus': reachability.get('status'),
'reachabilityStatusCode': reachability.get('statusCode'),
'telemetryStatus': 'reported' if available else 'unverified',
}
return {'success': True, 'status': status, 'message': message, 'items': items, 'total': len(items), **data, 'data': data}
@staticmethod
def _probe_outpost(config: _AuthentikConfig) -> dict[str, Any]:
if not config.outpost_url:
return {'status': 'not_configured', 'reachable': None, 'statusCode': None}
try:
# Never forward the Authentik API bearer token to a separately
# configured outpost origin. This probe verifies reachability only.
with httpx.Client(
timeout=min(config.timeout_seconds, 8.0),
verify=config.verify_tls,
follow_redirects=False,
) as client:
response = client.get(
config.outpost_url,
headers={'Accept': 'text/html,application/json;q=0.9,*/*;q=0.8'},
)
except httpx.RequestError:
return {'status': 'unreachable', 'reachable': False, 'statusCode': None}
reachable = response.status_code < 500
return {
'status': 'reachable' if reachable else 'degraded',
'reachable': reachable,
'statusCode': response.status_code,
}
def flows_summary(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, byDesignation={})
try:
items = self._paged('/flows/instances/')
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0, byDesignation={})
mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or item.get('slug') or ''), 'designation': str(item.get('designation') or 'unknown')} for item in items]
by_designation: dict[str, int] = {}
for item in mapped:
by_designation[item['designation']] = by_designation.get(item['designation'], 0) + 1
return {'success': True, 'status': 'ok', 'message': 'flows_summary_ok', 'total': len(mapped), 'items': mapped, 'byDesignation': by_designation, 'data': {'items': mapped, 'byDesignation': by_designation}}
def policies_summary(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, enabled=0, disabled=0)
try:
items = self._paged('/policies/all/')
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0, enabled=0, disabled=0)
mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'enabled': bool(item.get('enabled', True))} for item in items]
enabled = sum(1 for item in mapped if item['enabled'])
data = {'items': mapped, 'enabled': enabled, 'disabled': len(mapped) - enabled}
return {'success': True, 'status': 'ok', 'message': 'policies_summary_ok', 'total': len(mapped), **data, 'data': data}
def events(self, limit: int = 25) -> dict[str, Any]:
cache_key = f'events:{max(1, min(limit, 100))}'
return self._memo(cache_key, lambda: self._events_uncached(limit))
def _events_uncached(self, limit: int = 25) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
raw = self._paged('/events/events/', {'page_size': max(1, min(limit, 100))})
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
items = [self._map_event(item) for item in raw[:limit]]
return {'success': True, 'status': 'ok', 'message': 'events_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def auth_coverage(self) -> dict[str, Any]:
return self._memo('auth_coverage', self._auth_coverage_uncached)
def _auth_coverage_uncached(self) -> dict[str, Any]:
apps = self.applications()
providers = self.providers()
proxy = self.proxy_providers()
if not apps.get('success'):
return {**apps, 'summary': self._empty_coverage_summary(), 'publicHosts': [], 'data': {'publicHosts': [], 'summary': self._empty_coverage_summary()}}
npm_hosts = self._npm_hosts()
hosts = npm_hosts.get('items', []) if npm_hosts.get('success') else []
proxy_domains = {_domain(item.get('externalHost')): item for item in proxy.get('items', []) if _domain(item.get('externalHost'))} if proxy.get('success') else {}
app_domains = {_domain(item.get('launchUrl')): item for item in apps.get('items', []) if _domain(item.get('launchUrl'))}
public_hosts: list[dict[str, Any]] = []
for host in hosts:
if not host.get('enabled', True):
continue
domains = [str(item) for item in _as_list(host.get('domainNames'))]
matches = [domain for domain in domains if _domain(domain) in proxy_domains or _domain(domain) in app_domains]
status = 'protected' if matches else ('likely_protected' if host.get('accessListId') else 'unprotected')
public_hosts.append({
'hostId': host.get('id'),
'domains': domains,
'target': f"{host.get('forwardScheme', 'http')}://{host.get('forwardHost', '')}:{host.get('forwardPort', '')}",
'coverageStatus': status,
'authentikMatches': matches,
'accessListId': host.get('accessListId'),
})
summary = {
'totalApplications': len(apps.get('items', [])),
'totalProviders': len(providers.get('items', [])) if providers.get('success') else 0,
'totalProxyProviders': len(proxy.get('items', [])) if proxy.get('success') else 0,
'totalPublicHosts': len(public_hosts),
'protectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'protected'),
'likelyProtectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'likely_protected'),
'unprotectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'unprotected'),
'npmStatus': npm_hosts.get('status', 'unknown'),
}
data = {'summary': summary, 'publicHosts': public_hosts}
return {'success': True, 'status': 'ok', 'message': 'auth_coverage_ok', 'summary': summary, 'publicHosts': public_hosts, 'data': data}
def security_warnings(self) -> dict[str, Any]:
return self._memo('security_warnings', self._security_warnings_uncached)
def _security_warnings_uncached(self) -> dict[str, Any]:
warnings: list[dict[str, Any]] = []
if not self.is_configured():
npm_hosts = self._npm_hosts()
if npm_hosts.get('success') and npm_hosts.get('items'):
warnings.append({'type': 'authentik_missing_config_with_public_hosts', 'severity': 'high', 'title': 'Authentik connector is missing while NPM has public hosts', 'evidence': [str(len(npm_hosts.get('items', [])))], 'suggestedAction': 'configure_authentik_connector'})
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': warnings, 'total': len(warnings), 'data': {'items': warnings}}
coverage = self.auth_coverage()
outpost = self.outpost_health()
apps = self.applications()
proxy = self.proxy_providers()
oauth = self.oauth2_providers()
events = self.events()
if not outpost.get('items'):
warnings.append({'type': 'no_outposts', 'severity': 'high', 'title': 'No Authentik outposts reported', 'evidence': [], 'suggestedAction': 'review_outpost_health'})
if outpost.get('embeddedExpected') and not outpost.get('available'):
outpost_status = str(outpost.get('status') or 'unknown')
if outpost.get('reachable'):
warnings.append({
'type': 'embedded_outpost_health_unverified',
'severity': 'info',
'title': 'Embedded outpost is reachable but health telemetry is not reported',
'evidence': [outpost_status, str(outpost.get('reachabilityStatusCode') or '')],
'suggestedAction': 'review_embedded_outpost_telemetry',
})
else:
warnings.append({'type': 'embedded_outpost_unavailable', 'severity': 'warning', 'title': 'Expected embedded outpost is unavailable or unknown', 'evidence': [outpost_status], 'suggestedAction': 'review_embedded_outpost'})
for host in coverage.get('publicHosts', []):
if host.get('coverageStatus') == 'unprotected':
warnings.append({'type': 'public_host_without_authentik', 'severity': 'high', 'title': f"{', '.join(host.get('domains') or [])} has no Authentik mapping", 'evidence': host.get('domains') or [], 'suggestedAction': 'map_host_to_authentik'})
app_slugs = {str(item.get('slug')) for item in apps.get('items', []) if item.get('slug')}
provider_app_slugs = {str(item.get('assignedApplicationSlug')) for item in proxy.get('items', []) if item.get('assignedApplicationSlug')}
for provider in proxy.get('items', []):
if not provider.get('assignedApplicationSlug'):
warnings.append({'type': 'proxy_provider_without_app', 'severity': 'warning', 'title': f"{provider.get('name')} is not assigned to an application", 'evidence': [str(provider.get('externalHost') or provider.get('id'))], 'suggestedAction': 'assign_proxy_provider'})
for app in apps.get('items', []):
slug = str(app.get('slug') or '')
if slug and slug not in provider_app_slugs and not app.get('providerId'):
warnings.append({'type': 'application_without_provider', 'severity': 'warning', 'title': f"{app.get('name')} has no provider mapping", 'evidence': [slug], 'suggestedAction': 'review_application_provider'})
for provider in oauth.get('items', []):
if _as_int(provider.get('redirectUriCount')) >= 4:
warnings.append({'type': 'oauth_many_redirect_uris', 'severity': 'warning', 'title': f"{provider.get('name')} has many redirect URIs", 'evidence': [str(provider.get('redirectUriCount'))], 'suggestedAction': 'review_oauth_redirect_uris'})
if not events.get('items'):
warnings.append({'type': 'no_recent_events', 'severity': 'info', 'title': 'No recent Authentik events returned', 'evidence': [], 'suggestedAction': 'review_event_retention_or_permissions'})
warnings = [item for item in warnings if item.get('evidence') is not app_slugs]
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
items = sorted(warnings, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True)
status = 'warning' if items else 'ok'
return {'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'items': items, 'total': len(items), 'data': {'items': items}}
def _collection(self, path: str, mapper: Any, message: str) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = mapper(self._paged(path))
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': message, 'total': len(items), 'items': items, 'data': {'items': items}}
@staticmethod
def _version(payload: Any) -> str:
if not isinstance(payload, dict):
return 'unknown'
return str(payload.get('version') or payload.get('build') or payload.get('authentik_version') or 'unknown')
@staticmethod
def _map_applications(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'slug': str(item.get('slug') or ''),
'launchUrl': str(item.get('launch_url') or item.get('launchUrl') or item.get('meta_launch_url') or ''),
'providerId': item.get('provider') or item.get('providerId'),
} for item in raw]
@staticmethod
def _map_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for item in raw:
type_text = str(item.get('type') or item.get('component') or '').lower()
provider_type = 'proxy' if 'proxy' in type_text else ('oauth2' if 'oauth' in type_text or 'oidc' in type_text else 'unknown')
result.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'providerType': provider_type, 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug')})
return result
@staticmethod
def _map_proxy_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'externalHost': str(item.get('external_host') or item.get('externalHost') or ''),
'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'),
'mode': str(item.get('mode') or ''),
} for item in raw]
@staticmethod
def _map_oauth2_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
mapped = []
for item in raw:
redirect_uris = item.get('redirect_uris') or item.get('redirectUris') or []
if isinstance(redirect_uris, str):
uris = [line for line in redirect_uris.splitlines() if line.strip()]
else:
uris = [str(uri) for uri in _as_list(redirect_uris)]
mapped.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), 'redirectUriCount': len(uris), 'redirectUris': uris[:10]})
return mapped
@staticmethod
def _map_outposts(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'type': str(item.get('type') or item.get('outpost_type') or 'unknown'),
'health': str(item.get('health') or item.get('health_status') or item.get('status') or 'unknown'),
'providerCount': len(_as_list(item.get('providers'))),
} for item in raw]
@staticmethod
def _map_event(raw: dict[str, Any]) -> dict[str, Any]:
user = raw.get('user') if isinstance(raw.get('user'), dict) else {}
return {
'id': raw.get('pk') or raw.get('id'),
'action': str(raw.get('action') or raw.get('event') or ''),
'created': raw.get('created') or raw.get('timestamp'),
'username': str(user.get('username') or raw.get('username') or ''),
'clientIp': AuthentikConnector._mask_ip(raw.get('client_ip') or raw.get('clientIp')),
}
@staticmethod
def _mask_ip(value: Any) -> str:
text = str(value or '')
parts = text.split('.')
if len(parts) == 4:
return '.'.join(parts[:3] + ['x'])
return 'masked' if text else ''
@staticmethod
def _empty_coverage_summary() -> dict[str, Any]:
return {'totalApplications': 0, 'totalProviders': 0, 'totalProxyProviders': 0, 'totalPublicHosts': 0, 'protectedHosts': 0, 'likelyProtectedHosts': 0, 'unprotectedHosts': 0, 'npmStatus': 'unknown'}
@staticmethod
def _missing(**extra: Any) -> dict[str, Any]:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], 'data': {}, **extra}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}
@staticmethod
def _npm_hosts() -> dict[str, Any]:
try:
from .nginx_proxy_manager import NginxProxyManagerConnector
return NginxProxyManagerConnector().proxy_hosts()
except Exception as exc:
return {'success': False, 'status': 'unavailable', 'message': redact_message(str(exc)), 'items': []}
+14
View File
@@ -0,0 +1,14 @@
class BaseConnector:
name: str = 'base'
def __init__(self):
self.last_error: str | None = None
def is_configured(self):
return False
def set_error(self, message: str):
self.last_error = message
def clear_error(self):
self.last_error = None
+370
View File
@@ -0,0 +1,370 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_TORRENT_FIELDS = [
'name',
'state',
'progress',
'download_payload_rate',
'upload_payload_rate',
'eta',
'ratio',
'total_size',
'tracker_host',
'label',
'save_path',
'error',
'is_finished',
]
@dataclass
class _DelugeConfig:
url: str
rpc_url: str
password: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_number(value: Any, default: float = 0) -> float:
if value is None or value == '':
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_path(value: Any) -> str:
text = str(value or '').replace('\\', '/').rstrip('/')
if not text:
return ''
parts = [part for part in text.split('/') if part]
if not parts:
return ''
return f'.../{parts[-1]}'
def _safe_url() -> str:
value = settings.apis.get('DELUGE_URL')
return str(value).rstrip('/') if value else ''
class DelugeConnector(BaseConnector):
name = 'Deluge'
def __init__(self) -> None:
self._client: httpx.Client | None = None
self._rpc_id = 0
def is_configured(self) -> bool:
return bool(settings.apis.get('DELUGE_URL') and settings.apis.get('DELUGE_PASSWORD'))
def _config(self) -> _DelugeConfig | None:
if not self.is_configured():
return None
base_url = str(settings.apis['DELUGE_URL']).rstrip('/')
web_path = str(settings.apis.get('DELUGE_WEB_PATH') or '/json').strip() or '/json'
if not web_path.startswith('/'):
web_path = f'/{web_path}'
rpc_url = base_url if base_url.endswith('/json') else f'{base_url}{web_path}'
return _DelugeConfig(
url=base_url,
rpc_url=rpc_url,
password=str(settings.apis['DELUGE_PASSWORD']),
verify_tls=_as_bool(settings.apis.get('DELUGE_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('DELUGE_TIMEOUT_SECONDS'), 10.0),
)
def _http(self) -> httpx.Client:
config = self._config()
if not config:
raise RuntimeError('missing_config')
if self._client is None:
self._client = httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls)
return self._client
def _rpc(self, method: str, params: list[Any] | None = None, authed: bool = True) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
if authed:
self._login()
self._rpc_id += 1
body = {'method': method, 'params': params or [], 'id': self._rpc_id}
try:
response = self._http().post(config.rpc_url, json=body)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Deluge Web JSON-RPC error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if isinstance(payload, dict) and payload.get('error'):
error = payload['error']
message = redact_message(str(error.get('message') if isinstance(error, dict) else error))
if 'password' in message.lower() or 'auth' in message.lower():
raise RuntimeError(f'auth_failed {message}')
raise RuntimeError(message)
return payload.get('result') if isinstance(payload, dict) else payload
def _login(self) -> bool:
config = self._config()
if not config:
raise RuntimeError('missing_config')
result = self._rpc('auth.login', [config.password], authed=False)
if result is not True:
raise RuntimeError('auth_failed password=[REDACTED]')
return True
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
optional_warnings: list[dict[str, str]] = []
def optional_rpc(method: str, params: list[Any] | None = None) -> Any:
try:
return self._rpc(method, params)
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
raise
optional_warnings.append({'method': method, 'reason': reason})
return None
try:
self._login()
web_connected = optional_rpc('web.connected')
daemon_connected = optional_rpc('daemon.connected')
version = optional_rpc('daemon.get_version')
session_payload = optional_rpc('core.get_session_status', [['num_torrents']])
if web_connected is None and daemon_connected is None and version is None and session_payload is None:
reason = optional_warnings[0]['reason'] if optional_warnings else 'connection_failed'
return {'status': 'unreachable', 'reason': reason, 'diagnostics': {'optionalMethodWarnings': optional_warnings}}
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
fallback = 'core.get_session_status' if session_payload is not None and optional_warnings else None
return {
'status': 'connected',
'webConnected': bool(web_connected) if web_connected is not None else None,
'daemonConnected': bool(daemon_connected) if daemon_connected is not None else None,
'version': str(version or 'unknown'),
'diagnostics': {
'healthFallback': fallback,
'optionalMethodWarnings': optional_warnings,
},
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'url': '',
'webConnected': None,
'daemonConnected': None,
'version': 'unknown',
'required_env_vars': ['DELUGE_URL', 'DELUGE_PASSWORD'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Deluge connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'webConnected': result.get('webConnected'),
'daemonConnected': result.get('daemonConnected'),
'version': str(result.get('version', 'unknown')),
'diagnostics': result.get('diagnostics', {'healthFallback': None, 'optionalMethodWarnings': []}),
}
def session_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_session('missing_config')
try:
connected = bool(self._rpc('web.connected'))
session_payload = self._rpc('core.get_session_status', [['download_rate', 'upload_rate', 'num_torrents']])
torrents = self.torrent_status()
except RuntimeError as exc:
return self._empty_session('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc)))
items = torrents.get('items', []) if isinstance(torrents, dict) else []
return {
'success': True,
'status': 'ok',
'connected': connected,
'downloadRate': _as_number((session_payload or {}).get('download_rate')),
'uploadRate': _as_number((session_payload or {}).get('upload_rate')),
'numTorrents': int(_as_number((session_payload or {}).get('num_torrents'), len(items))),
'paused': sum(1 for item in items if item.get('paused')),
'active': sum(1 for item in items if item.get('active')),
'stalled': sum(1 for item in items if item.get('stalled')),
'errors': sum(1 for item in items if str(item.get('state', '')).lower() == 'error'),
}
def torrent_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_torrents('missing_config')
try:
payload = self._rpc('web.update_ui', [_TORRENT_FIELDS, {}])
except RuntimeError as exc:
return self._empty_torrents('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc)))
records = (payload or {}).get('torrents') if isinstance(payload, dict) else {}
items = [self._map_torrent(torrent_id, raw) for torrent_id, raw in (records or {}).items() if isinstance(raw, dict)]
return {'success': True, 'status': 'ok', 'message': 'torrents_ok', 'total': len(items), 'items': items}
def active_downloads(self) -> dict[str, Any]:
return self._filtered('active', lambda item: bool(item.get('active')))
def stalled_downloads(self) -> dict[str, Any]:
return self._filtered('stalled', lambda item: bool(item.get('stalled')))
def paused_downloads(self) -> dict[str, Any]:
return self._filtered('paused', lambda item: bool(item.get('paused')))
def speed_status(self) -> dict[str, Any]:
session = self.session_status()
return {
'success': bool(session.get('success')),
'status': session.get('status', 'unreachable'),
'connected': bool(session.get('connected')),
'downloadRate': session.get('downloadRate', 0),
'uploadRate': session.get('uploadRate', 0),
'active': session.get('active', 0),
'paused': session.get('paused', 0),
'stalled': session.get('stalled', 0),
'errors': session.get('errors', 0),
}
def pause_torrent(self, torrent_id: str) -> dict[str, Any]:
return self._torrent_mutation('core.pause_torrent', torrent_id, 'torrent_paused')
def resume_torrent(self, torrent_id: str) -> dict[str, Any]:
return self._torrent_mutation('core.resume_torrent', torrent_id, 'torrent_resumed')
def pause_all(self) -> dict[str, Any]:
return self._bulk_mutation('core.pause_all_torrents', 'all_torrents_paused')
def resume_all(self) -> dict[str, Any]:
return self._bulk_mutation('core.resume_all_torrents', 'all_torrents_resumed')
def _torrent_mutation(self, method: str, torrent_id: str, message: str) -> dict[str, Any]:
clean_id = torrent_id.strip()
if not clean_id:
return {'success': False, 'status': 'error', 'message': 'torrent_id_required'}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'torrentId': clean_id}}
try:
self._rpc(method, [[clean_id]])
except RuntimeError as exc:
status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable'
return {'success': False, 'status': status, 'message': redact_message(str(exc)), 'data': {'torrentId': clean_id}}
return {'success': True, 'status': 'ok', 'message': message, 'data': {'torrentId': clean_id}}
def _bulk_mutation(self, method: str, message: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
self._rpc(method)
except RuntimeError as exc:
status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable'
return {'success': False, 'status': status, 'message': redact_message(str(exc))}
return {'success': True, 'status': 'ok', 'message': message, 'data': {'accepted': True}}
def _filtered(self, name: str, predicate: Any) -> dict[str, Any]:
payload = self.torrent_status()
if not payload.get('success'):
return {'success': False, 'status': payload.get('status'), 'message': payload.get('message'), 'total': 0, 'items': []}
items = [item for item in payload.get('items', []) if predicate(item)]
return {'success': True, 'status': 'ok', 'message': f'{name}_downloads_ok', 'total': len(items), 'items': items}
@staticmethod
def _map_torrent(torrent_id: str, raw: dict[str, Any]) -> dict[str, Any]:
state = str(raw.get('state') or 'unknown')
state_lower = state.lower()
download_rate = _as_number(raw.get('download_payload_rate'))
error = str(raw.get('error') or '')
paused = state_lower == 'paused'
active = state_lower in {'downloading', 'allocating', 'checking', 'moving'} and download_rate > 0
stalled = state_lower in {'error', 'queued'} or (state_lower == 'downloading' and download_rate <= 0)
return {
'id': str(torrent_id),
'name': str(raw.get('name') or torrent_id),
'state': state,
'progress': _as_number(raw.get('progress')),
'downloadPayloadRate': download_rate,
'uploadPayloadRate': _as_number(raw.get('upload_payload_rate')),
'eta': _as_number(raw.get('eta')),
'ratio': _as_number(raw.get('ratio')),
'totalSize': _as_number(raw.get('total_size')),
'trackerHost': str(raw.get('tracker_host') or ''),
'category': str(raw.get('label') or raw.get('category') or ''),
'savePath': _safe_path(raw.get('save_path')),
'error': error,
'isFinished': bool(raw.get('is_finished')),
'paused': paused,
'stalled': stalled,
'active': active,
}
@staticmethod
def _empty_session(status: str, message: str | None = None) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': message or status,
'connected': False,
'downloadRate': 0,
'uploadRate': 0,
'numTorrents': 0,
'paused': 0,
'active': 0,
'stalled': 0,
'errors': 0,
}
@staticmethod
def _empty_torrents(status: str, message: str | None = None) -> dict[str, Any]:
return {'success': False, 'status': status, 'message': message or status, 'total': 0, 'items': []}
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
import copy
import json
from datetime import UTC, datetime
from threading import Lock
from time import monotonic
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_CACHE_SECONDS = 15
_WARNING_AGE_SECONDS = 180
_CRITICAL_AGE_SECONDS = 600
_ACTIVE_WARNING_AGE_SECONDS = 1_800
_ACTIVE_CRITICAL_AGE_SECONDS = 7_200
_QUEUE_SAMPLE_LIMIT = 50
_CACHE_LOCK = Lock()
_CACHE: tuple[float, dict[str, Any]] | None = None
_DEFAULT_RUNNER_TARGETS = {
"linux-validation": "itworx-validation-runner-2",
"docker-validation": "itworx-docker-runner",
"browser-validation": "itworx-browser-runner",
"security-validation": "itworx-security-runner",
"mcp-validation": "itworx-security-runner",
"unraid-deploy": "itworx-security-runner",
}
_DEFAULT_RUNNER_TARGET_CANDIDATES = {
"linux-validation": ("itworx-validation-runner-2", "itworx-validation-runner-3"),
"docker-validation": (
"itworx-docker-runner",
"itworx-validation-runner-2",
"itworx-security-runner",
),
"browser-validation": ("itworx-browser-runner",),
"security-validation": ("itworx-security-runner",),
"mcp-validation": ("itworx-security-runner",),
"unraid-deploy": ("itworx-security-runner",),
}
class GiteaActionsConnector(BaseConnector):
"""Read a bounded Gitea Actions control-plane snapshot; never execute jobs."""
name = "Gitea Actions"
def is_configured(self) -> bool:
return bool(
settings.apis.get("GITEA_URL") and settings.apis.get("GITEA_ADMIN_TOKEN")
)
def _base_url(self) -> str:
raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/")
parsed = urlsplit(raw)
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username
):
raise RuntimeError("invalid_gitea_url")
return urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")
)
def _request(
self, path: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
if not self.is_configured():
raise RuntimeError("missing_config")
token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "")
timeout = max(
2.0,
min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8)),
)
try:
with httpx.Client(
timeout=timeout,
verify=bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls)),
) as client:
response = client.get(
f"{self._base_url()}{path}",
params=params,
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
},
)
except (httpx.RequestError, ValueError) as exc:
raise RuntimeError(redact_message(str(exc))) from exc
if response.status_code in {401, 403}:
raise RuntimeError("gitea_admin_access_denied")
if response.status_code >= 400:
raise RuntimeError(f"gitea_actions_http_{response.status_code}")
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError("gitea_actions_invalid_response") from exc
if not isinstance(payload, dict):
raise RuntimeError("gitea_actions_invalid_response") # noqa: TRY004
return payload
@staticmethod
def _timestamp(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
@staticmethod
def _labels(value: Any) -> list[str]:
if not isinstance(value, list):
return []
labels = []
for item in value:
label = item.get("name") if isinstance(item, dict) else item
text = str(label or "").strip()
if text:
labels.append(text[:120])
return labels
@staticmethod
def _configured_restart_targets() -> dict[str, str]:
raw = str(settings.apis.get("GITEA_RUNNER_TARGETS_JSON") or "{}")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = {}
return (
{
str(label)[:120]: str(target)[:240]
for label, target in payload.items()
if str(label).strip() and str(target).strip()
}
if isinstance(payload, dict)
else {}
)
@classmethod
def _restart_targets(cls) -> dict[str, str]:
return {**_DEFAULT_RUNNER_TARGETS, **cls._configured_restart_targets()}
@classmethod
def _target_for_label(
cls,
label: str,
runners: list[dict[str, Any]],
targets: dict[str, str],
) -> str | None:
"""Keep explicit mappings authoritative; prove only built-in defaults."""
configured = cls._configured_restart_targets()
if label in configured:
return configured[label]
compatible = {
str(runner.get("name") or "")
for runner in runners
if label in runner.get("labels", []) and str(runner.get("name") or "")
}
for candidate in _DEFAULT_RUNNER_TARGET_CANDIDATES.get(
label, (targets.get(label, ""),)
):
if candidate and candidate in compatible:
return candidate
return None
def snapshot(self, *, refresh: bool = False) -> dict[str, Any]:
global _CACHE
now_monotonic = monotonic()
with _CACHE_LOCK:
if (
not refresh
and _CACHE is not None
and now_monotonic - _CACHE[0] < _CACHE_SECONDS
):
cached = copy.deepcopy(_CACHE[1])
cached["cache"] = {"hit": True, "ttlSeconds": _CACHE_SECONDS}
return cached
if not self.is_configured():
return {
"status": "missing_config",
"severity": "unknown",
"generatedAt": datetime.now(UTC).isoformat(),
"runners": [],
"queue": {
"queued": 0,
"inProgress": 0,
"oldestQueuedAgeSeconds": None,
"oldestInProgressAgeSeconds": None,
"sampleTruncated": False,
"blockedByRunnerLabels": [],
"staleAssignmentRunnerLabels": [],
},
"recommendedAction": None,
"cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS},
}
try:
runner_payload = self._request(
"/api/v1/admin/actions/runners", {"disabled": "false"}
)
queued_payload = self._request(
"/api/v1/admin/actions/jobs",
{
"status": "queued",
"page": 1,
"limit": 100,
"sort": "id",
"order": "asc",
},
)
pending_payload = self._request(
"/api/v1/admin/actions/jobs",
{
"status": "pending",
"page": 1,
"limit": 100,
"sort": "id",
"order": "asc",
},
)
active_payload = self._request(
"/api/v1/admin/actions/jobs",
{"status": "in_progress", "page": 1, "limit": 100},
)
except RuntimeError as exc:
return {
"status": "error",
"severity": "warning",
"reason": redact_message(str(exc))[:240],
"generatedAt": datetime.now(UTC).isoformat(),
"runners": [],
"queue": {
"queued": 0,
"inProgress": 0,
"oldestQueuedAgeSeconds": None,
"oldestInProgressAgeSeconds": None,
"sampleTruncated": False,
"blockedByRunnerLabels": [],
"staleAssignmentRunnerLabels": [],
},
"recommendedAction": None,
"cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS},
}
raw_runners = runner_payload.get("runners")
runner_items = raw_runners if isinstance(raw_runners, list) else []
runners = [
{
"id": int(item.get("id", 0) or 0),
"name": str(item.get("name") or "unknown")[:160],
"status": str(item.get("status") or "unknown")[:32],
"busy": bool(item.get("busy")),
"disabled": bool(item.get("disabled")),
"labels": self._labels(item.get("labels")),
}
for item in runner_items
if isinstance(item, dict)
]
queued_jobs = [
item
for payload in (queued_payload, pending_payload)
for item in (
payload.get("jobs") if isinstance(payload.get("jobs"), list) else []
)
if isinstance(item, dict)
]
active_jobs = (
active_payload.get("jobs")
if isinstance(active_payload.get("jobs"), list)
else []
)
now = datetime.now(UTC)
queued_times = [self._timestamp(item.get("created_at")) for item in queued_jobs]
queued_times = [item for item in queued_times if item is not None]
oldest_age = max(
(int((now - item).total_seconds()) for item in queued_times), default=None
)
active_times = [
self._timestamp(item.get("started_at"))
or self._timestamp(item.get("created_at"))
for item in active_jobs
]
active_times = [item for item in active_times if item is not None]
oldest_active_age = max(
(int((now - item).total_seconds()) for item in active_times), default=None
)
stale_active_jobs = []
for job in active_jobs:
created_at = self._timestamp(job.get("started_at")) or self._timestamp(
job.get("created_at")
)
if (
created_at is not None
and int((now - created_at).total_seconds())
>= _ACTIVE_CRITICAL_AGE_SECONDS
):
stale_active_jobs.append(job)
online_runners = [
item
for item in runners
if item["status"] == "online" and not item["disabled"]
]
blocked_jobs = []
stale_assignment_jobs: list[dict[str, Any]] = []
queue_items = []
for job in queued_jobs:
required = set(self._labels(job.get("labels")))
matching = [
runner
for runner in online_runners
if required.issubset(set(runner["labels"]))
]
if required and not matching:
blocked_jobs.append(job)
created = self._timestamp(job.get("created_at"))
age = int((now - created).total_seconds()) if created else None
all_matching_busy = bool(matching) and all(
bool(runner["busy"]) for runner in matching
)
reason = (
"no_online_runner_matches_required_capability"
if required and not matching
else "all_matching_runners_are_busy"
if all_matching_busy
else "awaiting_scheduler_assignment"
)
if (
required
and matching
and not all_matching_busy
and age is not None
and age >= _CRITICAL_AGE_SECONDS
):
stale_assignment_jobs.append(
{
"job": job,
"required": sorted(required),
"matching": matching,
"ageSeconds": age,
}
)
if len(queue_items) < _QUEUE_SAMPLE_LIMIT:
queue_items.append(
{
"id": int(job.get("id", 0) or 0),
"name": str(job.get("name") or "job")[:160],
"labels": sorted(required)[:20],
"ageSeconds": age,
"matchingRunners": [runner["name"] for runner in matching][:20],
"reason": reason,
}
)
severity = "healthy"
if (
(oldest_age or 0) >= _CRITICAL_AGE_SECONDS
or (oldest_active_age or 0) >= _ACTIVE_CRITICAL_AGE_SECONDS
or blocked_jobs
):
severity = "critical"
elif (
(oldest_age or 0) >= _WARNING_AGE_SECONDS
or (oldest_active_age or 0) >= _ACTIVE_WARNING_AGE_SECONDS
or len(online_runners) < len(runners)
):
severity = "warning"
targets = self._restart_targets()
blocked_labels = [
label for job in blocked_jobs for label in self._labels(job.get("labels"))
]
stale_active_labels = [
label
for job in stale_active_jobs
for label in self._labels(job.get("labels"))
]
stale_assignment_labels = sorted(
{
label
for candidate in stale_assignment_jobs
for label in candidate["required"]
}
)
recommended_action: dict[str, Any] | None = None
for candidate in sorted(
stale_assignment_jobs,
key=lambda item: int(item["ageSeconds"]),
reverse=True,
):
job = candidate["job"]
matching_by_name = {
str(runner.get("name") or ""): runner for runner in candidate["matching"]
}
for label in candidate["required"]:
target = self._target_for_label(label, candidate["matching"], targets)
if not target:
continue
target_runner = matching_by_name.get(target)
if target_runner is not None and bool(target_runner.get("busy")):
continue
recommended_action = {
"intent": "runner.recover",
"target": target,
"requiresApproval": False,
"autoExecute": True,
"reason": "Gitea Actions scheduler left a compatible idle runner unassigned for at least ten minutes.",
"evidence": {
"jobId": int(job.get("id", 0) or 0),
"label": label,
"minimumAgeSeconds": _CRITICAL_AGE_SECONDS,
},
}
break
if recommended_action is not None:
break
if recommended_action is None:
restart_target = None
restart_reason = ""
for label in [*blocked_labels, *stale_active_labels]:
target = self._target_for_label(label, runners, targets)
if target:
restart_target = target
restart_reason = (
"Gitea Actions queue is stalled without a matching online runner."
if label in blocked_labels
else "A Gitea Actions job exceeded the two-hour guardrail on an explicitly mapped runner."
)
break
recommended_action = (
{
"intent": "container.restart",
"target": restart_target,
"requiresApproval": True,
"autoExecute": False,
"reason": restart_reason,
}
if severity == "critical" and restart_target
else None
)
result = {
"status": "ok",
"severity": severity,
"generatedAt": now.isoformat(),
"runners": runners,
"summary": {
"total": len(runners),
"online": len(online_runners),
"busy": sum(bool(item["busy"]) for item in runners),
"offline": sum(item["status"] != "online" for item in runners),
},
"queue": {
"queued": int(queued_payload.get("total_count", 0) or 0)
+ int(pending_payload.get("total_count", 0) or 0),
"inProgress": int(
active_payload.get("total_count", len(active_jobs)) or 0
),
"oldestQueuedAgeSeconds": oldest_age,
"oldestInProgressAgeSeconds": oldest_active_age,
"sampleTruncated": (
int(queued_payload.get("total_count", 0) or 0)
> len(
queued_payload.get("jobs")
if isinstance(queued_payload.get("jobs"), list)
else []
)
or int(pending_payload.get("total_count", 0) or 0)
> len(
pending_payload.get("jobs")
if isinstance(pending_payload.get("jobs"), list)
else []
)
),
"blockedByRunnerLabels": sorted(set(blocked_labels))[:20],
"staleActiveRunnerLabels": sorted(set(stale_active_labels))[:20],
"staleAssignmentRunnerLabels": stale_assignment_labels[:20],
"items": queue_items,
},
"recommendedAction": recommended_action,
"cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS},
}
with _CACHE_LOCK:
_CACHE = (now_monotonic, copy.deepcopy(result))
return result
@@ -0,0 +1,645 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _NpmConfig:
url: str
identity: str
secret: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if value in {None, ''}:
return []
return [value]
def _safe_url(value: Any = None) -> str:
raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/')
if not raw:
return ''
parsed = urlparse(raw)
if not parsed.username and not parsed.password:
return raw
host = parsed.hostname or ''
port = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
def _parse_date(value: Any) -> datetime | None:
if not value:
return None
text = str(value).strip()
if not text:
return None
for candidate in (text, text.replace('Z', '+00:00')):
try:
parsed = datetime.fromisoformat(candidate)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
except ValueError:
continue
for fmt in ('%Y-%m-%d', '%Y-%m-%d %H:%M:%S'):
try:
return datetime.strptime(text, fmt).replace(tzinfo=UTC)
except ValueError:
continue
return None
def _days_until(value: Any) -> int | None:
parsed = _parse_date(value)
if not parsed:
return None
return (parsed - datetime.now(UTC)).days
def _response_items(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
data = payload.get('data')
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(payload.get('items'), list):
return [item for item in payload['items'] if isinstance(item, dict)]
return []
class NginxProxyManagerConnector(BaseConnector):
name = 'Nginx Proxy Manager'
def __init__(self):
super().__init__()
self._runtime_token: str | None = None
self._read_cache: dict[str, dict[str, Any]] = {}
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
if key not in self._read_cache:
self._read_cache[key] = factory()
return self._read_cache[key]
def is_configured(self) -> bool:
return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET'))
def _config(self) -> _NpmConfig | None:
if not self.is_configured():
return None
return _NpmConfig(
url=str(settings.apis['NPM_URL']).rstrip('/'),
identity=str(settings.apis['NPM_IDENTITY']),
secret=str(settings.apis['NPM_SECRET']),
verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0),
)
def _login(self) -> str:
if self._runtime_token:
return self._runtime_token
config = self._config()
if not config:
raise RuntimeError('missing_config')
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.post(
f'{config.url}/api/tokens',
json={'identity': config.identity, 'secret': config.secret},
headers={'Accept': 'application/json'},
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {400, 401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
token = payload.get('token') if isinstance(payload, dict) else None
if not token:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
self._runtime_token = str(token)
return self._runtime_token
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
token = self._login()
try:
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(
method,
f'{config.url}/api{path}',
headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'},
**kwargs,
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
self._login()
hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {'status': 'connected', 'proxyHostCount': len(hosts)}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(proxyHostCount=0, certificateCount=0)
result = self.test_connection()
status = str(result.get('status', 'error'))
certs = self.certificates() if status == 'connected' else {'items': []}
return {
'success': status == 'connected',
'status': status,
'message': 'Nginx Proxy Manager connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'proxyHostCount': _as_int(result.get('proxyHostCount')),
'certificateCount': len(certs.get('items', [])) if isinstance(certs, dict) else 0,
'data': {},
}
def proxy_hosts(self) -> dict[str, Any]:
return self._memo('proxy_hosts', self._proxy_hosts_uncached)
def _proxy_hosts_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = [self._map_proxy_host(item) for item in _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))]
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': 'proxy_hosts_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def certificates(self) -> dict[str, Any]:
return self._memo('certificates', self._certificates_uncached)
def _certificates_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = [self._map_certificate(item) for item in _response_items(self._request('GET', '/nginx/certificates', params={'limit': 100}))]
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': 'certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def expiring_certificates(self, days: int = 30) -> dict[str, Any]:
certs = self.certificates()
items = [
item for item in certs.get('items', [])
if item.get('isExpired') or (item.get('expiresInDays') is not None and int(item.get('expiresInDays')) <= days)
]
return {**certs, 'message': 'expiring_certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items, 'days': days}}
def access_lists(self) -> dict[str, Any]:
return self._memo('access_lists', self._access_lists_uncached)
def _access_lists_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = [self._map_access_list(item) for item in _response_items(self._request('GET', '/nginx/access-lists', params={'limit': 100}))]
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': 'access_lists_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def validate_managed_access_list(self, access_list_id: int) -> dict[str, Any]:
"""Read-only verification for a trusted managed-host access list.
Expanded authorization entries are inspected in memory, then discarded;
the result contains only the trusted numeric identifier and status.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 1 <= access_list_id <= 2_147_483_647:
return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'}
try:
access_lists = _response_items(self._request(
'GET', '/nginx/access-lists',
params={'limit': 1000, 'expand': 'items,clients'},
))
except RuntimeError as exc:
return self._error_payload(exc)
matches = [item for item in access_lists if _as_int(item.get('id')) == access_list_id]
if (
len(matches) != 1
or matches[0].get('satisfy_any', matches[0].get('satisfyAny')) is not False
or not isinstance(matches[0].get('items'), list)
or not matches[0]['items']
):
return {'success': False, 'status': 'blocked', 'message': 'required_access_list_unavailable'}
return {
'success': True,
'status': 'verified',
'message': 'required_access_list_verified',
'accessListId': access_list_id,
}
def exposure_summary(self) -> dict[str, Any]:
hosts = self.proxy_hosts()
certs = self.certificates()
access_lists = self.access_lists()
if not hosts.get('success'):
return {**hosts, 'data': {'warnings': []}}
host_items = hosts.get('items', [])
cert_items = certs.get('items', []) if certs.get('success') else []
cert_by_id = {item.get('id'): item for item in cert_items}
warnings = self._build_warnings(host_items, cert_by_id)
summary = {
'totalProxyHosts': len(host_items),
'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')),
'publicHosts': sum(1 for item in host_items if item.get('enabled')),
'hostsWithoutCertificate': sum(1 for item in host_items if item.get('enabled') and not item.get('certificateId')),
'hostsWithExpiredCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpired')),
'hostsWithExpiringCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpiringSoon')),
'hostsWithoutAccessList': sum(1 for item in host_items if item.get('enabled') and not item.get('accessListId')),
'hostsWithoutSslForce': sum(1 for item in host_items if item.get('enabled') and not item.get('sslForced')),
'hostsWithWebsocket': sum(1 for item in host_items if item.get('websocketSupport')),
'accessLists': access_lists.get('total', 0),
'warnings': warnings,
}
return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary}
def host_reachability(self) -> dict[str, Any]:
hosts = self.proxy_hosts()
if not hosts.get('success'):
return {**hosts, 'data': {'checks': []}}
checks = [
{
'hostId': item.get('id'),
'domains': item.get('domainNames', []),
'target': f"{item.get('forwardScheme')}://{item.get('forwardHost')}:{item.get('forwardPort')}",
'status': 'metadata_only',
'message': 'V1 does not perform active target scans by default.',
}
for item in hosts.get('items', [])
]
return {'success': True, 'status': 'metadata_only', 'message': 'host_reachability_metadata_only', 'checks': checks, 'data': {'checks': checks}}
def security_warnings(self) -> dict[str, Any]:
hosts = self.proxy_hosts()
certs = self.certificates()
if not hosts.get('success'):
return {**hosts, 'items': [], 'total': 0, 'data': {'items': []}}
cert_by_id = {item.get('id'): item for item in certs.get('items', [])} if certs.get('success') else {}
items = self._build_warnings(hosts.get('items', []), cert_by_id)
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
status = 'warning' if items else 'ok'
return {
'success': True,
'status': status,
'message': 'security_warnings_detected' if items else 'security_warnings_ok',
'total': len(items),
'items': sorted(items, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True),
'data': {'items': items},
}
def ensure_managed_proxy_host(
self,
*,
hostname: str,
forward_host: str,
forward_port: int,
certificate_mode: str,
force_ssl: bool,
http2: bool,
upload_limit_mb: int,
read_timeout_seconds: int,
ownership_id: str | None = None,
access_list_id: int = 0,
) -> dict[str, Any]:
"""Create or verify one deployment-owned proxy host.
This is intentionally not a general NPM mutation method. Every value
is derived from DeploymentPlanV1 and this refuses to take over a host
that does not already point at the exact managed container.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?', hostname):
return {'success': False, 'status': 'invalid', 'message': 'invalid_hostname'}
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}', forward_host):
return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_host'}
if not 1 <= int(forward_port) <= 65535:
return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_port'}
if certificate_mode not in {'existing', 'managed'}:
return {'success': False, 'status': 'invalid', 'message': 'invalid_certificate_mode'}
if not force_ssl:
return {'success': False, 'status': 'invalid', 'message': 'managed_proxy_requires_force_ssl'}
if ownership_id is not None and not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id):
return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'}
if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 0 <= access_list_id <= 2_147_483_647:
return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'}
try:
if access_list_id:
access_list = self.validate_managed_access_list(access_list_id)
if not access_list.get('success'):
return access_list
hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}))
exact = [
item for item in hosts
if hostname in {str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames'))}
]
if len(exact) > 1:
return {'success': False, 'status': 'conflict', 'message': 'multiple_hostname_owners'}
if exact:
existing = exact[0]
same_target = (
str(existing.get('forward_host') or existing.get('forwardHost') or '') == forward_host
and _as_int(existing.get('forward_port') or existing.get('forwardPort')) == int(forward_port)
)
if not same_target:
return {'success': False, 'status': 'conflict', 'message': 'hostname_owned_by_different_target'}
metadata = existing.get('meta') if isinstance(existing.get('meta'), dict) else {}
if ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id:
return {'success': False, 'status': 'conflict', 'message': 'hostname_is_not_owned_by_this_enrollment'}
hardened = (
bool(existing.get('ssl_forced') or existing.get('sslForced'))
and bool(existing.get('http2_support') or existing.get('http2Support'))
and bool(existing.get('block_exploits') or existing.get('blockExploits'))
and not bool(existing.get('caching_enabled') or existing.get('cachingEnabled'))
and not bool(existing.get('allow_websocket_upgrade') or existing.get('websocketSupport'))
and bool(existing.get('certificate_id') or existing.get('certificateId'))
and _as_int(existing.get('access_list_id') or existing.get('accessListId')) == access_list_id
)
if not hardened:
return {'success': False, 'status': 'conflict', 'message': 'existing_proxy_host_does_not_meet_managed_security_contract'}
return {
'success': True,
'status': 'already_managed',
'message': 'proxy_host_already_matches_typed_plan',
'hostId': existing.get('id'),
'hostname': hostname,
}
certificate_id: Any = 0
metadata: dict[str, Any] = {}
if certificate_mode == 'existing':
certificates = _response_items(self._request('GET', '/nginx/certificates', params={'limit': 1000}))
matching_certificate = next(
(
item for item in certificates
if hostname in {
str(value).strip().lower()
for value in _as_list(item.get('domain_names') or item.get('domainNames'))
}
),
None,
)
if not matching_certificate:
return {'success': False, 'status': 'blocked', 'message': 'existing_certificate_not_found'}
certificate_id = matching_certificate.get('id')
else:
email = str(settings.apis.get('NPM_LETSENCRYPT_EMAIL') or '').strip()
if not email or '@' not in email or len(email) > 254:
return {'success': False, 'status': 'blocked', 'message': 'certificate_automation_not_configured'}
certificate_id = 'new'
metadata = {'letsencrypt_email': email, 'letsencrypt_agree': True}
if ownership_id is not None:
metadata = {**metadata, 'appops_enrollment_id': ownership_id}
advanced = (
f'client_max_body_size {int(upload_limit_mb)}m;\n'
f'proxy_read_timeout {int(read_timeout_seconds)}s;\n'
f'proxy_send_timeout {int(read_timeout_seconds)}s;'
)
created = self._request(
'POST',
'/nginx/proxy-hosts',
json={
'domain_names': [hostname],
'forward_scheme': 'http',
'forward_host': forward_host,
'forward_port': int(forward_port),
'access_list_id': access_list_id,
'certificate_id': certificate_id,
'ssl_forced': bool(force_ssl),
'http2_support': bool(http2),
'hsts_enabled': False,
'hsts_subdomains': False,
'caching_enabled': False,
'block_exploits': True,
'allow_websocket_upgrade': False,
'meta': metadata,
'advanced_config': advanced,
},
)
except RuntimeError as exc:
return self._error_payload(exc)
host_id = created.get('id') if isinstance(created, dict) else None
if ownership_id is not None or access_list_id:
if host_id is None:
return {
'success': False,
'status': 'ownership_unverified',
'message': 'managed_proxy_creation_response_missing_identifier',
}
try:
after_create = _response_items(
self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})
)
except RuntimeError as exc:
return {
'success': False,
'status': 'ownership_unverified',
'message': redact_message(str(exc)),
'hostId': host_id,
}
verified = [item for item in after_create if str(item.get('id')) == str(host_id)]
metadata = verified[0].get('meta') if len(verified) == 1 and isinstance(verified[0].get('meta'), dict) else {}
same_target = bool(
len(verified) == 1
and str(verified[0].get('forward_host') or verified[0].get('forwardHost') or '') == forward_host
and _as_int(verified[0].get('forward_port') or verified[0].get('forwardPort')) == int(forward_port)
and _as_int(verified[0].get('access_list_id') or verified[0].get('accessListId')) == access_list_id
)
if not same_target or (ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id):
return {
'success': False,
'status': 'ownership_unverified',
'message': 'managed_proxy_creation_ownership_not_verified',
'hostId': host_id,
}
result = {
'success': True,
'status': 'created',
'message': 'proxy_host_created_from_typed_plan',
'hostId': host_id,
'hostname': hostname,
}
if ownership_id is not None:
result['enrollmentId'] = ownership_id
return result
def remove_managed_proxy_host(self, *, host_id: int | str, ownership_id: str) -> dict[str, Any]:
"""Compensate only a proxy host created by this exact enrollment."""
if not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id):
return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'}
if not self.is_configured():
return self._missing(reason='missing_config')
try:
hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}))
matching = [item for item in hosts if str(item.get('id')) == str(host_id)]
if len(matching) != 1:
return {'success': False, 'status': 'not_found', 'message': 'managed_proxy_host_not_found'}
metadata = matching[0].get('meta') if isinstance(matching[0].get('meta'), dict) else {}
if metadata.get('appops_enrollment_id') != ownership_id:
return {'success': False, 'status': 'conflict', 'message': 'proxy_host_is_not_owned_by_this_enrollment'}
self._request('DELETE', f'/nginx/proxy-hosts/{host_id}')
except RuntimeError as exc:
return self._error_payload(exc)
return {'success': True, 'status': 'removed', 'hostId': host_id, 'enrollmentId': ownership_id}
@staticmethod
def _map_proxy_host(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': raw.get('id'),
'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))],
'forwardHost': str(raw.get('forward_host') or raw.get('forwardHost') or ''),
'forwardPort': _as_int(raw.get('forward_port') or raw.get('forwardPort')),
'forwardScheme': str(raw.get('forward_scheme') or raw.get('forwardScheme') or 'http'),
'enabled': bool(raw.get('enabled')),
'sslForced': bool(raw.get('ssl_forced') or raw.get('sslForced')),
'http2Support': bool(raw.get('http2_support') or raw.get('http2Support')),
'hstsEnabled': bool(raw.get('hsts_enabled') or raw.get('hstsEnabled')),
'certificateId': raw.get('certificate_id') or raw.get('certificateId') or None,
'accessListId': raw.get('access_list_id') or raw.get('accessListId') or None,
'blockExploits': bool(raw.get('block_exploits') or raw.get('blockExploits')),
'cachingEnabled': bool(raw.get('caching_enabled') or raw.get('cachingEnabled')),
'websocketSupport': bool(raw.get('allow_websocket_upgrade') or raw.get('websocket_support') or raw.get('websocketSupport')),
'locationsCount': len(_as_list(raw.get('locations'))),
'meta': raw.get('meta') if isinstance(raw.get('meta'), dict) else {},
}
@staticmethod
def _map_certificate(raw: dict[str, Any]) -> dict[str, Any]:
expires = raw.get('expires_on') or raw.get('expiresOn') or raw.get('expires')
days = _days_until(expires)
return {
'id': raw.get('id'),
'niceName': str(raw.get('nice_name') or raw.get('niceName') or raw.get('name') or ''),
'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))],
'provider': str(raw.get('provider') or raw.get('type') or 'unknown').lower(),
'expiresOn': expires,
'expiresInDays': days,
'isExpired': days is not None and days < 0,
'isExpiringSoon': days is not None and days <= 30,
}
@staticmethod
def _map_access_list(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': raw.get('id'),
'name': str(raw.get('name') or ''),
'authorizationType': str(raw.get('auth_type') or raw.get('authorizationType') or raw.get('authorization_type') or 'unknown'),
'satisfyAny': bool(raw.get('satisfy_any') or raw.get('satisfyAny')),
'itemsCount': len(_as_list(raw.get('items'))),
'clientsCount': len(_as_list(raw.get('clients'))),
}
@staticmethod
def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]]) -> list[dict[str, Any]]:
warnings: list[dict[str, Any]] = []
for host in hosts:
if not host.get('enabled'):
continue
domains = host.get('domainNames') or []
label = ', '.join(domains) or f"host-{host.get('id')}"
cert = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None
target = f"{host.get('forwardScheme')}://{host.get('forwardHost')}:{host.get('forwardPort')}"
if not host.get('certificateId'):
warnings.append({'type': 'host_without_certificate', 'severity': 'high', 'title': f'{label} has no certificate', 'evidence': [label, target], 'suggestedAction': 'review_certificate'})
elif cert and cert.get('isExpired'):
warnings.append({'type': 'certificate_expired', 'severity': 'critical', 'title': f'{label} certificate is expired', 'evidence': [label, str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'})
elif cert and cert.get('isExpiringSoon'):
warnings.append({'type': 'certificate_expiring_soon', 'severity': 'warning', 'title': f'{label} certificate expires soon', 'evidence': [label, f"{cert.get('expiresInDays')} days", str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'})
if not host.get('accessListId'):
warnings.append({'type': 'public_host_without_access_list', 'severity': 'high', 'title': f'{label} has no access list', 'evidence': [label, target], 'suggestedAction': 'review_access_protection'})
if not host.get('sslForced'):
warnings.append({'type': 'ssl_not_forced', 'severity': 'warning', 'title': f'{label} does not force SSL', 'evidence': [label], 'suggestedAction': 'review_ssl_policy'})
if not host.get('hstsEnabled'):
warnings.append({'type': 'hsts_disabled', 'severity': 'info', 'title': f'{label} has HSTS disabled', 'evidence': [label], 'suggestedAction': 'review_hsts_policy'})
if str(host.get('forwardHost') or '').lower() in {'unknown-app', 'unknown', ''}:
warnings.append({'type': 'unknown_proxy_target', 'severity': 'warning', 'title': f'{label} forwards to an unknown target', 'evidence': [target], 'suggestedAction': 'map_proxy_host_target'})
return warnings
@staticmethod
def _missing(**extra: Any) -> dict[str, Any]:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], 'data': {}, **extra}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}
+329
View File
@@ -0,0 +1,329 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _PlexConfig:
url: str
token: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _safe_path(value: Any) -> str:
text = str(value or '').replace('\\', '/').rstrip('/')
parts = [part for part in text.split('/') if part]
return f'.../{parts[-1]}' if parts else ''
def _mask_ip(value: Any) -> str:
text = str(value or '')
if not text:
return ''
parts = text.split('.')
if len(parts) == 4:
return f'{parts[0]}.{parts[1]}.{parts[2]}.x'
return '[masked]'
def _safe_url() -> str:
value = settings.apis.get('PLEX_URL')
return str(value).rstrip('/') if value else ''
class PlexConnector(BaseConnector):
name = 'Plex'
def is_configured(self) -> bool:
return bool(settings.apis.get('PLEX_URL') and settings.apis.get('PLEX_TOKEN'))
def _config(self) -> _PlexConfig | None:
if not self.is_configured():
return None
return _PlexConfig(
url=str(settings.apis['PLEX_URL']).rstrip('/'),
token=str(settings.apis['PLEX_TOKEN']),
verify_tls=_as_bool(settings.apis.get('PLEX_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('PLEX_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
request_params = dict(params or {})
request_params['X-Plex-Token'] = config.token
url = f'{config.url}{path if path.startswith("/") else f"/{path}"}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(url, headers={'Accept': 'application/json'}, params=request_params)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Plex API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError:
return self._xml_to_container(response.text)
def _xml_to_container(self, text: str) -> dict[str, Any]:
try:
root = ElementTree.fromstring(text)
except ElementTree.ParseError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
container: dict[str, Any] = {'MediaContainer': dict(root.attrib)}
children = []
for child in root:
item = dict(child.attrib)
item['tag'] = child.tag
children.append(item)
if children:
container['MediaContainer']['Directory' if children[0].get('tag') == 'Directory' else 'Metadata'] = children
return container
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
container = self._media_container(self._request('/'))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {
'status': 'connected',
'friendlyName': str(container.get('friendlyName') or ''),
'version': str(container.get('version') or 'unknown'),
'machineIdentifier': str(container.get('machineIdentifier') or ''),
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'url': '',
'version': 'unknown',
'machineIdentifier': '',
'friendlyName': '',
'required_env_vars': ['PLEX_URL', 'PLEX_TOKEN'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Plex connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'version': str(result.get('version', 'unknown')),
'machineIdentifier': str(result.get('machineIdentifier', '')),
'friendlyName': str(result.get('friendlyName', '')),
}
def server_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
container = self._media_container(self._request('/'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'ok',
'friendlyName': str(container.get('friendlyName') or ''),
'version': str(container.get('version') or 'unknown'),
'platform': str(container.get('platform') or ''),
'updatedAt': container.get('updatedAt'),
'myPlex': container.get('myPlex'),
'machineIdentifier': str(container.get('machineIdentifier') or ''),
'libraryCount': _as_int(container.get('size')),
}
def library_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/library/sections'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Directory') if isinstance(container.get('Directory'), list) else []
items = [self._map_library(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items}
def active_sessions(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/status/sessions'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else []
items = [self._map_session(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'sessions_ok', 'total': len(items), 'items': items, 'sessions': items}
def recent_media(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/library/recentlyAdded', {'X-Plex-Container-Size': 25}))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else []
items = [self._map_recent(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'recent_media_ok', 'total': len(items), 'items': items}
def stream_summary(self) -> dict[str, Any]:
sessions = self.active_sessions()
items = sessions.get('items', []) if isinstance(sessions, dict) else []
return {
'success': bool(sessions.get('success')),
'status': sessions.get('status', 'unreachable'),
'activeStreams': len(items),
'total': len(items),
'directPlay': sum(1 for item in items if item.get('transcodeDecision') == 'directplay'),
'directStream': sum(1 for item in items if item.get('transcodeDecision') == 'directstream'),
'transcoding': sum(1 for item in items if item.get('transcodeDecision') == 'transcode'),
'bandwidth': sum(_as_int(item.get('bandwidth')) for item in items),
'users': len({item.get('user') for item in items if item.get('user')}),
}
def scan_library(self, library_id: str) -> dict[str, Any]:
clean_id = library_id.strip()
if not clean_id:
return {'success': False, 'status': 'error', 'message': 'library_id_required'}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'libraryId': clean_id}}
try:
self._request(f'/library/sections/{clean_id}/refresh')
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'data': {'libraryId': clean_id}}
return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': {'libraryId': clean_id}}
def scan_all_libraries(self) -> dict[str, Any]:
libraries = self.library_status()
if not libraries.get('success'):
return {'success': False, 'status': libraries.get('status', 'error'), 'message': libraries.get('message', 'library_status_failed')}
accepted: list[str] = []
for item in libraries.get('items', []):
library_id = str(item.get('id') or '')
if library_id and self.scan_library(library_id).get('success'):
accepted.append(library_id)
return {'success': True, 'status': 'ok', 'message': 'scan_all_started', 'data': {'libraryIds': accepted}}
def _map_library(self, raw: dict[str, Any]) -> dict[str, Any]:
library_id = str(raw.get('key') or raw.get('id') or '')
count = 0
if library_id:
try:
count = _as_int(self._media_container(self._request(f'/library/sections/{library_id}/all')).get('size'))
except RuntimeError:
count = 0
locations_raw = raw.get('Location') if isinstance(raw.get('Location'), list) else []
return {
'id': library_id,
'key': library_id,
'title': str(raw.get('title') or ''),
'type': str(raw.get('type') or ''),
'agent': str(raw.get('agent') or ''),
'scanner': str(raw.get('scanner') or ''),
'count': count,
'itemCount': count,
'refreshStatus': raw.get('refreshing'),
'locations': [_safe_path(item.get('path')) for item in locations_raw if isinstance(item, dict)],
}
@staticmethod
def _map_session(raw: dict[str, Any]) -> dict[str, Any]:
user = raw.get('User') if isinstance(raw.get('User'), dict) else {}
player = raw.get('Player') if isinstance(raw.get('Player'), dict) else {}
session = raw.get('Session') if isinstance(raw.get('Session'), dict) else {}
transcode = raw.get('TranscodeSession') if isinstance(raw.get('TranscodeSession'), dict) else {}
media = raw.get('Media')[0] if isinstance(raw.get('Media'), list) and raw.get('Media') else {}
decision = str(transcode.get('videoDecision') or media.get('videoDecision') or (media.get('Part', [{}])[0].get('decision') if isinstance(media.get('Part'), list) and media.get('Part') else '') or 'directplay').lower()
duration = _as_int(raw.get('duration'))
offset = _as_int(raw.get('viewOffset'))
return {
'sessionKey': str(raw.get('sessionKey') or ''),
'id': str(raw.get('sessionKey') or ''),
'user': str(user.get('title') or 'unknown'),
'title': str(raw.get('grandparentTitle') or raw.get('title') or 'unknown'),
'libraryType': str(raw.get('type') or ''),
'type': str(raw.get('type') or ''),
'player': str(player.get('title') or ''),
'state': str(player.get('state') or ''),
'product': str(player.get('product') or ''),
'platform': str(player.get('platform') or ''),
'ip': _mask_ip(player.get('address')),
'ipAddress': _mask_ip(player.get('address')),
'transcodeDecision': decision,
'videoDecision': str(transcode.get('videoDecision') or media.get('videoDecision') or decision),
'audioDecision': str(transcode.get('audioDecision') or media.get('audioDecision') or ''),
'bandwidth': _as_int(session.get('bandwidth')),
'progress': round((offset / duration) * 100, 1) if duration else 0,
}
@staticmethod
def _map_recent(raw: dict[str, Any]) -> dict[str, Any]:
title = str(raw.get('title') or 'unknown')
if raw.get('grandparentTitle'):
title = f"{raw.get('grandparentTitle')} - {title}"
return {
'id': str(raw.get('ratingKey') or raw.get('key') or raw.get('title') or ''),
'title': title,
'type': str(raw.get('type') or ''),
'year': raw.get('year'),
'library': str(raw.get('librarySectionTitle') or ''),
'addedAt': raw.get('addedAt'),
'thumb': raw.get('thumb'),
'summary': raw.get('summary'),
}
@staticmethod
def _media_container(payload: dict[str, Any]) -> dict[str, Any]:
container = payload.get('MediaContainer') if isinstance(payload, dict) else {}
return container if isinstance(container, dict) else {}
+492
View File
@@ -0,0 +1,492 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_PORTAINER_DEFAULT_TIMEOUT_SECONDS = 8.0
@dataclass
class _ConnectorConfig:
url: str
api_key: str
endpoint_id: str
verify_tls: bool
class PortainerConnector(BaseConnector):
name = 'Portainer'
default_headers = {
'Content-Type': 'application/json',
}
def __init__(self):
super().__init__()
def is_configured(self):
return bool(settings.apis.get('PORTAINER_URL') and settings.apis.get('PORTAINER_API_KEY') and settings.apis.get('PORTAINER_ENDPOINT_ID'))
def _config(self) -> _ConnectorConfig | None:
if not self.is_configured():
return None
return _ConnectorConfig(
url=str(settings.apis['PORTAINER_URL']).strip().rstrip('/'),
api_key=str(settings.apis['PORTAINER_API_KEY']).strip(),
endpoint_id=str(settings.apis['PORTAINER_ENDPOINT_ID']).strip(),
verify_tls=bool(settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls)),
)
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(self.default_headers)
headers['X-API-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = str(exc)
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
if response.status_code == 401:
message = 'unauthorized'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 500:
message = f'Portainer server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
response_text = response.text or ''
message = f'Portainer API error: HTTP {response.status_code} {response_text[:256]}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
return response
def test_connection(self):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
self._request('GET', '/api/status')
self._request('GET', f'/api/endpoints/{self._config().endpoint_id}/docker/version')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
if 'endpoints/' in reason or 'HTTP 404' in reason or 'HTTP 403' in reason:
return {'status': 'endpoint_error', 'reason': reason}
return {'status': 'error', 'reason': reason}
return {'status': 'connected', 'endpointReachable': True}
def list_containers(self, include_stats: bool = False, stats_limit: int = 200):
config = self._config()
if not config:
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
path = f'/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1'
response = self._request('GET', path)
payload = response.json()
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'containers': []}
if not isinstance(payload, list):
return {'status': 'error', 'reason': 'unexpected_payload', 'containers': []}
containers = [self._map_container(container, config) for container in payload]
if include_stats:
self._attach_container_stats(containers, max(1, min(int(stats_limit or 200), 500)))
return {'status': 'ok', 'containers': containers}
def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None:
candidates = [
container
for container in containers
if container.get('state') == 'running' and container.get('id') and container.get('id') != 'unknown'
]
selected = candidates[:stats_limit]
selected_ids = {str(container.get('id')) for container in selected}
for container in containers:
if container.get('state') != 'running':
container['metricsStatus'] = 'not_running'
elif str(container.get('id')) not in selected_ids:
container['metricsStatus'] = 'not_requested'
if not selected:
return
max_workers = min(12, len(selected))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(self.get_container_stats, str(container.get('id'))): container
for container in selected
}
for future in as_completed(futures):
container = futures[future]
try:
stats = future.result()
except Exception as exc: # pragma: no cover - get_container_stats normalizes expected failures.
stats = {'metricsStatus': 'error', 'metricsError': redact_message(str(exc))}
container.update(stats)
def get_container_stats(self, container_id: str) -> dict[str, Any]:
if not self.is_configured():
return {'metricsStatus': 'missing_config', 'metricsError': 'missing_config'}
try:
safe_container_id = quote(str(container_id).strip(), safe='')
response = self._request(
'GET',
f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{safe_container_id}/stats',
params={'stream': 'false'},
)
payload = response.json()
except (RuntimeError, ValueError) as exc:
return {
'metricsStatus': 'error',
'metricsError': redact_message(str(exc)),
}
metrics = _map_container_stats(payload)
metrics['metricsSource'] = 'portainer.docker.stats'
return metrics
def get_container_status(self, container_name: str):
containers = self.list_containers()
if containers.get('status') != 'ok':
return containers
entry = next((c for c in containers.get('containers', []) if c['name'] == container_name), None)
if entry:
return {'status': 'ok', 'container': entry}
return {'status': 'not_found', 'reason': f'Container {container_name} not found'}
def restart_container(self, target: str):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'container': target}
containers = self.list_containers()
if containers.get('status') != 'ok':
return containers
target_normalized = str(target).strip().lower()
selected = None
for candidate in containers.get('containers', []):
name = str(candidate.get('name', '')).lower()
container_id = str(candidate.get('id', '')).lower()
app_id = str(candidate.get('appId', '')).lower()
if (
name == target_normalized
or container_id == target_normalized
or app_id == target_normalized
or (target_normalized and (target_normalized in name or target_normalized in container_id))
):
selected = candidate
break
if not selected:
return {'status': 'not_found', 'reason': f'Container {target} not found'}
container_name = selected.get('id') or selected.get('name')
if not container_name:
return {'status': 'error', 'reason': 'Unable to resolve container identifier'}
endpoint_id = self._config().endpoint_id
if not endpoint_id:
return {'status': 'error', 'reason': 'invalid_connector_config'}
try:
self._request(
'POST',
f'/api/endpoints/{endpoint_id}/docker/containers/{container_name}/restart',
timeout=30.0,
)
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc)}
return {
'status': 'ok',
'container': str(container_name),
'target': target,
'action': 'container.restart',
}
def get_container_logs(self, container_name: str, tail: int = 100):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'container': container_name, 'logs': []}
try:
response = self._request(
'GET',
f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{container_name}/logs',
params={'stdout': 1, 'stderr': 1, 'tail': max(1, int(tail))},
)
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'container': container_name, 'logs': []}
output = (response.text or '').strip()
logs = [line for line in output.splitlines() if line.strip()]
return {'status': 'ok', 'container': container_name, 'logs': logs}
def list_stacks(self):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'stacks': []}
try:
payload = self._request('GET', '/api/stacks').json()
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'stacks': []}
if isinstance(payload, dict) and isinstance(payload.get('stacks'), list):
payload = payload.get('stacks', [])
if not isinstance(payload, list):
return {'status': 'error', 'reason': 'unexpected_payload', 'stacks': []}
return {'status': 'ok', 'stacks': [self._map_stack(stack) for stack in payload]}
def get_stack_status(self, stack_id: str):
stacks = self.list_stacks()
if stacks.get('status') != 'ok':
return stacks
entry = next((stack for stack in stacks.get('stacks', []) if stack['id'] == stack_id), None)
if entry:
return {'status': 'ok', 'stack': entry}
return {'status': 'not_found', 'reason': f'Stack {stack_id} not found'}
@staticmethod
def _map_container(raw: dict[str, Any], config: _ConnectorConfig | None = None) -> dict[str, Any]:
names = raw.get('Names') or []
raw_name = names[0] if names else raw.get('Name', '')
name = str(raw_name).strip('/').strip()
image = str(raw.get('Image', '')).strip()
state = str(raw.get('State', 'unknown')).lower()
state_map = {
'running': 'running',
'exited': 'exited',
'restarting': 'restarting',
'paused': 'paused',
}
mapped_state = state_map.get(state, 'exited')
health_raw = raw.get('State')
health = 'healthy'
if health_raw in {'exited', 'stopped', 'dead'}:
health = 'unhealthy'
ports_raw = raw.get('Ports', []) if isinstance(raw.get('Ports'), list) else []
ports = []
for port in ports_raw:
if not isinstance(port, dict):
continue
public_port = port.get('PublicPort')
private_port = port.get('PrivatePort')
if public_port is None or private_port is None:
continue
ports.append(f"{public_port}:{private_port}")
if not ports:
ports = []
container_id = str(raw.get('Id', name or 'unknown'))
mounts = []
for mount in raw.get('Mounts', []) if isinstance(raw.get('Mounts'), list) else []:
if not isinstance(mount, dict):
continue
source = str(mount.get('Source') or mount.get('Name') or '').strip()
destination = str(mount.get('Destination') or '').strip()
if not source and not destination:
continue
mounts.append({
'source': source,
'destination': destination,
'type': str(mount.get('Type') or '').strip(),
})
return {
'id': container_id,
'name': name or str(raw.get('Id', 'unknown')),
'appId': _infer_app_id(name),
'state': mapped_state,
'health': health,
'image': image or 'unknown',
'ports': ports,
'mounts': mounts,
'cpu': 'n/a',
'ram': 'n/a',
'metricsStatus': 'not_requested',
'restartCount': int(raw.get('RestartCount', 0) or 0),
'criticality': 'medium',
'voiceEnabled': name in {'widefrog', 'sonarr', 'jellyfin'},
'protected': _is_protected_app(name),
'portainerUrl': _container_deep_link(config, container_id),
}
@staticmethod
def _map_stack(raw: dict[str, Any]) -> dict[str, Any]:
stack_id = raw.get('Id') or raw.get('id') or raw.get('name')
status = str(raw.get('Status', 'unknown')).lower()
status_map = {
'stopped': 'critical',
'active': 'healthy',
'running': 'healthy',
'error': 'error',
'restarting': 'warning',
}
return {
'id': str(stack_id),
'name': str(raw.get('Name', stack_id or 'stack')),
'apps': [raw.get('EntryPoint')] if raw.get('EntryPoint') else [],
'containers': int(raw.get('ContainersCount', 1) or 0),
'status': status_map.get(status, 'healthy'),
'lastRedeploy': str(raw.get('UpdatedAt') or raw.get('created', 'unknown')),
'updatePolicy': str(raw.get('UpdateStrategy', 'Manual')),
'risk': 'medium',
}
def _infer_app_id(name: str) -> str:
normalized = str(name).lower()
if not normalized:
return ''
known = {
'widefrog': 'widefrog',
'sonarr': 'sonarr',
'radarr': 'radarr',
'jellyfin': 'jellyfin',
'authentik-server': 'authentik',
'authentik': 'authentik',
'postgres': 'postgres',
'redis': 'redis',
'portainer': 'portainer',
'home-assistant': 'home_assistant',
}
return known.get(normalized.split('/')[-1], normalized.split('/')[0].strip() if normalized else '')
def _is_protected_app(name: str) -> bool:
return _infer_app_id(name) in {'authentik', 'portainer', 'postgres', 'redis', 'home_assistant'}
def _nested_number(payload: dict[str, Any], path: tuple[str, ...]) -> float | None:
current: Any = payload
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
try:
return float(current)
except (TypeError, ValueError):
return None
def _format_percent(value: float | None) -> str:
if value is None:
return 'n/a'
return f'{max(value, 0.0):.1f}%'
def _format_bytes(value: float | None) -> str:
if value is None or value < 0:
return 'n/a'
units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
amount = float(value)
unit = units[0]
for unit in units:
if amount < 1024 or unit == units[-1]:
break
amount /= 1024
if unit == 'B' or amount >= 10 or amount.is_integer():
return f'{amount:.0f} {unit}'
return f'{amount:.1f} {unit}'
def _map_container_stats(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {
'cpu': 'n/a',
'ram': 'n/a',
'metricsStatus': 'partial',
}
cpu_total = _nested_number(payload, ('cpu_stats', 'cpu_usage', 'total_usage'))
cpu_previous = _nested_number(payload, ('precpu_stats', 'cpu_usage', 'total_usage'))
system_total = _nested_number(payload, ('cpu_stats', 'system_cpu_usage'))
system_previous = _nested_number(payload, ('precpu_stats', 'system_cpu_usage'))
online_cpus = _nested_number(payload, ('cpu_stats', 'online_cpus'))
if not online_cpus:
cpu_stats = payload.get('cpu_stats') if isinstance(payload.get('cpu_stats'), dict) else {}
cpu_usage = cpu_stats.get('cpu_usage') if isinstance(cpu_stats.get('cpu_usage'), dict) else {}
per_cpu = cpu_usage.get('percpu_usage')
online_cpus = float(len(per_cpu)) if isinstance(per_cpu, list) and per_cpu else 1.0
cpu_percent: float | None = None
if cpu_total is not None and cpu_previous is not None and system_total is not None and system_previous is not None:
cpu_delta = cpu_total - cpu_previous
system_delta = system_total - system_previous
if cpu_delta >= 0 and system_delta > 0:
cpu_percent = (cpu_delta / system_delta) * float(online_cpus or 1.0) * 100.0
memory_usage = _nested_number(payload, ('memory_stats', 'usage'))
memory_limit = _nested_number(payload, ('memory_stats', 'limit'))
ram = _format_bytes(memory_usage)
if memory_usage is not None and memory_limit:
ram = f'{_format_bytes(memory_usage)} / {_format_bytes(memory_limit)}'
return {
'cpu': _format_percent(cpu_percent),
'ram': ram,
'metricsStatus': 'measured' if cpu_percent is not None and memory_usage is not None else 'partial',
}
def _safe_portainer_base_url(raw_url: str) -> str | None:
parsed = urlsplit(str(raw_url or '').strip())
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
return None
path = parsed.path.rstrip('/')
return urlunsplit((parsed.scheme, parsed.netloc, path, '', '')).rstrip('/')
def _container_deep_link(config: _ConnectorConfig | None, container_id: str) -> str | None:
if not config or not config.endpoint_id or not container_id or container_id == 'unknown':
return None
base_url = _safe_portainer_base_url(config.url)
if not base_url:
return None
endpoint_id = quote(str(config.endpoint_id).strip(), safe='')
safe_container_id = quote(str(container_id).strip(), safe='')
return f'{base_url}/#!/{endpoint_id}/docker/containers/{safe_container_id}'
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _ProwlarrConfig:
url: str
api_key: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
if value is None or value == '':
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return default
def _safe_url() -> str:
value = settings.apis.get('PROWLARR_URL')
return str(value).rstrip('/') if value else ''
class ProwlarrConnector(BaseConnector):
name = 'Prowlarr'
def is_configured(self) -> bool:
return bool(settings.apis.get('PROWLARR_URL') and settings.apis.get('PROWLARR_API_KEY'))
def _config(self) -> _ProwlarrConfig | None:
if not self.is_configured():
return None
return _ProwlarrConfig(
url=str(settings.apis['PROWLARR_URL']).rstrip('/'),
api_key=str(settings.apis['PROWLARR_API_KEY']),
verify_tls=_as_bool(settings.apis.get('PROWLARR_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('PROWLARR_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
headers['X-Api-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('unauthorized')
raise RuntimeError('unauthorized')
if response.status_code == 404:
self.set_error('unsupported_endpoint')
raise RuntimeError('unsupported_endpoint')
if response.status_code >= 500:
message = f'Prowlarr server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
message = redact_message(f'Prowlarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 204:
return None
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
payload = self._request('GET', '/api/v1/system/status')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable' if reason not in {'unauthorized'} else 'error', 'reason': redact_message(reason)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'version': 'unknown',
'url': '',
'required_env_vars': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Prowlarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'version': str(result.get('version', 'unknown')),
'url': _safe_url(),
}
def indexer_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_indexers('missing_config')
try:
payload = self._request('GET', '/api/v1/indexer')
except RuntimeError as exc:
result = self._empty_indexers('unavailable' if str(exc) == 'unsupported_endpoint' else 'error')
result['message'] = redact_message(str(exc))
return result
items_raw = payload if isinstance(payload, list) else []
items = [self._map_indexer(item) for item in items_raw if isinstance(item, dict)]
return {
'success': True,
'status': 'ok',
'message': 'indexers_ok',
'total': len(items),
'enabled': sum(1 for item in items if item['enabled']),
'disabled': sum(1 for item in items if not item['enabled']),
'failing': sum(1 for item in items if item['status'] in {'warning', 'error'}),
'protocols': {
'torrent': sum(1 for item in items if item['protocol'] == 'torrent'),
'usenet': sum(1 for item in items if item['protocol'] == 'usenet'),
'unknown': sum(1 for item in items if item['protocol'] == 'unknown'),
},
'items': items,
}
def application_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v1/applications')
except RuntimeError as exc:
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
items_raw = payload if isinstance(payload, list) else []
items = [self._map_application(item) for item in items_raw if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'applications_ok', 'total': len(items), 'items': items}
def indexer_health(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'warnings': [], 'errors': []}
try:
payload = self._request('GET', '/api/v1/health')
except RuntimeError as exc:
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'warnings': [], 'errors': []}
records = payload if isinstance(payload, list) else []
warnings = [str(item.get('message') or item.get('source') or 'warning') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() != 'error']
errors = [str(item.get('message') or item.get('source') or 'error') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() == 'error']
return {
'success': True,
'status': 'ok' if not warnings and not errors else 'warning',
'message': 'healthy' if not warnings and not errors else 'health_issues_detected',
'warnings': warnings,
'errors': errors,
}
def search_test(self, query: str) -> dict[str, Any]:
clean_query = query.strip()
if not clean_query:
return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v1/search', params={'query': clean_query})
except RuntimeError as exc:
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = payload if isinstance(payload, list) else []
items = [self._map_search_result(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'search_ok', 'total': len(items), 'items': items}
def sync_status(self) -> dict[str, Any]:
indexers = self.indexer_status()
applications = self.application_status()
health = self.indexer_health()
success = bool(indexers.get('success')) and bool(applications.get('success')) and bool(health.get('success'))
return {
'success': success,
'status': 'ok' if success else 'unavailable',
'message': 'sync_status_ok' if success else 'sync_status_unavailable',
'applicationsConfigured': int(applications.get('total', 0)),
'indexersConfigured': int(indexers.get('total', 0)),
'indexersEnabled': int(indexers.get('enabled', 0)),
'indexersFailing': int(indexers.get('failing', 0)),
'healthWarnings': len(health.get('warnings', [])),
'healthErrors': len(health.get('errors', [])),
'lastSync': None,
}
@staticmethod
def _map_indexer(raw: dict[str, Any]) -> dict[str, Any]:
protocol = str(raw.get('protocol') or raw.get('protocolName') or 'unknown').lower()
if protocol not in {'torrent', 'usenet'}:
protocol = 'unknown'
enabled = bool(raw.get('enable', raw.get('enabled', False)))
status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower()
if status in {'ok', 'available'}:
status = 'healthy'
if status not in {'healthy', 'warning', 'error', 'unknown'}:
status = 'unknown'
categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else []
categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item]
return {
'id': raw.get('id'),
'name': str(raw.get('name') or raw.get('definitionName') or 'unknown'),
'protocol': protocol,
'enabled': enabled,
'status': status,
'categories': categories,
'privacy': str(raw.get('privacy') or 'unknown').lower(),
}
@staticmethod
def _map_application(raw: dict[str, Any]) -> dict[str, Any]:
enabled = bool(raw.get('enable', raw.get('enabled', False)))
status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower()
if status not in {'healthy', 'warning', 'error', 'unknown'}:
status = 'unknown'
return {
'id': raw.get('id'),
'name': str(raw.get('name') or raw.get('implementation') or 'unknown'),
'implementation': str(raw.get('implementation') or 'unknown'),
'syncLevel': str(raw.get('syncLevel') or raw.get('sync_level') or 'unknown'),
'enabled': enabled,
'status': status,
}
@staticmethod
def _map_search_result(raw: dict[str, Any]) -> dict[str, Any]:
categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else []
categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item]
return {
'title': str(raw.get('title') or 'unknown'),
'indexer': str(raw.get('indexer') or raw.get('indexerName') or 'unknown'),
'protocol': str(raw.get('protocol') or 'unknown').lower(),
'size': raw.get('size'),
'age': raw.get('age'),
'seeders': _as_int(raw.get('seeders'), 0),
'leechers': _as_int(raw.get('leechers'), 0),
'category': categories[0] if categories else str(raw.get('category') or 'unknown'),
'downloadAllowed': False,
}
@staticmethod
def _empty_indexers(status: str) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': status,
'total': 0,
'enabled': 0,
'disabled': 0,
'failing': 0,
'protocols': {'torrent': 0, 'usenet': 0, 'unknown': 0},
'items': [],
}
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _RadarrConfig:
url: str
api_key: str
verify_tls: bool
default_root_folder: str | None
default_quality_profile: int | None
default_minimum_availability: str
search_on_add: bool
timeout_seconds: float
def _as_int(value: Any, default: int = 0) -> int:
if value is None or value == '':
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return default
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _safe_url() -> str:
value = settings.apis.get('RADARR_URL')
if not value:
return ''
return str(value).rstrip('/')
class RadarrConnector(BaseConnector):
name = 'Radarr'
def is_configured(self) -> bool:
return bool(settings.apis.get('RADARR_URL') and settings.apis.get('RADARR_API_KEY'))
def _config(self) -> _RadarrConfig | None:
if not self.is_configured():
return None
return _RadarrConfig(
url=str(settings.apis['RADARR_URL']).rstrip('/'),
api_key=str(settings.apis['RADARR_API_KEY']),
verify_tls=_as_bool(settings.apis.get('RADARR_VERIFY_TLS'), False),
default_root_folder=(
str(settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER'))
if settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER')
else None
),
default_quality_profile=(
_as_int(settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE'))
if settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE')
else None
),
default_minimum_availability=str(settings.apis.get('RADARR_DEFAULT_MINIMUM_AVAILABILITY') or 'released'),
search_on_add=_as_bool(settings.apis.get('RADARR_SEARCH_ON_ADD'), True),
timeout_seconds=_as_float(settings.apis.get('RADARR_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
headers['X-Api-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('unauthorized')
raise RuntimeError('unauthorized')
if response.status_code >= 500:
message = f'Radarr server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
message = redact_message(f'Radarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 204:
return None
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
payload = self._request('GET', '/api/v3/system/status')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': redact_message(reason)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'version': 'unknown',
'url': '',
'required_env_vars': ['RADARR_URL', 'RADARR_API_KEY'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Radarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'version': str(result.get('version', 'unknown')),
'url': _safe_url(),
}
def search_movie(self, query: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []}
try:
payload = self._request('GET', '/api/v3/movie/lookup', params={'term': query})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []}
if not isinstance(payload, list):
return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []}
return {
'success': True,
'status': 'connected',
'items': [self._map_movie(item) for item in payload if isinstance(item, dict)],
}
def queue_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_queue('missing_config')
try:
payload = self._request('GET', '/api/v3/queue')
except RuntimeError as exc:
result = self._empty_queue('unavailable')
result['message'] = redact_message(str(exc))
return result
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
items = [self._map_queue_item(item) for item in records if isinstance(item, dict)]
return {
'success': True,
'status': 'ok',
'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items),
'downloading': sum(1 for item in items if item['status'] == 'downloading'),
'queued': sum(1 for item in items if item['status'] == 'queued'),
'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'),
'stalled': sum(1 for item in items if item['status'] == 'stalled'),
'items': items,
}
def wanted_missing(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v3/wanted/missing', params={'page': 1, 'pageSize': 20})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
return {
'success': True,
'status': 'connected',
'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records),
'items': records[:20],
}
def add_movie(self, payload: dict[str, Any]) -> dict[str, Any]:
config = self._config()
if not config:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
title = str(payload.get('title') or '').strip()
tmdb_id = _as_int(payload.get('tmdbId') or payload.get('tmdb_id'))
if not title or not tmdb_id:
return {'success': False, 'status': 'error', 'message': 'title_and_tmdbId_required'}
root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip()
quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0)
minimum_availability = str(
payload.get('minimumAvailability') or config.default_minimum_availability or 'released'
).strip()
if not root_folder or not quality_profile_id:
return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'}
search_on_add = _as_bool(payload.get('searchForMovie'), config.search_on_add)
request_payload = {
**payload,
'title': title,
'tmdbId': tmdb_id,
'rootFolderPath': root_folder,
'qualityProfileId': quality_profile_id,
'monitored': _as_bool(payload.get('monitored'), True),
'minimumAvailability': minimum_availability,
'searchForMovie': search_on_add,
'addOptions': {
'searchForMovie': search_on_add,
},
}
try:
result = self._request('POST', '/api/v3/movie', json=request_payload)
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'connected',
'message': 'movie_added',
'data': result if isinstance(result, dict) else {},
}
@staticmethod
def _map_movie(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': _as_int(raw.get('id')),
'tmdbId': _as_int(raw.get('tmdbId')),
'imdbId': str(raw.get('imdbId', '')),
'title': str(raw.get('title', '')),
'year': _as_int(raw.get('year')),
'overview': str(raw.get('overview', '')),
'status': str(raw.get('status', 'unknown')),
'remotePoster': str(raw.get('remotePoster', '')),
'existing': bool(raw.get('id') and raw.get('path')),
'monitored': _as_bool(raw.get('monitored'), False),
'minimumAvailability': str(raw.get('minimumAvailability', 'unknown')),
}
@staticmethod
def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]:
movie = raw.get('movie') if isinstance(raw.get('movie'), dict) else {}
return {
'id': raw.get('id'),
'title': str(raw.get('title') or movie.get('title') or 'unknown'),
'status': str(raw.get('status', 'unknown')).lower(),
'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(),
'size': raw.get('size'),
'sizeleft': raw.get('sizeleft'),
}
@staticmethod
def _empty_queue(status: str) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': status,
'total': 0,
'downloading': 0,
'queued': 0,
'failed': 0,
'stalled': 0,
'items': [],
}
+300
View File
@@ -0,0 +1,300 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_SONARR_DEFAULT_TIMEOUT_SECONDS = 8.0
@dataclass
class _SonarrConfig:
url: str
api_key: str
verify_tls: bool
default_root_folder: str | None
default_quality_profile: int | None
search_on_add: bool
def _as_int(value: Any, default: int = 0) -> int:
if value is None or value == '':
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return default
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _safe_url() -> str:
value = settings.apis.get('SONARR_URL')
if not value:
return ''
return str(value).rstrip('/')
class SonarrConnector(BaseConnector):
name = 'Sonarr'
def is_configured(self) -> bool:
return bool(settings.apis.get('SONARR_URL') and settings.apis.get('SONARR_API_KEY'))
def _config(self) -> _SonarrConfig | None:
if not self.is_configured():
return None
return _SonarrConfig(
url=str(settings.apis['SONARR_URL']).rstrip('/'),
api_key=str(settings.apis['SONARR_API_KEY']),
verify_tls=bool(settings.verify_tls),
default_root_folder=(
str(settings.apis.get('SONARR_DEFAULT_ROOT_FOLDER'))
if settings.apis.get('SONARR_DEFAULT_ROOT_FOLDER')
else None
),
default_quality_profile=(
_as_int(settings.apis.get('SONARR_DEFAULT_QUALITY_PROFILE'))
if settings.apis.get('SONARR_DEFAULT_QUALITY_PROFILE')
else None
),
search_on_add=_as_bool(settings.apis.get('SONARR_SEARCH_ON_ADD'), True),
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
headers['X-Api-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=_SONARR_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('unauthorized')
raise RuntimeError('unauthorized')
if response.status_code >= 500:
message = f'Sonarr server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
message = redact_message(f'Sonarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 204:
return None
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
payload = self._request('GET', '/api/v3/system/status')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'error', 'reason': redact_message(reason)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'version': 'unknown',
'url': '',
'required_env_vars': ['SONARR_URL', 'SONARR_API_KEY'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Sonarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'version': str(result.get('version', 'unknown')),
'url': _safe_url(),
}
def search_series(self, query: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []}
try:
payload = self._request('GET', '/api/v3/series/lookup', params={'term': query})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []}
if not isinstance(payload, list):
return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []}
return {
'success': True,
'status': 'connected',
'items': [self._map_series(item) for item in payload if isinstance(item, dict)],
}
def queue_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_queue('missing_config')
try:
payload = self._request('GET', '/api/v3/queue')
except RuntimeError as exc:
result = self._empty_queue('error')
result['message'] = redact_message(str(exc))
return result
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
items = [self._map_queue_item(item) for item in records if isinstance(item, dict)]
return {
'success': True,
'status': 'connected',
'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items),
'downloading': sum(1 for item in items if item['status'] == 'downloading'),
'queued': sum(1 for item in items if item['status'] == 'queued'),
'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'),
'stalled': sum(1 for item in items if item['status'] == 'stalled'),
'items': items,
}
def wanted_missing(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v3/wanted/missing', params={'page': 1, 'pageSize': 20})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
return {
'success': True,
'status': 'connected',
'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records),
'items': records[:20],
}
def add_series(self, payload: dict[str, Any]) -> dict[str, Any]:
config = self._config()
if not config:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
title = str(payload.get('title') or '').strip()
tvdb_id = _as_int(payload.get('tvdbId') or payload.get('tvdb_id'))
if not title or not tvdb_id:
return {'success': False, 'status': 'error', 'message': 'title_and_tvdbId_required'}
root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip()
quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0)
if not root_folder or not quality_profile_id:
return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'}
request_payload = {
**payload,
'title': title,
'tvdbId': tvdb_id,
'rootFolderPath': root_folder,
'qualityProfileId': quality_profile_id,
'monitored': _as_bool(payload.get('monitored'), True),
'seasonFolder': _as_bool(payload.get('seasonFolder'), True),
'searchForMissingEpisodes': _as_bool(payload.get('searchForMissingEpisodes'), config.search_on_add),
'addOptions': {
'searchForMissingEpisodes': _as_bool(payload.get('searchForMissingEpisodes'), config.search_on_add),
},
}
try:
result = self._request('POST', '/api/v3/series', json=request_payload)
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'connected',
'message': 'series_added',
'data': result if isinstance(result, dict) else {},
}
@staticmethod
def _map_series(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': _as_int(raw.get('id')),
'tvdbId': _as_int(raw.get('tvdbId')),
'title': str(raw.get('title', '')),
'year': _as_int(raw.get('year')),
'overview': str(raw.get('overview', '')),
'status': str(raw.get('status', 'unknown')),
'remotePoster': str(raw.get('remotePoster', '')),
'existing': bool(raw.get('id') and raw.get('path')),
'monitored': _as_bool(raw.get('monitored'), False),
'seasons': raw.get('seasons') if isinstance(raw.get('seasons'), list) else [],
}
@staticmethod
def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': raw.get('id'),
'title': str(raw.get('title', raw.get('series', {}).get('title', 'unknown'))),
'status': str(raw.get('status', 'unknown')).lower(),
'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(),
'size': raw.get('size'),
'sizeleft': raw.get('sizeleft'),
}
@staticmethod
def _empty_queue(status: str) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': status,
'total': 0,
'downloading': 0,
'queued': 0,
'failed': 0,
'stalled': 0,
'items': [],
}
+384
View File
@@ -0,0 +1,384 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _TautulliConfig:
url: str
api_key: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _safe_url() -> str:
value = settings.apis.get('TAUTULLI_URL')
return str(value).rstrip('/') if value else ''
def _mask_ip(value: Any) -> str:
text = str(value or '')
parts = text.split('.')
if len(parts) == 4:
return f'{parts[0]}.{parts[1]}.{parts[2]}.x'
return '[masked]' if text else ''
def _list_from_data(data: Any, *keys: str) -> list[dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in keys:
value = data.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
value = data.get('data')
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _title(raw: dict[str, Any]) -> str:
title = str(raw.get('full_title') or raw.get('title') or raw.get('media_title') or 'unknown')
parent = raw.get('grandparent_title') or raw.get('grandparentTitle')
return f'{parent} - {title}' if parent and str(parent) not in title else title
class TautulliConnector(BaseConnector):
name = 'Tautulli'
def is_configured(self) -> bool:
return bool(settings.apis.get('TAUTULLI_URL') and settings.apis.get('TAUTULLI_API_KEY'))
def _config(self) -> _TautulliConfig | None:
if not self.is_configured():
return None
return _TautulliConfig(
url=str(settings.apis['TAUTULLI_URL']).rstrip('/'),
api_key=str(settings.apis['TAUTULLI_API_KEY']),
verify_tls=_as_bool(settings.apis.get('TAUTULLI_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('TAUTULLI_TIMEOUT_SECONDS'), 10.0),
)
def _command(self, cmd: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
query = {'apikey': config.api_key, 'cmd': cmd, **(params or {})}
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(f'{config.url}/api/v2', params=query, headers={'Accept': 'application/json'})
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Tautulli API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
response_meta = payload.get('response') if isinstance(payload, dict) else {}
if isinstance(response_meta, dict) and str(response_meta.get('result', '')).lower() == 'error':
message = redact_message(str(response_meta.get('message') or 'tautulli_error'))
if 'api' in message.lower() and 'key' in message.lower():
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
self.set_error(message)
raise RuntimeError(message)
return payload
@staticmethod
def _data(payload: dict[str, Any]) -> Any:
response = payload.get('response') if isinstance(payload, dict) else {}
return response.get('data') if isinstance(response, dict) else None
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
data = self._data(self._command('get_server_info'))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {
'status': 'connected',
'tautulliVersion': str((data or {}).get('tautulli_version') or 'unknown') if isinstance(data, dict) else 'unknown',
'plexServerName': str((data or {}).get('pms_name') or '') if isinstance(data, dict) else '',
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'required_env_vars': ['TAUTULLI_URL', 'TAUTULLI_API_KEY']}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Tautulli connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'tautulliVersion': str(result.get('tautulliVersion', 'unknown')),
'plexServerName': str(result.get('plexServerName', '')),
}
def status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {}}
try:
data = self._data(self._command('status'))
except RuntimeError as exc:
return self._error_payload(exc, data={})
return {'success': True, 'status': 'ok', 'message': 'status_ok', 'data': data or {}}
def activity(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'streamCount': 0, 'sessions': []}
try:
data = self._data(self._command('get_activity'))
except RuntimeError as exc:
return self._error_payload(exc, streamCount=0, sessions=[])
records = _list_from_data(data, 'sessions')
sessions = [self._map_session(item) for item in records]
return {
'success': True,
'status': 'ok',
'message': 'activity_ok',
'streamCount': _as_int((data or {}).get('stream_count'), len(sessions)) if isinstance(data, dict) else len(sessions),
'lanBandwidth': _as_int((data or {}).get('lan_bandwidth')) if isinstance(data, dict) else 0,
'wanBandwidth': _as_int((data or {}).get('wan_bandwidth')) if isinstance(data, dict) else 0,
'totalBandwidth': _as_int((data or {}).get('total_bandwidth')) if isinstance(data, dict) else sum(item['bandwidth'] for item in sessions),
'sessions': sessions,
'data': {'sessions': sessions},
}
def history(self, limit: int = 25) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_history', {'length': limit}))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_history(item) for item in _list_from_data(data, 'data', 'history')]
return {'success': True, 'status': 'ok', 'message': 'history_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def users(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_users'))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_user(item) for item in _list_from_data(data, 'users')]
return {'success': True, 'status': 'ok', 'message': 'users_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def libraries(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_libraries'))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_library(item) for item in _list_from_data(data, 'libraries')]
return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items, 'data': {'items': items}}
def recently_added(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_recently_added', {'count': 25}))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_recent(item) for item in _list_from_data(data, 'recently_added', 'items')]
return {'success': True, 'status': 'ok', 'message': 'recently_added_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def watch_stats(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'totalPlays': 0, 'totalDuration': 0, 'topUsers': [], 'topLibraries': [], 'topPlatforms': [], 'playsByDay': []}
try:
by_day = [self._map_day(item) for item in _list_from_data(self._data(self._command('get_plays_by_date')), 'data')]
top_users = [self._map_top_user(item) for item in _list_from_data(self._data(self._command('get_plays_by_top_10_users')), 'data')]
top_platforms = [self._map_top_platform(item) for item in _list_from_data(self._data(self._command('get_plays_by_top_10_platforms')), 'data')]
except RuntimeError as exc:
return self._error_payload(exc, totalPlays=0, totalDuration=0, topUsers=[], topLibraries=[], topPlatforms=[], playsByDay=[])
total_plays = sum(_as_int(item.get('plays')) for item in by_day) or sum(_as_int(item.get('plays')) for item in top_users)
total_duration = sum(_as_int(item.get('duration')) for item in by_day) or sum(_as_int(item.get('duration')) for item in top_users)
return {
'success': True,
'status': 'ok',
'message': 'watch_stats_ok',
'totalPlays': total_plays,
'totalDuration': total_duration,
'topUsers': top_users,
'topLibraries': [],
'topPlatforms': top_platforms,
'playsByDay': by_day,
'data': {'topUsers': top_users, 'topPlatforms': top_platforms, 'playsByDay': by_day},
}
def stream_summary(self) -> dict[str, Any]:
activity = self.activity()
sessions = activity.get('sessions', []) if isinstance(activity, dict) else []
return {
'success': bool(activity.get('success')),
'status': activity.get('status', 'unreachable'),
'message': activity.get('message', 'stream_summary'),
'activeStreams': len(sessions),
'directPlay': sum(1 for item in sessions if item.get('transcodeDecision') == 'directplay'),
'directStream': sum(1 for item in sessions if item.get('transcodeDecision') == 'directstream'),
'transcoding': sum(1 for item in sessions if item.get('transcodeDecision') == 'transcode'),
'lanStreams': sum(1 for item in sessions if item.get('location') == 'lan'),
'wanStreams': sum(1 for item in sessions if item.get('location') == 'wan'),
'bandwidth': sum(_as_int(item.get('bandwidth')) for item in sessions),
'data': {'sessions': sessions},
}
def plex_info(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
data = self._data(self._command('get_server_info'))
except RuntimeError as exc:
return self._error_payload(exc)
data = data if isinstance(data, dict) else {}
machine = str(data.get('pms_identifier') or data.get('machine_identifier') or '')
return {
'success': True,
'status': 'ok',
'message': 'plex_info_ok',
'name': str(data.get('pms_name') or data.get('name') or ''),
'version': str(data.get('pms_version') or data.get('version') or 'unknown'),
'platform': str(data.get('pms_platform') or data.get('platform') or ''),
'machineIdentifier': f'{machine[:8]}...' if machine else '',
'plexPass': bool(data.get('pms_is_plexpass', data.get('plexpass', False))),
'data': {},
}
@staticmethod
def _map_session(raw: dict[str, Any]) -> dict[str, Any]:
decision = str(raw.get('transcode_decision') or raw.get('transcodeDecision') or 'direct play').lower().replace(' ', '')
if decision == 'copy':
decision = 'directstream'
return {
'sessionKey': str(raw.get('session_key') or raw.get('sessionKey') or ''),
'user': str(raw.get('friendly_name') or raw.get('username') or raw.get('user') or 'unknown'),
'title': _title(raw),
'mediaType': str(raw.get('media_type') or raw.get('type') or ''),
'player': str(raw.get('player') or ''),
'platform': str(raw.get('platform') or ''),
'state': str(raw.get('state') or ''),
'progress': _as_int(raw.get('progress_percent') or raw.get('progress')),
'qualityProfile': str(raw.get('quality_profile') or raw.get('qualityProfile') or ''),
'transcodeDecision': decision,
'videoDecision': str(raw.get('video_decision') or raw.get('videoDecision') or decision).lower().replace(' ', ''),
'audioDecision': str(raw.get('audio_decision') or raw.get('audioDecision') or '').lower().replace(' ', ''),
'bandwidth': _as_int(raw.get('bandwidth')),
'ipAddress': _mask_ip(raw.get('ip_address') or raw.get('ipAddress')),
'location': str(raw.get('location') or '').lower(),
}
@staticmethod
def _map_history(raw: dict[str, Any]) -> dict[str, Any]:
return {
'rowId': raw.get('row_id') or raw.get('rowId'),
'user': str(raw.get('friendly_name') or raw.get('username') or 'unknown'),
'title': _title(raw),
'mediaType': str(raw.get('media_type') or ''),
'watchedStatus': raw.get('watched_status'),
'date': raw.get('date'),
'duration': _as_int(raw.get('duration')),
'platform': str(raw.get('platform') or ''),
'transcodeDecision': str(raw.get('transcode_decision') or raw.get('transcodeDecision') or '').lower().replace(' ', ''),
}
@staticmethod
def _map_user(raw: dict[str, Any]) -> dict[str, Any]:
return {
'userId': str(raw.get('user_id') or raw.get('userId') or ''),
'username': str(raw.get('username') or ''),
'friendlyName': str(raw.get('friendly_name') or raw.get('friendlyName') or raw.get('username') or ''),
'lastSeen': raw.get('last_seen') or raw.get('lastSeen'),
'plays': _as_int(raw.get('plays')),
'duration': _as_int(raw.get('duration')),
}
@staticmethod
def _map_library(raw: dict[str, Any]) -> dict[str, Any]:
return {
'sectionId': str(raw.get('section_id') or raw.get('sectionId') or ''),
'name': str(raw.get('section_name') or raw.get('name') or ''),
'type': str(raw.get('section_type') or raw.get('type') or ''),
'count': _as_int(raw.get('count')),
'parentCount': _as_int(raw.get('parent_count') or raw.get('parentCount')),
'childCount': _as_int(raw.get('child_count') or raw.get('childCount')),
'lastAccessed': raw.get('last_accessed') or raw.get('lastAccessed'),
}
@staticmethod
def _map_recent(raw: dict[str, Any]) -> dict[str, Any]:
return {
'title': _title(raw),
'mediaType': str(raw.get('media_type') or raw.get('type') or ''),
'library': str(raw.get('section_name') or raw.get('library') or ''),
'addedAt': raw.get('added_at') or raw.get('addedAt'),
'year': raw.get('year'),
'thumb': raw.get('thumb'),
}
@staticmethod
def _map_day(raw: dict[str, Any]) -> dict[str, Any]:
return {'date': raw.get('date'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))}
@staticmethod
def _map_top_user(raw: dict[str, Any]) -> dict[str, Any]:
return {'name': str(raw.get('friendly_name') or raw.get('username') or raw.get('user') or 'unknown'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))}
@staticmethod
def _map_top_platform(raw: dict[str, Any]) -> dict[str, Any]:
return {'platform': str(raw.get('platform') or 'unknown'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, **extra}
+742
View File
@@ -0,0 +1,742 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_UNRAID_DEFAULT_TIMEOUT_SECONDS = 8.0
_UNRAID_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
@dataclass
class _ConnectorConfig:
url: str
api_key: str
verify_tls: bool
def _as_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {'true', '1', 'yes', 'on', 'running', 'started'}:
return True
if normalized in {'false', '0', 'off', 'stopped'}:
return False
return None
def _as_int(value: Any, default: int = 0) -> int:
if value is None:
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
if isinstance(value, str):
cleaned = value.strip().strip('%')
if not cleaned:
return default
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
if not numeric:
return default
try:
return int(float(numeric))
except ValueError:
return default
return default
def _as_float(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
cleaned = value.strip().strip('%')
if not cleaned:
return None
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
if not numeric:
return None
try:
return float(numeric)
except ValueError:
return None
return None
def _clamp_percent(value: float) -> int:
return int(round(max(0.0, min(100.0, value))))
def _percent_from_values(used: Any, total: Any) -> int | None:
used_value = _as_float(used)
total_value = _as_float(total)
if used_value is None or total_value is None or total_value <= 0:
return None
return _clamp_percent((used_value / total_value) * 100)
def _average_cpu_usage(cpus: Any) -> int | None:
if not isinstance(cpus, list) or not cpus:
return None
idle_values: list[float] = []
active_values: list[float] = []
active_fields = ('percentUser', 'percentSystem', 'percentNice', 'percentIrq')
for raw_cpu in cpus:
if not isinstance(raw_cpu, dict):
continue
idle = _as_float(raw_cpu.get('percentIdle'))
if idle is not None:
idle_values.append(max(0.0, min(100.0, idle)))
continue
active_total = 0.0
has_active = False
for field in active_fields:
value = _as_float(raw_cpu.get(field))
if value is None:
continue
active_total += value
has_active = True
if has_active:
active_values.append(active_total)
if idle_values:
return _clamp_percent(100 - (sum(idle_values) / len(idle_values)))
if active_values:
return _clamp_percent(sum(active_values) / len(active_values))
return None
def _storage_value_to_gib(value: Any) -> int:
raw = _as_int(value, 0)
if raw <= 0:
return 0
if raw >= 1_048_576:
return int(raw / 1024 / 1024)
return raw
def _get_nested(data: dict[str, Any], path: str) -> Any:
cursor: Any = data
for part in path.split('.'):
if not isinstance(cursor, dict):
return None
cursor = cursor.get(part)
if cursor is None:
return None
return cursor
def _first_value(data: dict[str, Any], paths: tuple[str, ...]) -> Any:
for path in paths:
value = _get_nested(data, path)
if value is not None:
return value
return None
def _normalize_status(value: Any, allowed: tuple[str, ...], default: str = "stopped") -> str:
if value is None:
return default
candidate = str(value).strip().lower()
if not candidate:
return default
if candidate in {"1", "true", "started", "running", "online", "healthy"}:
return allowed[0]
if candidate in {"0", "false", "stopped", "offline", "unhealthy", "critical", "invalid"}:
return allowed[1] if len(allowed) > 1 else default
return candidate if candidate in allowed else default
def _safe_disk_warning_count(disks: list[dict[str, Any]]) -> int:
total = 0
for raw_disk in disks:
if not isinstance(raw_disk, dict):
continue
warning = raw_disk.get('warning')
if warning is True:
total += 1
continue
if raw_disk.get('critical') is not None:
total += 1
continue
status = str(raw_disk.get('status', raw_disk.get('state', ''))).strip().lower()
if status in {'warning', 'degraded', 'failed', 'offline', 'faulty', 'disk_dsbl', 'disk_invalid'}:
total += 1
if _as_bool(raw_disk.get('healthy')) is False:
total += 1
return total
def _trusted_https_redirect(response: httpx.Response) -> httpx.URL | None:
if response.status_code not in _UNRAID_REDIRECT_STATUSES:
return None
location = response.headers.get('location')
if not location:
return None
redirected = response.url.join(location)
if redirected.scheme != 'https' or redirected.host != response.url.host:
return None
return redirected
class UnraidConnector(BaseConnector):
name = 'Unraid'
default_headers = {
'Content-Type': 'application/json',
'x-api-key': '',
}
def __init__(self):
super().__init__()
def is_configured(self):
return bool(settings.apis.get('UNRAID_URL') and settings.apis.get('UNRAID_API_KEY'))
def _config(self) -> _ConnectorConfig | None:
if not self.is_configured():
return None
return _ConnectorConfig(
url=str(settings.apis['UNRAID_URL']).rstrip('/'),
api_key=str(settings.apis['UNRAID_API_KEY']),
verify_tls=bool(settings.apis.get('UNRAID_VERIFY_TLS', settings.verify_tls)),
)
def _request(self, query: str) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(self.default_headers)
headers['x-api-key'] = config.api_key
url = f'{config.url}/graphql'
try:
self.clear_error()
with httpx.Client(timeout=_UNRAID_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.post(url, headers=headers, json={'query': query})
if response.status_code in _UNRAID_REDIRECT_STATUSES:
redirected = _trusted_https_redirect(response)
if redirected is None:
raise RuntimeError('Unraid API refused an untrusted redirect')
response = client.post(str(redirected), headers=headers, json={'query': query})
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
message = 'unauthorized'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 500:
message = f'Unraid server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
payload = response.text or ''
message = f'Unraid API error: HTTP {response.status_code} {payload[:256]}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if not isinstance(payload, dict):
message = 'Invalid GraphQL payload'
self.set_error(message)
raise RuntimeError(message)
if payload.get('errors'):
error_messages = [
str(err.get('message', 'unknown'))
for err in payload.get('errors') if isinstance(err, dict)
]
message = f'GraphQL error: {", ".join(error_messages[:2])}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
data = payload.get('data')
if data is None:
message = 'No GraphQL data field'
self.set_error(message)
raise RuntimeError(message)
if not isinstance(data, dict):
message = 'Invalid GraphQL data'
self.set_error(message)
raise RuntimeError(message)
return data
def _query(self, queries: list[str]) -> tuple[dict[str, Any], str]:
last_error = ''
for query in queries:
try:
return self._request(query), 'ok'
except RuntimeError as exc:
last_error = str(exc)
if last_error == 'missing_config':
return {'error': 'missing_config'}, 'missing_config'
if 'VMs are not available' in last_error:
return {'error': last_error}, 'unavailable'
continue
return {'error': last_error or 'query_failed'}, 'error'
def test_connection(self):
data, status = self._query(['query { __typename }'])
if status == 'ok':
return {'status': 'connected'}
if status == 'missing_config':
return {'status': 'missing_config', 'reason': 'missing_config'}
reason = str(data.get('error', 'connection_failed'))
return {'status': 'error', 'reason': reason}
def get_server_info(self):
payload, status = self._query([
'query { info { os { platform distro release uptime } cpu { manufacturer brand cores threads } } }',
'query { serverInfo { hostname version uptime cpuPercent ramPercent } }',
'query { system { name version uptime cpuPercent ramPercent } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
uptime = _first_value(payload, ('info.os.uptime', 'serverInfo.uptime', 'system.uptime', 'info.uptime'))
hostname = _first_value(payload, ('serverInfo.hostname', 'system.hostname', 'info.hostname', 'serverInfo.name'))
version = _first_value(payload, ('info.os.release', 'serverInfo.version', 'system.version', 'info.version'))
cpu_brand = _first_value(payload, ('info.cpu.brand',))
cpu_cores = _first_value(payload, ('info.cpu.cores',))
cpu_threads = _first_value(payload, ('info.cpu.threads',))
return {
'status': 'ok',
'info': {
'hostname': str(hostname or 'unknown'),
'version': str(version or 'unknown'),
'uptime': str(uptime or 'unknown'),
'name': str(hostname or 'unraid'),
'cpu': {
'brand': cpu_brand,
'cores': _as_int(cpu_cores, 0) if cpu_cores is not None else None,
'threads': _as_int(cpu_threads, 0) if cpu_threads is not None else None,
},
},
}
def get_system_metrics(self):
payload, status = self._query([
(
'query { metrics { '
'cpu { cpus { percentIdle percentUser percentSystem percentNice percentIrq } } '
'memory { total used free available active } '
'} }'
),
])
if status != 'ok':
return {
'status': status,
'reason': str(payload.get('error', 'metrics_unavailable')),
'cpuUsage': None,
'ramUsage': None,
}
cpu_usage = _average_cpu_usage(_first_value(payload, ('metrics.cpu.cpus',)))
memory_total = _first_value(payload, ('metrics.memory.total',))
memory_available = _first_value(payload, ('metrics.memory.available',))
memory_used = _first_value(payload, ('metrics.memory.used',))
# Linux includes reclaimable cache (and often ZFS ARC) in ``used``.
# Prefer total - available so the dashboard reflects actual memory
# pressure rather than useful filesystem cache.
ram_uses_available = (
isinstance(memory_total, (int, float))
and isinstance(memory_available, (int, float))
and 0 <= memory_available <= memory_total
)
if ram_uses_available:
memory_used = memory_total - memory_available
ram_usage = _percent_from_values(memory_used, memory_total)
missing = []
if cpu_usage is None:
missing.append('cpu_unavailable')
if ram_usage is None:
missing.append('ram_unavailable')
memory = _first_value(payload, ('metrics.memory',))
return {
'status': 'ok' if not missing else 'partial',
'reason': ','.join(missing) if missing else None,
'cpuUsage': cpu_usage,
'ramUsage': ram_usage,
'sources': {
'cpu': 'metrics.cpu.cpus.percentIdle' if cpu_usage is not None else 'unavailable',
'ram': 'metrics.memory.available_total' if ram_usage is not None and ram_uses_available else ('metrics.memory.used_total' if ram_usage is not None else 'unavailable'),
},
'memory': memory if isinstance(memory, dict) else {},
}
def get_array_status(self):
payload, status = self._query([
'query { array { state } }',
'query { array { status started } }',
'query { system { array { status started } } }',
'query { arrayStatus started }',
'query { system { arrayStatus started } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
raw_status = _first_value(payload, ('array.state', 'array.status', 'arrayStatus', 'system.array.status', 'system.arrayStatus', 'state', 'status'))
started = _first_value(payload, ('array.started', 'system.array.started', 'started'))
if isinstance(started, bool):
raw_status = 'started' if started else 'stopped'
elif isinstance(started, str):
if started.lower() in {'true', '1', 'yes', 'running', 'started', 'online'}:
raw_status = 'started'
elif started.lower() in {'false', '0', 'no', 'stopped', 'offline'}:
raw_status = 'stopped'
normalized = _normalize_status(raw_status, ('started', 'stopped', 'maintenance'), default='maintenance')
return {'status': 'ok', 'arrayStatus': normalized}
def get_disk_warnings(self):
payload, status = self._query([
'query { array { disks { name status warning critical fsSize fsUsed fsFree type } } }',
'query { disks { name smartStatus temperature isSpinning size } }',
'query { array { disks { name status healthy warning } } }',
'query { system { disks { name status healthy warning } } }',
'query { disks { name status healthy warning } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'disks': [], 'diskWarnings': 0}
disks_raw = _first_value(payload, ('array.disks', 'system.disks', 'disks')) or []
if not isinstance(disks_raw, list):
disks_raw = []
disks: list[dict[str, Any]] = []
for raw_disk in disks_raw:
if not isinstance(raw_disk, dict):
continue
status_value = str(raw_disk.get('status') or raw_disk.get('smartStatus') or 'unknown')
warning = raw_disk.get('warning') is not None or raw_disk.get('critical') is not None
healthy = _as_bool(raw_disk.get('healthy', None))
if healthy is None:
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'unknown'}
used_gib = _storage_value_to_gib(raw_disk.get('fsUsed', raw_disk.get('used')))
size_gib = _storage_value_to_gib(raw_disk.get('fsSize', raw_disk.get('size')))
free_gib = _storage_value_to_gib(raw_disk.get('fsFree', raw_disk.get('free')))
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
size_gib = used_gib + free_gib
disks.append({
'name': str(raw_disk.get('name', 'unknown')),
'status': status_value,
'healthy': healthy,
'type': raw_disk.get('type'),
'usedGiB': used_gib if used_gib > 0 else None,
'sizeGiB': size_gib if size_gib > 0 else None,
'freeGiB': free_gib if free_gib > 0 else None,
'temperature': raw_disk.get('temperature'),
})
return {'status': 'ok', 'disks': disks, 'diskWarnings': _safe_disk_warning_count(disks_raw)}
def get_cache_status(self):
payload, status = self._query([
'query { array { caches { name fsSize fsUsed fsFree size } } }',
'query { cache { usagePercent usedGiB sizeGiB } }',
'query { cache { used size } }',
'query { system { cache { usagePercent usedGiB sizeGiB used size } } }',
'query { cacheUsage }',
'query { cachePercent }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'cacheUsage': 0, 'pools': []}
cache_percent = _first_value(payload, ('cache.usagePercent', 'system.cache.usagePercent', 'cacheUsage', 'cachePercent'))
pools: list[dict[str, Any]] = []
caches = _first_value(payload, ('array.caches',))
if isinstance(caches, list):
for cache in caches:
if not isinstance(cache, dict):
continue
used_gib = _storage_value_to_gib(cache.get('fsUsed', cache.get('used', cache.get('usedGiB'))))
size_gib = _storage_value_to_gib(cache.get('fsSize', cache.get('size', cache.get('sizeGiB'))))
free_gib = _storage_value_to_gib(cache.get('fsFree', cache.get('free', cache.get('freeGiB'))))
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
size_gib = used_gib + free_gib
status_value = str(cache.get('status') or cache.get('smartStatus') or 'unknown')
warning = cache.get('warning') is not None or cache.get('critical') is not None
healthy = _as_bool(cache.get('healthy', None))
if healthy is None:
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'healthy', 'unknown'}
usage_percent = _as_int(cache.get('usagePercent'), -1)
if usage_percent < 0:
usage_percent = _percent_from_values(used_gib, size_gib)
pools.append({
'name': str(cache.get('name', 'cache')),
'status': status_value,
'healthy': healthy,
'type': cache.get('type'),
'usedGiB': used_gib if used_gib > 0 else None,
'sizeGiB': size_gib if size_gib > 0 else None,
'freeGiB': free_gib if free_gib > 0 else None,
'usagePercent': usage_percent,
})
if cache_percent is None:
if isinstance(caches, list):
used_total = 0
size_total = 0
for cache in caches:
if not isinstance(cache, dict):
continue
used_total += _as_int(cache.get('fsUsed', cache.get('used')), 0)
size_total += _as_int(cache.get('fsSize', cache.get('size')), 0)
if size_total > 0:
cache_percent = int((used_total / size_total) * 100)
if cache_percent is None:
used = _first_value(payload, ('cache.used', 'cache.usedGiB', 'system.cache.used', 'system.cache.usedGiB'))
total = _first_value(payload, ('cache.size', 'cache.sizeGiB', 'system.cache.size', 'system.cache.sizeGiB'))
if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
cache_percent = int((float(used) / float(total)) * 100)
if isinstance(cache_percent, bool):
cache_percent = 100 if cache_percent else 0
usage = _as_int(cache_percent, 0)
return {'status': 'ok', 'cacheUsage': usage, 'pools': pools}
def get_parity_status(self):
payload, status = self._query([
(
'query { array { '
'parityCheckStatus { progress speed errors status paused running correcting } '
'parities { name status warning critical } '
'} }'
),
'query { array { parities { name status warning critical } } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'parityStatus': 'invalid'}
check = _first_value(payload, ('array.parityCheckStatus',))
check = check if isinstance(check, dict) else {}
check_status = str(check.get('status') or '').strip().lower()
paused = _as_bool(check.get('paused')) is True
running = (
not paused
and (
_as_bool(check.get('running')) is True
or check_status in {'running', 'checking', 'resyncing'}
)
)
if running or paused or check_status in {'running', 'checking', 'paused', 'resyncing'}:
parity_status = 'checking'
else:
parities = _first_value(payload, ('array.parities',))
parities = parities if isinstance(parities, list) else []
healthy_statuses = {'disk_ok', 'ok', 'valid', 'healthy', 'online'}
parity_status = 'valid' if parities and all(
isinstance(disk, dict)
and not disk.get('warning')
and not disk.get('critical')
and str(disk.get('status') or '').strip().lower() in healthy_statuses
for disk in parities
) else 'invalid'
return {
'status': 'ok',
'parityStatus': parity_status,
'checkStatus': check_status or 'idle',
'running': running,
'paused': paused,
'correcting': _as_bool(check.get('correcting')) is True,
'progress': check.get('progress'),
'speed': check.get('speed'),
'errors': _as_int(check.get('errors'), 0),
}
def get_docker_service_status(self):
payload, status = self._query([
'query { docker { containers { id names image state status } networks { id name } } }',
'query { docker { running status } }',
'query { dockerService { running status } }',
'query { system { docker { running status } } }',
'query { dockerStatus }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'running': False}
docker_containers = _first_value(payload, ('docker.containers',))
raw_running = _first_value(payload, ('docker.running', 'dockerService.running', 'system.docker.running'))
if raw_running is None and isinstance(docker_containers, list):
raw_running = True
if isinstance(raw_running, str) and raw_running.lower() in {'running', 'started', 'online'}:
raw_running = True
running = bool(_as_bool(raw_running) or str(raw_running).lower() in {'running', 'started', 'online'})
return {
'status': 'ok',
'running': running,
'serviceStatus': str(_first_value(payload, ('docker.status', 'dockerService.status', 'system.docker.status', 'status')) or ('running' if running else 'stopped')),
'containers': len(docker_containers) if isinstance(docker_containers, list) else None,
}
def get_vm_summary(self):
payload, status = self._query([
'query { vms { domains { id uuid name state } } }',
'query { vms { id name state status } }',
'query { virtualMachines { id name state status } }',
'query { system { vms { id name state status } } }',
])
if status != 'ok':
reason = str(payload.get('error', 'connection_failed'))
if 'VMs are not available' in reason:
return {
'status': 'api_unavailable',
'reason': (
'De Unraid API gaf geen VM-toegang terug. VM Manager kan nog steeds actief zijn; '
'controleer of de ingestelde API-sleutel vm:read (of vm:*) heeft en of deze '
'Unraid API-versie het VM-schema beschikbaar stelt.'
),
'vms': [],
}
return {'status': status, 'reason': reason, 'vms': []}
raw_vms = _first_value(payload, ('vms.domains', 'vms', 'virtualMachines', 'system.vms'))
if not isinstance(raw_vms, list):
raw_vms = []
vms = []
for raw_vm in raw_vms:
if not isinstance(raw_vm, dict):
continue
vms.append({
'id': str(raw_vm.get('id', raw_vm.get('uuid', raw_vm.get('name', '')))),
'name': str(raw_vm.get('name', 'unknown')),
'state': str(raw_vm.get('state', raw_vm.get('status', 'unknown'))),
})
return {'status': 'ok', 'vms': vms}
def mutate_vm(self, vm_id: str, action: str) -> dict[str, Any]:
supported_actions = {'start', 'pause', 'resume'}
normalized_action = str(action).strip().lower()
normalized_vm_id = str(vm_id).strip()
if normalized_action not in supported_actions:
return {
'success': False,
'status': 'blocked',
'message': 'unsupported_or_blocked_vm_action',
'action': normalized_action,
}
if not normalized_vm_id:
return {
'success': False,
'status': 'invalid_target',
'message': 'vm_id_required',
'action': normalized_action,
}
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'action': normalized_action,
}
# json.dumps produces a valid escaped GraphQL string literal without
# interpolating an operator-controlled identifier as GraphQL syntax.
vm_id_literal = json.dumps(normalized_vm_id)
query = f'mutation AppOpsVmAction {{ vm {{ {normalized_action}(id: {vm_id_literal}) }} }}'
try:
payload = self._request(query)
except RuntimeError as exc:
reason = str(exc)
if reason == 'unauthorized':
reason = 'Unraid API key needs vm:update (or vm:*) permission.'
return {
'success': False,
'status': 'permission_denied' if 'unauthorized' in reason.lower() else 'error',
'message': reason,
'action': normalized_action,
}
changed = _first_value(payload, (f'vm.{normalized_action}',))
return {
'success': changed is True,
'status': 'ok' if changed is True else 'failed',
'message': f'vm_{normalized_action}_accepted' if changed is True else f'vm_{normalized_action}_rejected',
'action': normalized_action,
}
def get_share_summary(self):
payload, status = self._query([
'query { shares { name free used size } }',
'query { shares { name usedGiB sizeGiB critical } }',
'query { system { shares { name usedGiB sizeGiB critical } } }',
'query { shareSummary { shares { name usedGiB sizeGiB critical } } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'shares': []}
raw_shares = _first_value(payload, ('shares', 'system.shares', 'shareSummary.shares'))
if not isinstance(raw_shares, list):
raw_shares = []
shares = []
for share in raw_shares:
if not isinstance(share, dict):
continue
used = _as_int(share.get('usedGiB'), -1)
size = _as_int(share.get('sizeGiB'), -1)
if used < 0:
used = _storage_value_to_gib(share.get('used'))
if size < 0:
size = _storage_value_to_gib(share.get('size'))
if size <= 0 and share.get('free') is not None:
size = used + _storage_value_to_gib(share.get('free'))
shares.append({
'name': str(share.get('name', 'unknown')),
'usedGiB': used,
'sizeGiB': size,
'critical': bool(_as_bool(share.get('critical', False))),
})
return {'status': 'ok', 'shares': shares}
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _WidefrogConfig:
url: str
api_key: str | None
verify_tls: bool
timeout_seconds: float
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_url() -> str:
value = settings.apis.get('WIDEFROG_URL')
if not value:
return ''
parsed = urlparse(str(value).rstrip('/'))
if not parsed.scheme or not parsed.netloc:
return str(value).rstrip('/')
return f'{parsed.scheme}://{parsed.netloc}'
def _port() -> int:
parsed = urlparse(_safe_url())
return parsed.port or 1207
class WidefrogConnector(BaseConnector):
name = 'Widefrog'
def is_configured(self) -> bool:
return bool(settings.apis.get('WIDEFROG_URL'))
def _config(self) -> _WidefrogConfig | None:
if not self.is_configured():
return None
return _WidefrogConfig(
url=str(settings.apis['WIDEFROG_URL']).rstrip('/'),
api_key=str(settings.apis['WIDEFROG_API_KEY']) if settings.apis.get('WIDEFROG_API_KEY') else None,
verify_tls=bool(settings.apis.get('WIDEFROG_VERIFY_TLS', False)),
timeout_seconds=_as_float(settings.apis.get('WIDEFROG_TIMEOUT_SECONDS'), 10.0),
)
def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
if config.api_key:
headers['X-Api-Key'] = config.api_key
try:
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(method=method, url=f'{config.url}{path}', headers=headers, **kwargs)
except (httpx.TimeoutException, httpx.ConnectError, httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 404:
raise RuntimeError('unsupported_endpoint')
if response.status_code in {401, 403}:
raise RuntimeError('unauthorized')
if response.status_code >= 500:
raise RuntimeError(f'Widefrog server error: HTTP {response.status_code}')
if response.status_code >= 400:
raise RuntimeError(redact_message(f'Widefrog API error: HTTP {response.status_code} {(response.text or "")[:256]}'))
if response.status_code == 204:
return {}
content_type = response.headers.get('content-type', '')
if 'json' not in content_type and response.text:
return {'message': response.text[:256], 'status': 'ok'}
try:
return response.json() if response.text else {}
except ValueError as exc:
raise RuntimeError(redact_message(str(exc)))
def _first_supported(self, method: str, paths: list[str], **kwargs: Any) -> tuple[Any | None, str | None, str | None]:
last_error: str | None = None
for path in paths:
try:
return self._request_json(method, path, **kwargs), path, None
except RuntimeError as exc:
reason = str(exc)
if reason == 'unsupported_endpoint':
last_error = reason
continue
return None, path, redact_message(reason)
return None, None, last_error or 'unsupported_endpoint'
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/'])
if error:
return {'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'reason': redact_message(error)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'port': 1207, 'version': 'unknown', 'checks': [], 'required_env_vars': ['WIDEFROG_URL']}
payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/'])
if error:
return {'success': False, 'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'message': redact_message(error), 'url': _safe_url(), 'port': _port(), 'version': 'unknown', 'checks': []}
data = payload if isinstance(payload, dict) else {}
return {'success': True, 'status': 'connected', 'message': 'Widefrog connector ready.', 'url': _safe_url(), 'port': _port(), 'version': str(data.get('version', 'unknown')), 'checks': data.get('checks', []) if isinstance(data.get('checks'), list) else []}
def queue_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_queue('missing_config')
payload, _path, error = self._first_supported('GET', ['/api/queue', '/api/jobs'])
if error:
result = self._empty_queue('unavailable')
result['message'] = redact_message(error)
return result
items = _extract_items(payload)
return _queue_summary(items)
def failed_jobs(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'failed': 0, 'items': []}
payload, _path, error = self._first_supported('GET', ['/api/failed', '/api/jobs/failed', '/api/queue/failed', '/api/jobs'])
if error:
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'failed': 0, 'items': []}
items = [item for item in _extract_items(payload) if str(item.get('status', '')).lower() == 'failed']
if not items:
items = _extract_items(payload)
return {'success': True, 'status': 'ok', 'failed': len(items), 'items': items}
def activity(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'recent': [], 'last_event': None}
payload, _path, error = self._first_supported('GET', ['/api/activity', '/api/events', '/api/history'])
if error:
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'recent': [], 'last_event': None}
recent = _extract_items(payload, key='recent')
return {'success': True, 'status': 'ok', 'recent': recent, 'last_event': recent[0] if recent else None}
def download_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'active': 0, 'speed': None, 'processing': 0, 'items': []}
payload, _path, error = self._first_supported('GET', ['/api/download-status', '/api/downloads', '/api/queue/downloads'])
if error:
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'active': 0, 'speed': None, 'processing': 0, 'items': []}
data = payload if isinstance(payload, dict) else {}
items = _extract_items(payload)
return {'success': True, 'status': 'ok', 'active': int(data.get('active', len(items)) or 0), 'speed': data.get('speed'), 'processing': int(data.get('processing', 0) or 0), 'items': items}
def retry_failed(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
result, _path, error = self._first_supported('POST', ['/api/retry-failed', '/api/jobs/retry-failed', '/api/failed/retry'], json=payload or {})
if error:
return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)}
return {'success': True, 'status': 'ok', 'message': 'retry_started', 'data': result if isinstance(result, dict) else {}}
def scan_library(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
result, _path, error = self._first_supported('POST', ['/api/scan-library', '/api/library/scan', '/api/scan'], json=payload or {})
if error:
return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)}
return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': result if isinstance(result, dict) else {}}
@staticmethod
def _empty_queue(status: str) -> dict[str, Any]:
return {'success': False, 'status': status, 'message': status, 'total': 0, 'active': 0, 'queued': 0, 'completed': 0, 'failed': 0, 'items': []}
def _extract_items(payload: Any, key: str = 'items') -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
value = payload.get(key) or payload.get('jobs') or payload.get('queue') or []
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _queue_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
normalized = [{**item, 'status': str(item.get('status', 'unknown')).lower()} for item in items]
return {
'success': True,
'status': 'ok',
'total': len(normalized),
'active': sum(1 for item in normalized if item['status'] in {'active', 'running', 'processing'}),
'queued': sum(1 for item in normalized if item['status'] in {'queued', 'pending'}),
'completed': sum(1 for item in normalized if item['status'] in {'completed', 'done', 'success'}),
'failed': sum(1 for item in normalized if item['status'] in {'failed', 'error'}),
'items': normalized,
}
+362
View File
@@ -0,0 +1,362 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
def _env_value(name: str, default: str | None) -> str | None:
value = os.getenv(name)
if value is None:
return default
value = value.strip()
return value or default
def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _secret_value(name: str, file_name: str) -> str | None:
value = _env_value(name, None)
if value:
return value
path = _env_value(file_name, None)
if not path:
return None
try:
with open(path, encoding="utf-8") as secret_file:
return secret_file.read().strip() or None
except OSError:
return None
def _env_list(name: str, default: list[str] | None = None) -> list[str]:
value = os.getenv(name)
if value is None:
return list(default or [])
return [item.strip() for item in value.split(",") if item.strip()]
@dataclass
class Settings:
app_title: str = "Unraid AppOps Gateway"
bind_host: str = "0.0.0.0"
port: int = 1216
db_path: str = "/data/appops.db"
data_dir: str = "/data"
environment: str = "development"
log_level: str = "info"
static_dir: str = "dist"
frontend_origin: str = "http://localhost:5173"
voice_token: str | None = None
voice_token_file: str | None = None
operator_token: str | None = None
operator_token_file: str | None = None
operator_auth_required: bool = False
operator_proxy_auth_enabled: bool = False
operator_proxy_trusted_networks: list[str] | None = None
operator_proxy_users: list[str] | None = None
operator_proxy_groups: list[str] | None = None
deployment_mode: str = "private"
verify_tls: bool = True
secret_key: str | None = None
secret_key_file: str | None = None
secret_key_source: str = "missing"
secret_rotation_enabled: bool = False
discovery_interval_seconds: int = 0
discovery_history_runs: int = 0
notification_interval_seconds: int = 0
connector_health_interval_seconds: int = 0
observability_persist_interval_seconds: int = 0
restore_drill_interval_seconds: int = 0
mcp_canary_interval_seconds: int = 0
backup_retention: int = 20
mutation_rate_limit_per_minute: int = 120
mcp_enabled: bool = False
mcp_token: str | None = None
mcp_token_file: str | None = None
mcp_allowed_hosts: list[str] | None = None
n8n_enabled: bool = False
n8n_token: str | None = None
n8n_token_file: str | None = None
apis: dict[str, str | bool | None] = None # populated in __post_init__
def __post_init__(self) -> None:
self.app_title = _env_value("APP_OPS_APP_TITLE", self.app_title) or self.app_title
self.bind_host = _env_value("APPOPS_BIND_HOST", self.bind_host) or self.bind_host
self.port = int(_env_value("APPOPS_PORT", str(self.port)) or str(self.port))
self.db_path = _env_value("APPOPS_DB_PATH", self.db_path) or self.db_path
self.data_dir = _env_value("APPOPS_DATA_DIR", self.data_dir) or self.data_dir
self.environment = _env_value("APPOPS_ENV", self.environment) or self.environment
default_discovery_interval = 120 if self.environment == "production" else self.discovery_interval_seconds
self.discovery_interval_seconds = max(0, int(_env_value("APPOPS_DISCOVERY_INTERVAL_SECONDS", str(default_discovery_interval)) or "0"))
default_discovery_history = 48 if self.environment == "production" else self.discovery_history_runs
self.discovery_history_runs = max(0, int(_env_value("APPOPS_DISCOVERY_HISTORY_RUNS", str(default_discovery_history)) or "0"))
production_default = self.environment == "production"
self.notification_interval_seconds = max(0, int(_env_value("APPOPS_NOTIFICATION_INTERVAL_SECONDS", "300" if production_default else str(self.notification_interval_seconds)) or "0"))
self.connector_health_interval_seconds = max(0, int(_env_value("APPOPS_CONNECTOR_HEALTH_INTERVAL_SECONDS", "300" if production_default else str(self.connector_health_interval_seconds)) or "0"))
self.observability_persist_interval_seconds = max(0, int(_env_value("APPOPS_OBSERVABILITY_PERSIST_INTERVAL_SECONDS", "60" if production_default else str(self.observability_persist_interval_seconds)) or "0"))
self.restore_drill_interval_seconds = max(0, int(_env_value("APPOPS_RESTORE_DRILL_INTERVAL_SECONDS", "86400" if production_default else str(self.restore_drill_interval_seconds)) or "0"))
self.mcp_canary_interval_seconds = max(0, int(_env_value("APPOPS_MCP_CANARY_INTERVAL_SECONDS", "300" if production_default else str(self.mcp_canary_interval_seconds)) or "0"))
self.backup_retention = max(1, min(200, int(_env_value("APPOPS_BACKUP_RETENTION", str(self.backup_retention)) or "20")))
self.mutation_rate_limit_per_minute = max(10, min(1000, int(_env_value("APPOPS_MUTATION_RATE_LIMIT", str(self.mutation_rate_limit_per_minute)) or "120")))
self.mcp_enabled = _env_bool("APPOPS_ENABLE_MCP", self.mcp_enabled)
self.mcp_token = _env_value("APPOPS_MCP_TOKEN", self.mcp_token)
self.mcp_token_file = _env_value("APPOPS_MCP_TOKEN_FILE", self.mcp_token_file)
if not self.mcp_token and self.mcp_token_file:
try:
with open(self.mcp_token_file, encoding="utf-8") as token_file:
self.mcp_token = token_file.read().strip() or None
except OSError:
self.mcp_token = None
allowed_hosts = _env_value("APPOPS_MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*,198.18.10.150:*") or ""
self.mcp_allowed_hosts = [item.strip() for item in allowed_hosts.split(",") if item.strip()]
self.n8n_enabled = _env_bool("APPOPS_ENABLE_N8N", self.n8n_enabled)
self.n8n_token = _env_value("APPOPS_N8N_TOKEN", self.n8n_token)
self.n8n_token_file = _env_value("APPOPS_N8N_TOKEN_FILE", self.n8n_token_file)
if not self.n8n_token and self.n8n_token_file:
try:
with open(self.n8n_token_file, encoding="utf-8") as token_file:
self.n8n_token = token_file.read().strip() or None
except OSError:
self.n8n_token = None
self.log_level = (_env_value("APPOPS_LOG_LEVEL", self.log_level) or self.log_level).lower()
self.static_dir = _env_value("APPOPS_STATIC_DIR", self.static_dir) or self.static_dir
self.frontend_origin = _env_value("APPOPS_FRONTEND_ORIGIN", None) or (
f"http://localhost:{self.port}" if self.environment == "production" else self.frontend_origin
)
self.voice_token_file = _env_value("APP_OPS_VOICE_TOKEN_FILE", self.voice_token_file)
self.voice_token = _secret_value("APP_OPS_VOICE_TOKEN", "APP_OPS_VOICE_TOKEN_FILE") or self.voice_token
self.operator_token = _env_value("APPOPS_OPERATOR_TOKEN", self.operator_token)
self.operator_token_file = _env_value("APPOPS_OPERATOR_TOKEN_FILE", self.operator_token_file)
if not self.operator_token and self.operator_token_file:
try:
with open(self.operator_token_file, encoding="utf-8") as token_file:
self.operator_token = token_file.read().strip() or None
except OSError:
self.operator_token = None
self.operator_auth_required = _env_bool("APPOPS_REQUIRE_OPERATOR_AUTH", self.operator_auth_required)
self.operator_proxy_auth_enabled = _env_bool("APPOPS_OPERATOR_PROXY_AUTH", self.operator_proxy_auth_enabled)
self.operator_proxy_trusted_networks = _env_list("APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS", self.operator_proxy_trusted_networks)
self.operator_proxy_users = _env_list("APPOPS_OPERATOR_PROXY_USERS", self.operator_proxy_users)
self.operator_proxy_groups = _env_list("APPOPS_OPERATOR_PROXY_GROUPS", self.operator_proxy_groups)
self.deployment_mode = (_env_value("APPOPS_DEPLOYMENT_MODE", self.deployment_mode) or self.deployment_mode).lower()
self.verify_tls = _env_bool("APPOPS_VERIFY_TLS", self.verify_tls)
self.secret_key_file = _env_value("APPOPS_SECRET_KEY_FILE", self.secret_key_file)
self.secret_key = _env_value("APPOPS_SECRET_KEY", self.secret_key)
self.secret_key_source = "env" if self.secret_key else "missing"
if not self.secret_key and self.secret_key_file:
try:
with open(self.secret_key_file, encoding="utf-8") as key_file:
self.secret_key = key_file.read().strip() or None
self.secret_key_source = "file" if self.secret_key else "missing"
except OSError:
self.secret_key = None
self.secret_key_source = "file_unreadable"
self.secret_rotation_enabled = _env_bool("APPOPS_SECRET_ROTATION_ENABLED", self.secret_rotation_enabled)
gitea_admin_token = _secret_value("GITEA_ADMIN_TOKEN", "GITEA_ADMIN_TOKEN_FILE")
self.apis = {
"PORTAINER_URL": _env_value("PORTAINER_URL", None),
"PORTAINER_API_KEY": _env_value("PORTAINER_API_KEY", None),
"PORTAINER_ENDPOINT_ID": _env_value("PORTAINER_ENDPOINT_ID", None),
"PORTAINER_VERIFY_TLS": _env_bool("PORTAINER_VERIFY_TLS", self.verify_tls),
"UNRAID_URL": _env_value("UNRAID_URL", None),
"UNRAID_API_KEY": _env_value("UNRAID_API_KEY", None),
"UNRAID_VERIFY_TLS": _env_bool("UNRAID_VERIFY_TLS", self.verify_tls),
"GITEA_URL": _env_value("GITEA_URL", None),
"GITEA_ADMIN_TOKEN": gitea_admin_token,
"GITEA_VERIFY_TLS": _env_bool("GITEA_VERIFY_TLS", self.verify_tls),
"GITEA_TIMEOUT_SECONDS": _env_value("GITEA_TIMEOUT_SECONDS", "8"),
"GITEA_RUNNER_TARGETS_JSON": _env_value(
"GITEA_RUNNER_TARGETS_JSON",
'{}',
),
# Deployment Control Plane V1 uses the private broker's dedicated
# typed endpoint. The HMAC material is always resolved from the
# encrypted Secret store by reference, never from this environment
# preview or a deployment plan.
"DEPLOYMENT_BROKER_URL": _env_value("DEPLOYMENT_BROKER_URL", None),
"DEPLOYMENT_BROKER_VERIFY_TLS": _env_bool(
"DEPLOYMENT_BROKER_VERIFY_TLS", self.verify_tls
),
"DEPLOYMENT_BROKER_CA_FILE": _env_value(
"DEPLOYMENT_BROKER_CA_FILE", None
),
"DEPLOYMENT_BROKER_TIMEOUT_SECONDS": _env_value(
"DEPLOYMENT_BROKER_TIMEOUT_SECONDS", "10"
),
"SONARR_URL": _env_value("SONARR_URL", None),
"SONARR_API_KEY": _env_value("SONARR_API_KEY", None),
"SONARR_DEFAULT_ROOT_FOLDER": _env_value("SONARR_DEFAULT_ROOT_FOLDER", None),
"SONARR_DEFAULT_QUALITY_PROFILE": _env_value("SONARR_DEFAULT_QUALITY_PROFILE", None),
"SONARR_SEARCH_ON_ADD": _env_bool("SONARR_SEARCH_ON_ADD", True),
"RADARR_URL": _env_value("RADARR_URL", None),
"RADARR_API_KEY": _env_value("RADARR_API_KEY", None),
"RADARR_DEFAULT_ROOT_FOLDER": _env_value("RADARR_DEFAULT_ROOT_FOLDER", None),
"RADARR_DEFAULT_QUALITY_PROFILE": _env_value("RADARR_DEFAULT_QUALITY_PROFILE", None),
"RADARR_DEFAULT_MINIMUM_AVAILABILITY": _env_value("RADARR_DEFAULT_MINIMUM_AVAILABILITY", "released"),
"RADARR_SEARCH_ON_ADD": _env_bool("RADARR_SEARCH_ON_ADD", True),
"RADARR_VERIFY_TLS": _env_bool("RADARR_VERIFY_TLS", self.verify_tls),
"RADARR_TIMEOUT_SECONDS": _env_value("RADARR_TIMEOUT_SECONDS", "10"),
"PROWLARR_URL": _env_value("PROWLARR_URL", None),
"PROWLARR_API_KEY": _env_value("PROWLARR_API_KEY", None),
"PROWLARR_VERIFY_TLS": _env_bool("PROWLARR_VERIFY_TLS", self.verify_tls),
"PROWLARR_TIMEOUT_SECONDS": _env_value("PROWLARR_TIMEOUT_SECONDS", "10"),
"DELUGE_URL": _env_value("DELUGE_URL", None),
"DELUGE_PASSWORD": _env_value("DELUGE_PASSWORD", None),
"DELUGE_VERIFY_TLS": _env_bool("DELUGE_VERIFY_TLS", self.verify_tls),
"DELUGE_TIMEOUT_SECONDS": _env_value("DELUGE_TIMEOUT_SECONDS", "10"),
"DELUGE_DEFAULT_HOST_ID": _env_value("DELUGE_DEFAULT_HOST_ID", None),
"DELUGE_WEB_PATH": _env_value("DELUGE_WEB_PATH", "/json"),
"PLEX_URL": _env_value("PLEX_URL", None),
"PLEX_TOKEN": _env_value("PLEX_TOKEN", None),
"PLEX_VERIFY_TLS": _env_bool("PLEX_VERIFY_TLS", self.verify_tls),
"PLEX_TIMEOUT_SECONDS": _env_value("PLEX_TIMEOUT_SECONDS", "10"),
"PLEX_DEFAULT_LIBRARY_ID": _env_value("PLEX_DEFAULT_LIBRARY_ID", None),
"PLEX_MACHINE_IDENTIFIER": _env_value("PLEX_MACHINE_IDENTIFIER", None),
"TAUTULLI_URL": _env_value("TAUTULLI_URL", None),
"TAUTULLI_API_KEY": _env_value("TAUTULLI_API_KEY", None),
"TAUTULLI_VERIFY_TLS": _env_bool("TAUTULLI_VERIFY_TLS", self.verify_tls),
"TAUTULLI_TIMEOUT_SECONDS": _env_value("TAUTULLI_TIMEOUT_SECONDS", "10"),
"NPM_URL": _env_value("NPM_URL", None),
"NPM_IDENTITY": _env_value("NPM_IDENTITY", None),
"NPM_SECRET": _env_value("NPM_SECRET", None),
"NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", self.verify_tls),
"NPM_TIMEOUT_SECONDS": _env_value("NPM_TIMEOUT_SECONDS", "10"),
"NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None),
"AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None),
"AUTHENTIK_TOKEN": _env_value("AUTHENTIK_TOKEN", None),
"AUTHENTIK_VERIFY_TLS": _env_bool("AUTHENTIK_VERIFY_TLS", self.verify_tls),
"AUTHENTIK_TIMEOUT_SECONDS": _env_value("AUTHENTIK_TIMEOUT_SECONDS", "10"),
"AUTHENTIK_OUTPOST_URL": _env_value("AUTHENTIK_OUTPOST_URL", None),
"AUTHENTIK_EXPECT_EMBEDDED_OUTPOST": _env_bool("AUTHENTIK_EXPECT_EMBEDDED_OUTPOST", True),
# OIDC provisioning never guesses tenant-wide flow/mapping IDs.
# Operators bind these once to existing, reviewed Authentik objects.
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None),
"AUTHENTIK_OIDC_INVALIDATION_FLOW": _env_value("AUTHENTIK_OIDC_INVALIDATION_FLOW", None),
"AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING", None),
# The reviewed group mapping must emit this exact claim. The
# enrollment schema only accepts its bounded role contract.
"AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None),
"JELLYFIN_URL": _env_value("JELLYFIN_URL", None),
"JELLYFIN_API_KEY": _env_value("JELLYFIN_API_KEY", None),
"WIDEFROG_URL": _env_value("WIDEFROG_URL", None),
"WIDEFROG_API_KEY": _env_value("WIDEFROG_API_KEY", None),
"WIDEFROG_VERIFY_TLS": _env_bool("WIDEFROG_VERIFY_TLS", self.verify_tls),
"WIDEFROG_TIMEOUT_SECONDS": _env_value("WIDEFROG_TIMEOUT_SECONDS", "10"),
"HOME_ASSISTANT_URL": _env_value("HOME_ASSISTANT_URL", None),
"HOME_ASSISTANT_TOKEN": _env_value("HOME_ASSISTANT_TOKEN", None),
"HOME_ASSISTANT_VERIFY_TLS": _env_bool("HOME_ASSISTANT_VERIFY_TLS", self.verify_tls),
"HOME_ASSISTANT_TIMEOUT_SECONDS": _env_value("HOME_ASSISTANT_TIMEOUT_SECONDS", "10"),
"HOME_ASSISTANT_NOTIFY_DOMAIN": _env_value("HOME_ASSISTANT_NOTIFY_DOMAIN", "notify"),
"HOME_ASSISTANT_NOTIFY_SERVICE": _env_value("HOME_ASSISTANT_NOTIFY_SERVICE", "notify"),
"HOME_ASSISTANT_NOTIFY_TARGET": _env_value("HOME_ASSISTANT_NOTIFY_TARGET", None),
"HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX": _env_value("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX", "AppOps"),
"HOME_ASSISTANT_DRY_RUN": _env_bool("HOME_ASSISTANT_DRY_RUN", False),
"APPOPS_VERIFY_TLS": self.verify_tls,
}
def redact_env_preview(self) -> dict[str, Any]:
return {
"APPOPS_BIND_HOST": self.bind_host,
"APPOPS_PORT": self.port,
"APPOPS_DB_PATH": self.db_path,
"APPOPS_DATA_DIR": self.data_dir,
"APPOPS_ENV": self.environment,
"APPOPS_LOG_LEVEL": self.log_level,
"APPOPS_STATIC_DIR": self.static_dir,
"APPOPS_FRONTEND_ORIGIN": self.frontend_origin,
"voice_token": bool(self.voice_token),
"operator_token": bool(self.operator_token),
"APPOPS_OPERATOR_TOKEN_FILE": bool(self.operator_token_file),
"APPOPS_REQUIRE_OPERATOR_AUTH": self.operator_auth_required,
"APPOPS_OPERATOR_PROXY_AUTH": self.operator_proxy_auth_enabled,
"APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS_CONFIGURED": bool(self.operator_proxy_trusted_networks),
"APPOPS_OPERATOR_PROXY_USER_COUNT": len(self.operator_proxy_users or []),
"APPOPS_OPERATOR_PROXY_GROUP_COUNT": len(self.operator_proxy_groups or []),
"APPOPS_DEPLOYMENT_MODE": self.deployment_mode,
"APPOPS_BACKUP_RETENTION": self.backup_retention,
"APPOPS_ENABLE_MCP": self.mcp_enabled,
"APPOPS_MCP_TOKEN_FILE": bool(self.mcp_token_file),
"APPOPS_MCP_TOKEN_CONFIGURED": bool(self.mcp_token),
"APPOPS_MCP_ALLOWED_HOSTS": list(self.mcp_allowed_hosts or []),
"APPOPS_ENABLE_N8N": self.n8n_enabled,
"APPOPS_N8N_TOKEN_FILE": bool(self.n8n_token_file),
"APPOPS_N8N_TOKEN_CONFIGURED": bool(self.n8n_token),
"APPOPS_VERIFY_TLS": self.verify_tls,
"APPOPS_SECRET_KEY": bool(self.secret_key),
"APPOPS_SECRET_KEY_FILE": bool(self.secret_key_file),
"APPOPS_SECRET_KEY_SOURCE": self.secret_key_source,
"APPOPS_SECRET_ROTATION_ENABLED": self.secret_rotation_enabled,
"PORTAINER_URL": bool(self.apis["PORTAINER_URL"]),
"PORTAINER_VERIFY_TLS": bool(self.apis["PORTAINER_VERIFY_TLS"]),
"UNRAID_URL": bool(self.apis["UNRAID_URL"]),
"UNRAID_VERIFY_TLS": bool(self.apis["UNRAID_VERIFY_TLS"]),
"GITEA_URL": bool(self.apis["GITEA_URL"]),
"GITEA_ADMIN_TOKEN_CONFIGURED": bool(self.apis["GITEA_ADMIN_TOKEN"]),
"GITEA_VERIFY_TLS": bool(self.apis["GITEA_VERIFY_TLS"]),
"DEPLOYMENT_BROKER_URL": bool(self.apis["DEPLOYMENT_BROKER_URL"]),
"DEPLOYMENT_BROKER_VERIFY_TLS": bool(self.apis["DEPLOYMENT_BROKER_VERIFY_TLS"]),
"DEPLOYMENT_BROKER_CA_FILE": bool(self.apis["DEPLOYMENT_BROKER_CA_FILE"]),
"DEPLOYMENT_BROKER_TIMEOUT_SECONDS": bool(self.apis["DEPLOYMENT_BROKER_TIMEOUT_SECONDS"]),
"SONARR_URL": bool(self.apis["SONARR_URL"]),
"SONARR_DEFAULT_ROOT_FOLDER": bool(self.apis["SONARR_DEFAULT_ROOT_FOLDER"]),
"SONARR_DEFAULT_QUALITY_PROFILE": bool(self.apis["SONARR_DEFAULT_QUALITY_PROFILE"]),
"SONARR_SEARCH_ON_ADD": bool(self.apis["SONARR_SEARCH_ON_ADD"]),
"RADARR_URL": bool(self.apis["RADARR_URL"]),
"RADARR_DEFAULT_ROOT_FOLDER": bool(self.apis["RADARR_DEFAULT_ROOT_FOLDER"]),
"RADARR_DEFAULT_QUALITY_PROFILE": bool(self.apis["RADARR_DEFAULT_QUALITY_PROFILE"]),
"RADARR_DEFAULT_MINIMUM_AVAILABILITY": bool(self.apis["RADARR_DEFAULT_MINIMUM_AVAILABILITY"]),
"RADARR_SEARCH_ON_ADD": bool(self.apis["RADARR_SEARCH_ON_ADD"]),
"RADARR_VERIFY_TLS": bool(self.apis["RADARR_VERIFY_TLS"]),
"RADARR_TIMEOUT_SECONDS": bool(self.apis["RADARR_TIMEOUT_SECONDS"]),
"PROWLARR_URL": bool(self.apis["PROWLARR_URL"]),
"PROWLARR_VERIFY_TLS": bool(self.apis["PROWLARR_VERIFY_TLS"]),
"PROWLARR_TIMEOUT_SECONDS": bool(self.apis["PROWLARR_TIMEOUT_SECONDS"]),
"DELUGE_URL": bool(self.apis["DELUGE_URL"]),
"DELUGE_VERIFY_TLS": bool(self.apis["DELUGE_VERIFY_TLS"]),
"DELUGE_TIMEOUT_SECONDS": bool(self.apis["DELUGE_TIMEOUT_SECONDS"]),
"DELUGE_DEFAULT_HOST_ID": bool(self.apis["DELUGE_DEFAULT_HOST_ID"]),
"DELUGE_WEB_PATH": bool(self.apis["DELUGE_WEB_PATH"]),
"PLEX_URL": bool(self.apis["PLEX_URL"]),
"PLEX_VERIFY_TLS": bool(self.apis["PLEX_VERIFY_TLS"]),
"PLEX_TIMEOUT_SECONDS": bool(self.apis["PLEX_TIMEOUT_SECONDS"]),
"PLEX_DEFAULT_LIBRARY_ID": bool(self.apis["PLEX_DEFAULT_LIBRARY_ID"]),
"PLEX_MACHINE_IDENTIFIER": bool(self.apis["PLEX_MACHINE_IDENTIFIER"]),
"TAUTULLI_URL": bool(self.apis["TAUTULLI_URL"]),
"TAUTULLI_VERIFY_TLS": bool(self.apis["TAUTULLI_VERIFY_TLS"]),
"TAUTULLI_TIMEOUT_SECONDS": bool(self.apis["TAUTULLI_TIMEOUT_SECONDS"]),
"NPM_URL": bool(self.apis["NPM_URL"]),
"NPM_VERIFY_TLS": bool(self.apis["NPM_VERIFY_TLS"]),
"NPM_TIMEOUT_SECONDS": bool(self.apis["NPM_TIMEOUT_SECONDS"]),
"NPM_LETSENCRYPT_EMAIL": bool(self.apis["NPM_LETSENCRYPT_EMAIL"]),
"AUTHENTIK_URL": bool(self.apis["AUTHENTIK_URL"]),
"AUTHENTIK_VERIFY_TLS": bool(self.apis["AUTHENTIK_VERIFY_TLS"]),
"AUTHENTIK_TIMEOUT_SECONDS": bool(self.apis["AUTHENTIK_TIMEOUT_SECONDS"]),
"AUTHENTIK_OUTPOST_URL": bool(self.apis["AUTHENTIK_OUTPOST_URL"]),
"AUTHENTIK_EXPECT_EMBEDDED_OUTPOST": bool(self.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"]),
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"]),
"AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"]),
"AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING"]),
"AUTHENTIK_OIDC_ROLE_CLAIM": bool(self.apis["AUTHENTIK_OIDC_ROLE_CLAIM"]),
"JELLYFIN_URL": bool(self.apis["JELLYFIN_URL"]),
"WIDEFROG_URL": bool(self.apis["WIDEFROG_URL"]),
"WIDEFROG_VERIFY_TLS": bool(self.apis["WIDEFROG_VERIFY_TLS"]),
"WIDEFROG_TIMEOUT_SECONDS": bool(self.apis["WIDEFROG_TIMEOUT_SECONDS"]),
"HOME_ASSISTANT_URL": bool(self.apis["HOME_ASSISTANT_URL"]),
"HOME_ASSISTANT_VERIFY_TLS": bool(self.apis["HOME_ASSISTANT_VERIFY_TLS"]),
"HOME_ASSISTANT_TIMEOUT_SECONDS": bool(self.apis["HOME_ASSISTANT_TIMEOUT_SECONDS"]),
"HOME_ASSISTANT_NOTIFY_DOMAIN": bool(self.apis["HOME_ASSISTANT_NOTIFY_DOMAIN"]),
"HOME_ASSISTANT_NOTIFY_SERVICE": bool(self.apis["HOME_ASSISTANT_NOTIFY_SERVICE"]),
"HOME_ASSISTANT_NOTIFY_TARGET": bool(self.apis["HOME_ASSISTANT_NOTIFY_TARGET"]),
"HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX": bool(self.apis["HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX"]),
"HOME_ASSISTANT_DRY_RUN": bool(self.apis["HOME_ASSISTANT_DRY_RUN"]),
}
settings = Settings()
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlsplit, urlunsplit
class UnsafeConnectorUrl(ValueError):
pass
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain", "metadata.google.internal"}
_BLOCKED_IPS = {
ipaddress.ip_address("169.254.169.254"),
ipaddress.ip_address("100.100.100.200"),
}
def _address_is_forbidden(address: ipaddress.IPv4Address | ipaddress.IPv6Address, *, allow_loopback: bool) -> bool:
return (
address in _BLOCKED_IPS
or address.is_link_local
or address.is_multicast
or address.is_unspecified
or (address.is_loopback and not allow_loopback)
)
def validate_connector_url(value: str, *, allow_loopback: bool = False) -> str:
"""Validate a configured LAN connector URL and return a normalized value."""
raw = str(value or "").strip()
if not raw:
return ""
parsed = urlsplit(raw)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
raise UnsafeConnectorUrl("connector_url_requires_http_or_https")
if parsed.username or parsed.password or parsed.fragment:
raise UnsafeConnectorUrl("connector_url_contains_forbidden_components")
try:
port = parsed.port
except ValueError as exc:
raise UnsafeConnectorUrl("connector_url_has_invalid_port") from exc
if port is not None and not 1 <= port <= 65535:
raise UnsafeConnectorUrl("connector_url_has_invalid_port")
hostname = parsed.hostname.rstrip(".").lower()
if hostname in _BLOCKED_HOSTS and not allow_loopback:
raise UnsafeConnectorUrl("connector_url_loopback_blocked")
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None:
if _address_is_forbidden(address, allow_loopback=allow_loopback):
raise UnsafeConnectorUrl("connector_url_reserved_address_blocked")
else:
# Inspect currently resolvable names as well as literal IPs. Unresolved LAN
# names remain configurable, but DNS names resolving to local-only system
# addresses cannot bypass the literal-address checks.
try:
resolved = {item[4][0] for item in socket.getaddrinfo(hostname, port or 0, type=socket.SOCK_STREAM)}
except socket.gaierror:
resolved = set()
for resolved_value in resolved:
resolved_address = ipaddress.ip_address(resolved_value)
if _address_is_forbidden(resolved_address, allow_loopback=allow_loopback):
raise UnsafeConnectorUrl("connector_url_resolves_to_reserved_address")
normalized_path = parsed.path.rstrip("/")
return urlunsplit((parsed.scheme.lower(), parsed.netloc, normalized_path, parsed.query, ""))
+34
View File
@@ -0,0 +1,34 @@
import re
from typing import Any
REDACTION_PATTERNS = [
re.compile(r"(Bearer\s+)([A-Za-z0-9._~+/=-]{10,})", re.IGNORECASE),
re.compile(r"(api[-_]?(?:key|token)\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
re.compile(r"(apiKey\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
re.compile(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
re.compile(r"(token\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
re.compile(r"(password\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
re.compile(r"(access[_-]?token\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
]
_SENSITIVE_KEY_PARTS = ("api_key", "apikey", "token", "password", "secret", "authorization", "cookie")
def redact_message(message: str) -> str:
redacted = message
for pattern in REDACTION_PATTERNS:
redacted = pattern.sub(r"\1[REDACTED]", redacted)
return redacted
def redact_value(value: Any, key: str = "") -> Any:
"""Recursively redact values crossing log, AI, MCP or automation boundaries."""
if any(part in key.casefold() for part in _SENSITIVE_KEY_PARTS):
return "[REDACTED]"
if isinstance(value, dict):
return {str(nested_key): redact_value(nested, str(nested_key)) for nested_key, nested in value.items()}
if isinstance(value, (list, tuple, set)):
return [redact_value(item) for item in value]
if isinstance(value, str):
return redact_message(value)
return value
+255
View File
@@ -0,0 +1,255 @@
import hashlib
import hmac
import os
import re
import secrets
import threading
from ipaddress import ip_address, ip_network
from time import monotonic
from fastapi import Header, HTTPException, Request
from .config import settings
OPERATOR_SESSION_COOKIE = "appops_operator_session"
OPERATOR_SESSION_CSRF_HEADER = "X-AppOps-Session-CSRF"
_OPERATOR_SESSION_TTL_SECONDS = 8 * 60 * 60
_operator_sessions: dict[str, tuple[float, str]] = {}
_operator_sessions_lock = threading.Lock()
def _operator_token() -> str | None:
expected = settings.operator_token or os.getenv("APPOPS_OPERATOR_TOKEN")
token_file = settings.operator_token_file or os.getenv("APPOPS_OPERATOR_TOKEN_FILE")
if expected or not token_file:
return expected
try:
with open(token_file, encoding="utf-8") as handle:
return handle.read().strip() or None
except OSError:
return None
def token_is_strong(value: str | None, *, minimum_length: int = 24) -> bool:
if not value or len(value) < minimum_length:
return False
return value.strip().lower() not in {"change-me", "changeme", "dev", "test", "dev-voice-token"}
def _extract_bearer_token(authorization: str | None) -> str:
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header missing")
prefix = "Bearer "
if not authorization.startswith(prefix):
raise HTTPException(status_code=401, detail="Authorization header missing")
return authorization[len(prefix):]
def require_auth_token(provided_token: str | None, expected_token: str | None, scope: str):
if expected_token:
token = _extract_bearer_token(provided_token)
if not hmac.compare_digest(token, expected_token):
raise HTTPException(status_code=403, detail=f"Invalid {scope} token")
def _operator_token_from_request(request: Request) -> str | None:
explicit = request.headers.get("X-AppOps-Operator-Token")
if explicit:
return explicit.strip()
authorization = request.headers.get("Authorization")
if authorization and authorization.startswith("Bearer "):
return authorization[len("Bearer "):].strip()
return None
def _operator_token_fingerprint(value: str | None) -> str:
return hashlib.sha256((value or "").encode("utf-8")).hexdigest()
def _remove_expired_operator_sessions(now: float) -> None:
expired = [session_id for session_id, (expires_at, _) in _operator_sessions.items() if expires_at <= now]
for session_id in expired:
_operator_sessions.pop(session_id, None)
def issue_operator_session(request: Request) -> str:
"""Exchange the explicit operator token for an opaque, process-local browser session."""
expected = _operator_token()
provided = _operator_token_from_request(request)
auth_required = settings.operator_auth_required
if not auth_required:
raise HTTPException(status_code=409, detail="Operator sessions are disabled")
if not provided:
raise HTTPException(status_code=401, detail="Operator token missing")
if not expected:
if auth_required:
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
elif settings.environment == "production" and not token_is_strong(expected):
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
elif not hmac.compare_digest(provided, expected):
raise HTTPException(status_code=403, detail="Invalid operator token")
session_id = secrets.token_urlsafe(32)
now = monotonic()
with _operator_sessions_lock:
_remove_expired_operator_sessions(now)
_operator_sessions[session_id] = (
now + _OPERATOR_SESSION_TTL_SECONDS,
_operator_token_fingerprint(expected),
)
return session_id
def revoke_operator_session(request: Request) -> None:
session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "")
if not session_id:
return
with _operator_sessions_lock:
_operator_sessions.pop(session_id, None)
def _operator_session_identity(request: Request, expected: str | None) -> str | None:
session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "")
if not session_id:
return None
now = monotonic()
with _operator_sessions_lock:
_remove_expired_operator_sessions(now)
record = _operator_sessions.get(session_id)
if record and not hmac.compare_digest(record[1], _operator_token_fingerprint(expected)):
_operator_sessions.pop(session_id, None)
record = None
if not record:
return None
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
csrf = request.headers.get(OPERATOR_SESSION_CSRF_HEADER, "")
if not hmac.compare_digest(csrf, "1"):
raise HTTPException(status_code=403, detail="Operator session CSRF validation failed")
return "session"
def _normalized_set(values: list[str] | None) -> set[str]:
return {value.strip().casefold() for value in values or [] if value.strip()}
def _request_from_trusted_operator_proxy(request: Request) -> bool:
if not settings.operator_proxy_auth_enabled or not settings.operator_proxy_trusted_networks:
return False
client_host = request.client.host if request.client else ""
try:
client_ip = ip_address(client_host)
except ValueError:
return False
for configured_network in settings.operator_proxy_trusted_networks:
try:
if client_ip in ip_network(configured_network, strict=False):
return True
except ValueError:
continue
return False
def _trusted_proxy_operator_identity(request: Request) -> str | None:
"""Resolve an Authentik identity only across an explicitly trusted proxy boundary."""
if not _request_from_trusted_operator_proxy(request):
return None
allowed_users = _normalized_set(settings.operator_proxy_users)
allowed_groups = _normalized_set(settings.operator_proxy_groups)
if not allowed_users and not allowed_groups:
return None
username = request.headers.get("X-authentik-username", "").strip()
groups_header = request.headers.get("X-authentik-groups", "")
groups = {
item.strip().casefold()
for item in re.split(r"[|,;]", groups_header)
if item.strip()
}
if username and (username.casefold() in allowed_users or groups.intersection(allowed_groups)):
return username
return None
def require_operator_request(request: Request) -> str:
"""Validate the local operator boundary without leaking token material."""
expected = _operator_token()
auth_required = settings.operator_auth_required
if not auth_required:
return "private_unlocked"
proxy_identity = _trusted_proxy_operator_identity(request)
if proxy_identity:
return f"authentik:{proxy_identity}"
provided = _operator_token_from_request(request)
if provided:
if not expected:
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
if settings.environment == "production" and not token_is_strong(expected):
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
if not hmac.compare_digest(provided, expected):
raise HTTPException(status_code=403, detail="Invalid operator token")
return "operator"
session_identity = _operator_session_identity(request, expected)
if session_identity:
return session_identity
if not expected:
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
if settings.environment == "production" and not token_is_strong(expected):
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
raise HTTPException(status_code=401, detail="Operator authorization missing")
def operator_session_status(request: Request) -> dict[str, str | bool | None]:
"""Return the current request's operator state without exposing credentials."""
try:
identity = require_operator_request(request)
except HTTPException:
return {"authorized": False, "mode": "locked", "identity": None}
if identity.startswith("authentik:"):
return {"authorized": True, "mode": "account", "identity": identity.split(":", 1)[1]}
if identity == "session":
return {"authorized": True, "mode": "session", "identity": None}
return {"authorized": True, "mode": "token" if identity == "operator" else "private", "identity": None}
def require_service_request(request: Request, service: str) -> str:
"""Authenticate a narrowly scoped machine integration without reusing operator credentials."""
if service == "mcp":
enabled, expected = settings.mcp_enabled, settings.mcp_token
elif service == "n8n":
enabled, expected = settings.n8n_enabled, settings.n8n_token
else:
raise HTTPException(status_code=403, detail="Unknown service identity")
if not enabled:
raise HTTPException(status_code=404, detail=f"{service} integration is disabled")
if not token_is_strong(expected):
raise HTTPException(status_code=503, detail=f"{service} authentication is not safely configured")
provided = _operator_token_from_request(request)
if not provided:
raise HTTPException(status_code=401, detail=f"{service} authorization missing")
if not hmac.compare_digest(provided, expected):
raise HTTPException(status_code=403, detail=f"Invalid {service} token")
return service
def n8n_auth(request: Request) -> str:
return require_service_request(request, "n8n")
def operator_auth(request: Request) -> str:
return require_operator_request(request)
def voice_auth(authorization: str | None = Header(default=None, alias='Authorization')):
expected = settings.voice_token or os.getenv("APP_OPS_VOICE_TOKEN")
if not expected:
return True
require_auth_token(authorization, expected, "voice")
return True
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
from datetime import UTC, datetime
def utc_now() -> datetime:
"""Return current UTC as a naive datetime for existing SQLite columns."""
return datetime.now(UTC).replace(tzinfo=None)
+1
View File
@@ -0,0 +1 @@
APP_VERSION = "1.8.0"
+944
View File
@@ -0,0 +1,944 @@
from datetime import UTC, datetime
from sqlalchemy import inspect, text
from sqlmodel import SQLModel, select
from . import session as db_session
from .session import get_session
from ..core.config import Settings
from ..services.app_classifier import classify_container
from ..services.app_registry import category_label
from .models import App, AppContainerMapping, Connector, DiscoveredContainer, NotificationChannel, NotificationEvent, NotificationRule, Policy, WorkMeshPromotion
settings = Settings()
def init_db() -> None:
SQLModel.metadata.create_all(db_session.engine)
_apply_lightweight_schema_upgrades()
with next(get_session()) as session:
changed = False
for app in _seed_apps():
if session.get(App, app.id) is None:
session.add(app)
changed = True
for connector in _seed_connectors():
if session.get(Connector, connector.id) is None:
session.add(connector)
changed = True
for policy in _seed_policies():
if session.get(Policy, policy.id) is None:
session.add(policy)
changed = True
for channel in _seed_notification_channels():
if session.get(NotificationChannel, channel.id) is None:
session.add(channel)
changed = True
for rule in _seed_notification_rules():
if session.get(NotificationRule, rule.id) is None:
session.add(rule)
changed = True
changed = _upgrade_unknown_app_classifications(session) or changed
changed = _upgrade_default_notification_rule_channels(session) or changed
changed = _upgrade_duplicate_notification_events(session) or changed
changed = _upgrade_suppressed_notification_events(session) or changed
if changed:
session.commit()
from ..services.setup_service import apply_stored_connector_settings
from ..services.runtime_service import apply_stored_operator_auth_setting
apply_stored_connector_settings(session)
apply_stored_operator_auth_setting(session)
_ensure_notification_suppression_index()
def _apply_lightweight_schema_upgrades() -> None:
inspector = inspect(db_session.engine)
table_names = set(inspector.get_table_names())
with db_session.engine.begin() as connection:
if 'notification_channels' in table_names:
columns = {column['name'] for column in inspector.get_columns('notification_channels')}
if 'last_test_at' not in columns:
connection.execute(text('ALTER TABLE notification_channels ADD COLUMN last_test_at DATETIME'))
if 'approval' in table_names:
columns = {column['name'] for column in inspector.get_columns('approval')}
approval_columns = {
'decided_at': 'DATETIME',
'executed_at': 'DATETIME',
'dismissed_at': 'DATETIME',
'job_id': 'VARCHAR',
'audit_event_id': 'VARCHAR',
'approved_by': 'VARCHAR',
'executed_by': 'VARCHAR',
'action_fingerprint': 'VARCHAR',
'request_context_json': "JSON DEFAULT '{}'",
}
for name, sql_type in approval_columns.items():
if name not in columns:
connection.execute(text(f'ALTER TABLE approval ADD COLUMN {name} {sql_type}'))
if 'policy' in table_names:
policy_columns = {column['name'] for column in inspector.get_columns('policy')}
if 'provenance_json' not in policy_columns:
connection.execute(text("ALTER TABLE policy ADD COLUMN provenance_json JSON DEFAULT '{}'"))
if 'discovery_runs' in table_names:
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovery_runs_started_at ON discovery_runs (started_at)'))
if 'discovered_containers' in table_names:
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_discovery_run_id ON discovered_containers (discovery_run_id)'))
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_container_id ON discovered_containers (container_id)'))
if 'app_container_mappings' in table_names:
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_app_id ON app_container_mappings (app_id)'))
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_container_id ON app_container_mappings (container_id)'))
def _upgrade_unknown_app_classifications(session) -> bool:
changed = False
risk_rank = {'low': 0, 'medium': 1, 'high': 2, 'critical': 3}
unknown_apps = [
app
for app in session.exec(select(App)).all()
if str(app.category or '').strip().lower() in {'', 'unknown', 'generic'}
]
if not unknown_apps:
return False
mappings_by_app: dict[str, list[AppContainerMapping]] = {}
for mapping in session.exec(select(AppContainerMapping)).all():
mappings_by_app.setdefault(mapping.app_id, []).append(mapping)
discovered_by_container: dict[str, list[DiscoveredContainer]] = {}
for item in session.exec(select(DiscoveredContainer)).all():
discovered_by_container.setdefault(item.container_id, []).append(item)
for app in unknown_apps:
mappings = mappings_by_app.get(app.id, [])
evidence = [
item
for mapping in mappings
for item in discovered_by_container.get(mapping.container_id, [])
]
classification = classify_container({
'name': ' '.join([app.id, app.name, *(mapping.container_name for mapping in mappings)]),
'image': ' '.join(item.image for item in evidence),
'ports': [port for item in evidence for port in (item.ports_json or [])],
})
if classification.get('app_type') == 'unknown' or float(classification.get('confidence', 0)) < 0.8:
continue
app.category = category_label(str(classification['category']))
suggested_criticality = str(classification.get('criticality') or 'low')
if risk_rank.get(suggested_criticality, 0) > risk_rank.get(app.criticality, 0):
app.criticality = suggested_criticality
app.protected = app.protected or bool(classification.get('protected'))
session.add(app)
changed = True
return changed
def _upgrade_duplicate_notification_events(session) -> bool:
rules = {rule.id: rule for rule in session.exec(select(NotificationRule)).all()}
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.status != 'dismissed')
.order_by(NotificationEvent.created_at.desc())
).all()
grouped: dict[tuple[str, ...], list[NotificationEvent]] = {}
for row in rows:
key = (row.source, row.event_type, row.dedupe_key, row.title, row.message)
grouped.setdefault(key, []).append(row)
changed = False
severity_rank = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4}
def specificity(row: NotificationEvent) -> tuple[int, object]:
rule = rules.get(row.rule_id)
if not rule:
return 0, row.created_at
conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {}
constrained = sum((bool(rule.categories_json), bool(rule.targets_json), bool(conditions)))
return constrained * 100 + severity_rank.get(rule.severity_min, 0), row.created_at
specific_media_rows = [
row for row in rows
if row.rule_id in {'notify-deluge-stalled-downloads', 'notify-widefrog-failed-jobs'}
]
for row in rows:
if row.rule_id != 'notify-media-pipeline-issue':
continue
message = str(row.message or '').lower()
expected_rule = (
'notify-deluge-stalled-downloads'
if 'deluge' in message and 'stalled' in message
else 'notify-widefrog-failed-jobs'
if 'widefrog' in message and 'fail' in message
else None
)
if not expected_rule:
continue
overlap = next((
specific for specific in specific_media_rows
if specific.rule_id == expected_rule
and abs((specific.created_at - row.created_at).total_seconds()) <= 5
), None)
if not overlap:
continue
row.status = 'dismissed'
row.reason = 'superseded_by_specific_rule'
row.read = True
session.add(row)
changed = True
for duplicates in grouped.values():
if len(duplicates) < 2:
continue
timestamps = [row.created_at for row in duplicates]
if (max(timestamps) - min(timestamps)).total_seconds() > 5:
continue
keep = max(duplicates, key=specificity)
for row in duplicates:
if row.id == keep.id:
continue
row.status = 'dismissed'
row.reason = 'superseded_by_specific_rule'
row.read = True
session.add(row)
changed = True
return changed
def _upgrade_suppressed_notification_events(session) -> bool:
"""Collapse legacy scheduler spam while retaining one read suppression record per identity."""
def suppression_count(row: NotificationEvent) -> int:
payload = row.payload_json if isinstance(row.payload_json, dict) else {}
try:
return max(1, int(payload.get('suppressionCount') or 1))
except (TypeError, ValueError):
return 1
def suppression_timestamp(row: NotificationEvent, key: str) -> datetime:
payload = row.payload_json if isinstance(row.payload_json, dict) else {}
raw_value = payload.get(key)
if isinstance(raw_value, str):
try:
parsed = datetime.fromisoformat(raw_value.replace('Z', '+00:00'))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
except ValueError:
pass
return row.created_at
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.status == 'suppressed')
.order_by(NotificationEvent.created_at.desc())
).all()
grouped: dict[tuple[str, str, str], list[NotificationEvent]] = {}
changed = False
for row in rows:
if not row.read:
row.read = True
session.add(row)
changed = True
if row.reason not in {'cooldown', 'quiet_hours'}:
continue
key = (row.rule_id, row.dedupe_key, row.reason or 'unspecified')
grouped.setdefault(key, []).append(row)
for duplicates in grouped.values():
keeper = duplicates[0]
payload = keeper.payload_json if isinstance(keeper.payload_json, dict) else {}
total = sum(suppression_count(row) for row in duplicates)
first_suppressed = min(suppression_timestamp(row, 'firstSuppressedAt') for row in duplicates)
last_suppressed = max(suppression_timestamp(row, 'lastSuppressedAt') for row in duplicates)
normalized_payload = {
**payload,
'suppressionCount': total,
'firstSuppressedAt': first_suppressed.isoformat(),
'lastSuppressedAt': last_suppressed.isoformat(),
}
if not keeper.read or keeper.payload_json != normalized_payload:
keeper.read = True
keeper.payload_json = normalized_payload
session.add(keeper)
changed = True
for duplicate in duplicates[1:]:
session.delete(duplicate)
changed = True
return changed
def _ensure_notification_suppression_index() -> None:
"""Enforce one live routine-suppression row per rule identity after legacy compaction."""
with db_session.engine.begin() as connection:
connection.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_notification_routine_suppression
ON notification_events (rule_id, dedupe_key, reason)
WHERE status = 'suppressed' AND reason IN ('cooldown', 'quiet_hours')
"""))
def _seed_apps() -> list[App]:
return [
App(
id="widefrog",
name="Widefrog",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="sonarr",
name="Sonarr",
category="Media",
status="healthy",
criticality="low",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="radarr",
name="Radarr",
category="Media",
status="healthy",
criticality="low",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="prowlarr",
name="Prowlarr",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="deluge",
name="Deluge",
category="Downloads",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="plex",
name="Plex",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="tautulli",
name="Tautulli",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="jellyfin",
name="Jellyfin",
category="Media",
status="healthy",
criticality="low",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="gitea",
name="Gitea",
category="Dev",
status="healthy",
criticality="medium",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="projectbrain",
name="ProjectBrain",
category="Dev",
status="healthy",
criticality="medium",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="psforge",
name="PSForge",
category="Dev",
status="healthy",
criticality="low",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="spoortracker",
name="SpoorTracker",
category="Monitoring",
status="healthy",
criticality="low",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="authentik",
name="Authentik",
category="Auth",
status="healthy",
criticality="critical",
connector_status="connected" if (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="nginx_proxy_manager",
name="Nginx Proxy Manager",
category="Network",
status="healthy",
criticality="critical",
connector_status="connected" if (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="zabbix",
name="Zabbix",
category="Monitoring",
status="healthy",
criticality="high",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="home_assistant",
name="Home Assistant",
category="Infrastructure",
status="healthy",
criticality="medium",
connector_status="connected" if settings.apis.get("HOME_ASSISTANT_URL") else "disconnected",
container_status="running",
voice_enabled=True,
),
App(
id="portainer",
name="Portainer",
category="Infrastructure",
status="healthy",
criticality="high",
connector_status="connected" if settings.apis.get("PORTAINER_URL") else "disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="postgres",
name="PostgreSQL",
category="Database",
status="healthy",
criticality="critical",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="redis",
name="Redis",
category="Database",
status="healthy",
criticality="high",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
]
def _seed_notification_channels() -> list[NotificationChannel]:
return [
NotificationChannel(
id="dashboard",
type="dashboard",
name="Dashboard",
enabled=True,
config_json={"delivery": "in_app"},
secret_refs_json=[],
),
NotificationChannel(
id="home_assistant",
type="home_assistant",
name="Home Assistant",
enabled=False,
config_json={
"url": settings.apis.get("HOME_ASSISTANT_URL"),
"notifyDomain": settings.apis.get("HOME_ASSISTANT_NOTIFY_DOMAIN") or "notify",
"notifyService": settings.apis.get("HOME_ASSISTANT_NOTIFY_SERVICE") or "notify",
"target": settings.apis.get("HOME_ASSISTANT_NOTIFY_TARGET"),
"verifyTls": bool(settings.apis.get("HOME_ASSISTANT_VERIFY_TLS")),
"timeoutSeconds": int(settings.apis.get("HOME_ASSISTANT_TIMEOUT_SECONDS") or 10),
"titlePrefix": settings.apis.get("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX") or "AppOps",
"dryRun": bool(settings.apis.get("HOME_ASSISTANT_DRY_RUN")),
},
secret_refs_json=["HOME_ASSISTANT_TOKEN"],
),
NotificationChannel(id="discord", type="discord", name="Discord", enabled=False, config_json={"username": "AppOps", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_DISCORD_WEBHOOK"]),
NotificationChannel(id="telegram", type="telegram", name="Telegram", enabled=False, config_json={"chatId": "", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_TELEGRAM_BOT_TOKEN"]),
NotificationChannel(id="email", type="email", name="E-mail", enabled=False, config_json={"host": "", "port": 465, "username": "", "fromAddress": "", "toAddress": "", "useTls": True, "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_SMTP_PASSWORD"]),
]
def _upgrade_default_notification_rule_channels(session) -> bool:
home_assistant_defaults = {
"notify-critical-safety-finding",
"notify-high-safety-finding",
"notify-npm-certificate-expiring",
"notify-public-host-without-auth",
"notify-blocked-dangerous-action",
}
changed = False
for rule_id in home_assistant_defaults:
rule = session.get(NotificationRule, rule_id)
if rule and "home_assistant" not in (rule.channels_json or []):
rule.channels_json = [*(rule.channels_json or ["dashboard"]), "home_assistant"]
changed = True
session.add(rule)
return changed
def _seed_notification_rules() -> list[NotificationRule]:
return [
NotificationRule(id="notify-critical-safety-finding", name="Critical Safety Finding", source="safety", event_type="safety.finding", severity_min="critical", cooldown_seconds=900, quiet_hours_json={"enabled": False, "allowCriticalOverride": True}, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-high-safety-finding", name="High Safety Finding", source="safety", event_type="safety.finding", severity_min="high", cooldown_seconds=3600, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-coverage-incomplete", name="Coverage Incomplete", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["coverage", "connector_config", "setup", "connectors"], cooldown_seconds=86400, channels_json=["dashboard"]),
NotificationRule(id="notify-connector-missing-config", name="Connector Missing Config", source="connector", event_type="connector.missing_config", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]),
NotificationRule(id="notify-connector-failed", name="Connector Failure After Previously Connected", source="connector", event_type="connector.failed", severity_min="medium", cooldown_seconds=1800, channels_json=["dashboard"]),
NotificationRule(id="notify-media-pipeline-issue", name="Media Pipeline Issue", source="media_pipeline", event_type="media.issue", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-deluge-stalled-downloads", name="Deluge Stalled Downloads", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["downloads"], cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-widefrog-failed-jobs", name="Widefrog Failed Jobs", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["widefrog"], cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-npm-certificate-expiring", name="NPM Certificate Expiring", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["exposure"], cooldown_seconds=86400, channels_json=["dashboard", "home_assistant"], conditions_json={"titleContains": ["certificate", "certificaat"]}),
NotificationRule(id="notify-public-host-without-auth", name="Public Host Without Auth Coverage", source="safety", event_type="safety.finding", severity_min="high", categories_json=["auth", "exposure"], cooldown_seconds=43200, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-blocked-dangerous-action", name="Blocked Dangerous Action Attempt", source="audit", event_type="audit.blocked_action", severity_min="high", cooldown_seconds=1800, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-voice-auth-failure", name="Voice Auth Failure", source="audit", event_type="audit.voice_denied", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-discovery-needs-review", name="Discovery Needs Review", source="inventory", event_type="inventory.needs_review", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]),
]
def _seed_voice_export_policies() -> list[Policy]:
return [
Policy(id="policy-unraid-health-summary", action="unraid.health_summary", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading Unraid health, storage and VM inventory is read-only; mover, format and power mutations use separate actions."),
Policy(id="policy-unraid-vms", action="unraid.vms", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading the Unraid VM inventory is read-only."),
Policy(id="policy-unraid-vm-start", action="unraid.vm.start", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Starting a VM changes host load; manual execution is confirmed and audited, while voice requires approval."),
Policy(id="policy-unraid-vm-resume", action="unraid.vm.resume", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a paused VM changes host load; manual execution is confirmed and audited, while voice requires approval."),
Policy(id="policy-unraid-vm-pause", action="unraid.vm.pause", target="*", voice="blocked", manual="approval_required", risk="high", reason="Pausing a running VM interrupts its workload and requires explicit operator approval."),
Policy(id="policy-unraid-vm-stop", action="unraid.vm.stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Unraid stop can force-stop after timeout and remains blocked."),
Policy(id="policy-unraid-vm-reboot", action="unraid.vm.reboot", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reboot remains blocked until shutdown and recovery semantics are safely verifiable."),
Policy(id="policy-unraid-vm-force-stop", action="unraid.vm.force_stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Force-stop is destructive and blocked."),
Policy(id="policy-unraid-vm-reset", action="unraid.vm.reset", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reset is destructive and blocked."),
Policy(id="policy-setup-status", action="setup.status", target="appops", voice="allowed", manual="allowed", risk="low", reason="Reading AppOps setup status is read-only."),
Policy(id="policy-safety-summary", action="safety.summary", target="safety", voice="allowed", manual="allowed", risk="low", reason="Reading Safety Center summary is read-only."),
Policy(id="policy-media-pipeline-check", action="media_pipeline.check", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline voice check is read-only in the voice export path."),
Policy(id="policy-media-pipeline-diagnose", action="media_pipeline.diagnose", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline diagnosis is a read-only evidence summary and does not execute connector actions."),
Policy(id="policy-notifications-summary", action="notifications.summary", target="notifications", voice="allowed", manual="allowed", risk="low", reason="Reading notification summary is read-only and never returns notification tokens."),
Policy(id="policy-notifications-evaluate-safety", action="notifications.evaluate_safety", target="notifications", voice="approval_required", manual="allowed", risk="medium", reason="Evaluating safety notifications can create notification events and requires voice approval."),
]
def _seed_connectors() -> list[Connector]:
return [
Connector(
id="portainer",
name="Portainer",
status="disconnected" if not settings.apis.get("PORTAINER_URL") else "connected",
last_test="never",
secret_ref="PORTAINER_API_KEY",
capabilities=[
"container.status",
"container.logs",
"container.restart",
"container.stop",
"stack.status",
"stack.redeploy",
],
type="Infrastructure",
),
Connector(
id="unraid",
name="Unraid",
status="disconnected" if not settings.apis.get("UNRAID_URL") else "connected",
last_test="never",
secret_ref="UNRAID_API_KEY",
capabilities=[
"unraid.health_summary",
"unraid.array",
"unraid.disks",
"unraid.cache",
"unraid.parity",
"unraid.docker",
"unraid.vms",
"unraid.shares",
],
type="Infrastructure",
),
Connector(
id="sonarr",
name="Sonarr",
status="disconnected" if not settings.apis.get("SONARR_URL") else "connected",
last_test="never",
secret_ref="SONARR_API_KEY",
capabilities=[
"sonarr.health",
"sonarr.search_series",
"sonarr.add_series",
"sonarr.queue_status",
"sonarr.wanted_missing",
],
type="Media",
),
Connector(
id="radarr",
name="Radarr",
status="disconnected" if not (settings.apis.get("RADARR_URL") and settings.apis.get("RADARR_API_KEY")) else "connected",
last_test="never",
secret_ref="RADARR_API_KEY",
capabilities=[
"radarr.health",
"radarr.search_movie",
"radarr.add_movie",
"radarr.queue_status",
"radarr.wanted_missing",
],
type="Media",
),
Connector(
id="prowlarr",
name="Prowlarr",
status="disconnected" if not (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "connected",
last_test="never",
secret_ref="PROWLARR_API_KEY",
capabilities=[
"prowlarr.health",
"prowlarr.indexer_status",
"prowlarr.application_status",
"prowlarr.indexer_health",
"prowlarr.search_test",
"prowlarr.sync_status",
],
type="Media",
),
Connector(
id="deluge",
name="Deluge",
status="disconnected" if not (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "connected",
last_test="never",
secret_ref="DELUGE_PASSWORD",
capabilities=[
"deluge.health",
"deluge.session_status",
"deluge.torrent_status",
"deluge.active_downloads",
"deluge.stalled_downloads",
"deluge.paused_downloads",
"deluge.speed_status",
"deluge.pause_torrent",
"deluge.resume_torrent",
"deluge.pause_all",
"deluge.resume_all",
],
type="Downloads",
),
Connector(
id="plex",
name="Plex",
status="disconnected" if not (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "connected",
last_test="never",
secret_ref="PLEX_TOKEN",
capabilities=[
"plex.health",
"plex.server_status",
"plex.library_status",
"plex.active_sessions",
"plex.recent_media",
"plex.scan_library",
"plex.scan_all_libraries",
"plex.stream_summary",
],
type="Media",
),
Connector(
id="tautulli",
name="Tautulli",
status="disconnected" if not (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "connected",
last_test="never",
secret_ref="TAUTULLI_API_KEY",
capabilities=[
"tautulli.health",
"tautulli.status",
"tautulli.activity",
"tautulli.history",
"tautulli.users",
"tautulli.libraries",
"tautulli.recently_added",
"tautulli.watch_stats",
"tautulli.stream_summary",
"tautulli.plex_info",
],
type="Media",
),
Connector(
id="authentik",
name="Authentik",
status="disconnected" if not (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "connected",
last_test="never",
secret_ref="AUTHENTIK_TOKEN",
capabilities=[
"authentik.health",
"authentik.system_status",
"authentik.applications",
"authentik.providers",
"authentik.proxy_providers",
"authentik.oauth2_providers",
"authentik.outposts",
"authentik.outpost_health",
"authentik.flows_summary",
"authentik.policies_summary",
"authentik.events",
"authentik.auth_coverage",
"authentik.security_warnings",
],
type="Auth",
),
Connector(
id="nginx_proxy_manager",
name="Nginx Proxy Manager",
status="disconnected" if not (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "connected",
last_test="never",
secret_ref="NPM_SECRET",
capabilities=[
"npm.health",
"npm.proxy_hosts",
"npm.certificates",
"npm.expiring_certificates",
"npm.access_lists",
"npm.exposure_summary",
"npm.host_reachability",
"npm.security_warnings",
],
type="Network",
),
Connector(
id="widefrog",
name="Widefrog",
status="disconnected" if not settings.apis.get("WIDEFROG_URL") else "connected",
last_test="never",
secret_ref="WIDEFROG_API_KEY",
capabilities=[
"widefrog.health",
"widefrog.queue_status",
"widefrog.failed_jobs",
"widefrog.activity",
"widefrog.retry_failed",
"widefrog.scan_library",
"widefrog.download_status",
],
type="Media",
),
Connector(
id="home_assistant",
name="Home Assistant",
status="disconnected" if not settings.apis.get("HOME_ASSISTANT_URL") else "connected",
last_test="never",
secret_ref="HOME_ASSISTANT_TOKEN",
capabilities=["read_status", "trigger_action"],
type="Automation",
),
]
def _seed_policies() -> list[Policy]:
return [
Policy(id="policy-volume-delete", action="volume.delete", target="*", voice="blocked", manual="blocked", risk="critical", reason="Dangerous operation."),
Policy(id="policy-container-stop", action="container.stop", target="*", voice="blocked", manual="blocked", risk="high", reason="Container stop is outside AppOps Gateway V1; use Portainer or Unraid directly after manual review."),
Policy(id="policy-container-clear-logs", action="container.clear_logs", target="*", voice="blocked", manual="blocked", risk="high", reason="Clearing container logs is outside AppOps Gateway V1; log viewing and local download remain read-only."),
Policy(id="policy-gateway-clear-cache", action="gateway.clear_cache", target="gateway", voice="blocked", manual="blocked", risk="high", reason="Clearing gateway cache is outside AppOps Gateway V1; use explicit refresh or restart procedures after review."),
Policy(id="policy-container-restart-authentik", action="container.restart", target="authentik", voice="approval_required", manual="approval_required", risk="critical", reason="Authenticator restart is high-risk."),
Policy(id="policy-container-restart-widefrog", action="container.restart", target="widefrog", voice="allowed", manual="allowed", risk="medium", reason="Widefrog restart is a safe first-line media action."),
Policy(id="policy-unraid-mover", action="unraid.mover.start", target="unraid", voice="blocked", manual="approval_required", risk="high", reason="Mover start requires manual approval except read-only voice flows."),
Policy(id="policy-stack-redeploy", action="stack.redeploy", target="*", voice="approval_required", manual="approval_required", risk="high", reason="Stack redeploy needs approval in this safe policy."),
*_seed_voice_export_policies(),
Policy(id="policy-sonarr-search", action="sonarr.search_series", target="sonarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Sonarr lookup."),
Policy(id="policy-sonarr-add", action="sonarr.add_series", target="sonarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored series is a reversible app-level action and does not affect infrastructure."),
Policy(id="policy-sonarr-delete", action="sonarr.delete_series", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting series can remove metadata/files and is not supported by AppOps Gateway V1."),
Policy(id="policy-sonarr-mass-edit", action="sonarr.mass_edit", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."),
Policy(id="policy-radarr-search", action="radarr.search_movie", target="radarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Radarr lookup."),
Policy(id="policy-radarr-add", action="radarr.add_movie", target="radarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored movie is a reversible app-level action and does not affect infrastructure."),
Policy(id="policy-radarr-delete", action="radarr.delete_movie", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movies can remove metadata/files and is not supported by AppOps Gateway V1."),
Policy(id="policy-radarr-delete-files", action="radarr.delete_files", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movie files is destructive and is blocked by AppOps Gateway V1."),
Policy(id="policy-radarr-mass-edit", action="radarr.mass_edit", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."),
Policy(id="policy-widefrog-health", action="widefrog.health", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog health check."),
Policy(id="policy-widefrog-queue", action="widefrog.queue_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog queue status."),
Policy(id="policy-widefrog-failed", action="widefrog.failed_jobs", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog failed jobs status."),
Policy(id="policy-widefrog-activity", action="widefrog.activity", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog activity status."),
Policy(id="policy-widefrog-download-status", action="widefrog.download_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog download status."),
Policy(id="policy-widefrog-scan-library", action="widefrog.scan_library", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Scanning the media library is a non-destructive app-level action but may create load."),
Policy(id="policy-widefrog-retry-failed", action="widefrog.retry_failed", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Retrying failed jobs may start downloads or processing and should be operator-controlled."),
Policy(id="policy-widefrog-delete-job", action="widefrog.delete_job", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting Widefrog jobs is not supported by AppOps Gateway V1."),
Policy(id="policy-widefrog-delete-media", action="widefrog.delete_media", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting media is blocked."),
Policy(id="policy-widefrog-clear-library", action="widefrog.clear_library", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing library data is blocked."),
Policy(id="policy-widefrog-clear-queue", action="widefrog.clear_queue", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing queue data is blocked."),
Policy(id="policy-widefrog-reset-database", action="widefrog.reset_database", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Database reset is blocked."),
Policy(id="policy-widefrog-delete-downloads", action="widefrog.delete_downloads", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloads is blocked."),
Policy(id="policy-widefrog-prune", action="widefrog.prune", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Prune actions are blocked."),
Policy(id="policy-prowlarr-health", action="prowlarr.health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr health is read-only."),
Policy(id="policy-prowlarr-indexers", action="prowlarr.indexer_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer status is read-only."),
Policy(id="policy-prowlarr-applications", action="prowlarr.application_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr app sync status is read-only."),
Policy(id="policy-prowlarr-indexer-health", action="prowlarr.indexer_health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer health is read-only."),
Policy(id="policy-prowlarr-sync-status", action="prowlarr.sync_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr sync status is read-only."),
Policy(id="policy-prowlarr-search-test", action="prowlarr.search_test", target="prowlarr", voice="approval_required", manual="allowed", risk="medium", reason="Search test consumes indexer API quota and is read-only controlled access."),
Policy(id="policy-prowlarr-delete-indexer", action="prowlarr.delete_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting indexers is destructive and not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-delete-application", action="prowlarr.delete_application", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting Prowlarr applications is not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-update-indexer", action="prowlarr.update_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating indexers is not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-update-application", action="prowlarr.update_application", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating Prowlarr applications is not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-grab-release", action="prowlarr.grab_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release grab/download is blocked in Prowlarr Connector V1."),
Policy(id="policy-prowlarr-push-release", action="prowlarr.push_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release push is blocked in Prowlarr Connector V1."),
Policy(id="policy-prowlarr-mass-edit", action="prowlarr.mass_edit", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-test-all-indexers", action="prowlarr.test_all_indexers", target="prowlarr", voice="blocked", manual="blocked", risk="medium", reason="Testing all indexers may consume API quota and is disabled in V1."),
Policy(id="policy-deluge-health", action="deluge.health", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge health is read-only."),
Policy(id="policy-deluge-session", action="deluge.session_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge session status is read-only."),
Policy(id="policy-deluge-torrents", action="deluge.torrent_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge torrent status is read-only."),
Policy(id="policy-deluge-active", action="deluge.active_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading active downloads is read-only."),
Policy(id="policy-deluge-stalled", action="deluge.stalled_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading stalled downloads is read-only."),
Policy(id="policy-deluge-paused", action="deluge.paused_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading paused downloads is read-only."),
Policy(id="policy-deluge-speeds", action="deluge.speed_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge speed status is read-only."),
Policy(id="policy-deluge-pause-torrent", action="deluge.pause_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Pausing a torrent changes download state and should be operator-controlled."),
Policy(id="policy-deluge-resume-torrent", action="deluge.resume_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a torrent changes download state and should be operator-controlled."),
Policy(id="policy-deluge-pause-all", action="deluge.pause_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Pausing all downloads affects the media pipeline."),
Policy(id="policy-deluge-resume-all", action="deluge.resume_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Resuming all downloads affects the media pipeline."),
Policy(id="policy-deluge-remove-torrent", action="deluge.remove_torrent", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing torrents is destructive and blocked in V1."),
Policy(id="policy-deluge-remove-data", action="deluge.remove_torrent_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing downloaded data is blocked."),
Policy(id="policy-deluge-delete-data", action="deluge.delete_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloaded data is blocked."),
Policy(id="policy-deluge-force-recheck", action="deluge.force_recheck", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Force recheck is unsupported in V1."),
Policy(id="policy-deluge-move-storage", action="deluge.move_storage", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Moving torrent storage is unsupported in V1."),
Policy(id="policy-deluge-add-torrent", action="deluge.add_torrent", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding torrents is unsupported in V1."),
Policy(id="policy-deluge-add-magnet", action="deluge.add_magnet", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding magnets is unsupported in V1."),
Policy(id="policy-deluge-mass-edit", action="deluge.mass_edit", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are blocked in V1."),
Policy(id="policy-deluge-set-options", action="deluge.set_options", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Changing torrent options is blocked in V1."),
Policy(id="policy-plex-health", action="plex.health", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex health is read-only."),
Policy(id="policy-plex-server-status", action="plex.server_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server status is read-only."),
Policy(id="policy-plex-library-status", action="plex.library_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex library status is read-only."),
Policy(id="policy-plex-active-sessions", action="plex.active_sessions", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex active sessions is read-only."),
Policy(id="policy-plex-recent-media", action="plex.recent_media", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex recent media is read-only."),
Policy(id="policy-plex-stream-summary", action="plex.stream_summary", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex stream summary is read-only."),
Policy(id="policy-plex-scan-library", action="plex.scan_library", target="plex", voice="approval_required", manual="allowed", risk="medium", reason="Scanning a Plex library can create server load and should be operator-controlled."),
Policy(id="policy-plex-scan-all", action="plex.scan_all_libraries", target="plex", voice="blocked", manual="approval_required", risk="high", reason="Scanning all Plex libraries can create significant server load."),
Policy(id="policy-plex-delete-media", action="plex.delete_media", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex media is blocked."),
Policy(id="policy-plex-delete-library", action="plex.delete_library", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex libraries is blocked."),
Policy(id="policy-plex-empty-trash", action="plex.empty_trash", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Empty trash can remove media references and is blocked."),
Policy(id="policy-plex-clean-bundles", action="plex.clean_bundles", target="plex", voice="blocked", manual="blocked", risk="high", reason="Cleaning bundles is blocked in V1."),
Policy(id="policy-plex-optimize-db", action="plex.optimize_database", target="plex", voice="blocked", manual="blocked", risk="high", reason="Database optimization is blocked in V1."),
Policy(id="policy-plex-kill-session", action="plex.kill_session", target="plex", voice="blocked", manual="blocked", risk="high", reason="Killing user sessions is blocked in V1."),
Policy(id="policy-plex-user-management", action="plex.user_management", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex user management is blocked."),
Policy(id="policy-plex-force-refresh-all", action="plex.force_refresh_all_metadata", target="plex", voice="blocked", manual="blocked", risk="high", reason="Force refreshing all metadata is blocked in V1."),
Policy(id="policy-plex-mass-edit", action="plex.mass_edit", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex mass edits are blocked in V1."),
Policy(id="policy-tautulli-health", action="tautulli.health", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli health is read-only."),
Policy(id="policy-tautulli-status", action="tautulli.status", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli status is read-only."),
Policy(id="policy-tautulli-activity", action="tautulli.activity", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading current Tautulli activity is read-only."),
Policy(id="policy-tautulli-history", action="tautulli.history", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Viewing watch history may expose user activity."),
Policy(id="policy-tautulli-users", action="tautulli.users", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="User statistics may expose personal viewing behavior."),
Policy(id="policy-tautulli-libraries", action="tautulli.libraries", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli libraries is read-only."),
Policy(id="policy-tautulli-recently-added", action="tautulli.recently_added", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading recently added media is read-only."),
Policy(id="policy-tautulli-watch-stats", action="tautulli.watch_stats", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Watch statistics may expose personal viewing behavior."),
Policy(id="policy-tautulli-stream-summary", action="tautulli.stream_summary", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading stream summary is read-only."),
Policy(id="policy-tautulli-plex-info", action="tautulli.plex_info", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server info through Tautulli is read-only."),
Policy(id="policy-tautulli-terminate-session", action="tautulli.terminate_session", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Terminating sessions is blocked in V1."),
Policy(id="policy-tautulli-delete-newsletter", action="tautulli.delete_newsletter", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting newsletters is blocked in V1."),
Policy(id="policy-tautulli-delete-notification-log", action="tautulli.delete_notification_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notification logs is blocked in V1."),
Policy(id="policy-tautulli-delete-notifier", action="tautulli.delete_notifier", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notifiers is blocked in V1."),
Policy(id="policy-tautulli-delete-recently-added", action="tautulli.delete_recently_added", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting recently-added records is blocked in V1."),
Policy(id="policy-tautulli-download-config", action="tautulli.download_config", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli config is blocked in V1."),
Policy(id="policy-tautulli-download-database", action="tautulli.download_database", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli database is blocked in V1."),
Policy(id="policy-tautulli-download-export", action="tautulli.download_export", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli exports is blocked in V1."),
Policy(id="policy-tautulli-download-log", action="tautulli.download_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli logs is blocked in V1."),
Policy(id="policy-tautulli-download-plex-log", action="tautulli.download_plex_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Plex logs through Tautulli is blocked in V1."),
Policy(id="policy-tautulli-notify", action="tautulli.notify", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Sending notifications through Tautulli is blocked in V1."),
Policy(id="policy-tautulli-user-management", action="tautulli.user_management", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli user management is blocked in V1."),
Policy(id="policy-tautulli-mass-edit", action="tautulli.mass_edit", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli mass edits are blocked in V1."),
Policy(id="policy-npm-health", action="npm.health", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM health is read-only."),
Policy(id="policy-npm-proxy-hosts", action="npm.proxy_hosts", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Proxy hosts may reveal external exposure."),
Policy(id="policy-npm-certificates", action="npm.certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading certificate metadata is read-only."),
Policy(id="policy-npm-expiring-certificates", action="npm.expiring_certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading expiring certificate metadata is read-only."),
Policy(id="policy-npm-access-lists", action="npm.access_lists", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Access list information may reveal security structure."),
Policy(id="policy-npm-exposure-summary", action="npm.exposure_summary", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="medium", reason="Exposure summary is a read-only security overview."),
Policy(id="policy-npm-host-reachability", action="npm.host_reachability", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="V1 host reachability is metadata-only."),
Policy(id="policy-npm-security-warnings", action="npm.security_warnings", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM security warnings is read-only."),
Policy(id="policy-npm-create-proxy-host", action="npm.create_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Creating proxy hosts is not supported by AppOps Gateway V1."),
Policy(id="policy-npm-update-proxy-host", action="npm.update_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Updating proxy hosts is not supported by AppOps Gateway V1."),
Policy(id="policy-npm-delete-proxy-host", action="npm.delete_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting proxy hosts is destructive and blocked."),
Policy(id="policy-npm-enable-proxy-host", action="npm.enable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Enabling proxy hosts changes external exposure and is blocked in V1."),
Policy(id="policy-npm-disable-proxy-host", action="npm.disable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Disabling proxy hosts changes external exposure and is blocked in V1."),
Policy(id="policy-npm-renew-certificate", action="npm.renew_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Certificate renewal is not executed by AppOps Gateway V1."),
Policy(id="policy-npm-delete-certificate", action="npm.delete_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting certificates is blocked."),
Policy(id="policy-npm-modify-access-list", action="npm.modify_access_list", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Access list mutations are blocked in V1."),
Policy(id="policy-npm-create-user", action="npm.create_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."),
Policy(id="policy-npm-update-user", action="npm.update_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."),
Policy(id="policy-npm-delete-user", action="npm.delete_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."),
Policy(id="policy-npm-mass-edit", action="npm.mass_edit", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM bulk mutations are blocked."),
Policy(id="policy-npm-unknown-mutation", action="npm.unknown_mutation", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Unknown NPM mutations fail closed."),
Policy(id="policy-authentik-health", action="authentik.health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik health is read-only."),
Policy(id="policy-authentik-system", action="authentik.system_status", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik system status is read-only."),
Policy(id="policy-authentik-applications", action="authentik.applications", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Applications reveal SSO coverage and are operator-visible."),
Policy(id="policy-authentik-providers", action="authentik.providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Provider metadata can reveal security topology."),
Policy(id="policy-authentik-proxy-providers", action="authentik.proxy_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Proxy provider metadata can reveal exposure topology."),
Policy(id="policy-authentik-oauth2-providers", action="authentik.oauth2_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="OAuth provider metadata can reveal application integrations."),
Policy(id="policy-authentik-outposts", action="authentik.outposts", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Outpost metadata is security-topology information."),
Policy(id="policy-authentik-outpost-health", action="authentik.outpost_health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Outpost health summary is read-only."),
Policy(id="policy-authentik-flows", action="authentik.flows_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Flow summaries reveal authentication design."),
Policy(id="policy-authentik-policies", action="authentik.policies_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Policy summaries reveal security controls."),
Policy(id="policy-authentik-events", action="authentik.events", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Events may expose user activity and require operator context."),
Policy(id="policy-authentik-auth-coverage", action="authentik.auth_coverage", target="authentik", voice="allowed", manual="allowed", risk="medium", reason="Auth coverage is a read-only security overview."),
Policy(id="policy-authentik-security-warnings", action="authentik.security_warnings", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik security warnings is read-only."),
Policy(id="policy-authentik-create-application", action="authentik.create_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-application", action="authentik.update_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-application", action="authentik.delete_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-provider", action="authentik.create_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-provider", action="authentik.update_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-provider", action="authentik.delete_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-outpost", action="authentik.create_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-outpost", action="authentik.update_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-outpost", action="authentik.delete_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-policy", action="authentik.create_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-policy", action="authentik.update_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-policy", action="authentik.delete_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-user", action="authentik.create_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."),
Policy(id="policy-authentik-update-user", action="authentik.update_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."),
Policy(id="policy-authentik-delete-user", action="authentik.delete_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."),
Policy(id="policy-authentik-create-group", action="authentik.create_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."),
Policy(id="policy-authentik-update-group", action="authentik.update_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."),
Policy(id="policy-authentik-delete-group", action="authentik.delete_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."),
Policy(id="policy-authentik-rotate-token", action="authentik.rotate_token", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Token rotation is never automated by Connector V1."),
Policy(id="policy-authentik-force-sync", action="authentik.force_sync", target="authentik", voice="blocked", manual="blocked", risk="high", reason="Force sync is a mutating operation and blocked in Connector V1."),
Policy(id="policy-authentik-mass-edit", action="authentik.mass_edit", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Bulk Authentik mutations are blocked."),
Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."),
]
+398
View File
@@ -0,0 +1,398 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, CheckConstraint
from sqlmodel import Column, Field, SQLModel
from ..core.time import utc_now
class App(SQLModel, table=True):
id: str = Field(primary_key=True)
name: str
category: str
status: str = Field(default="healthy")
criticality: str = Field(default="low")
connector_status: str = Field(default="disconnected")
container_status: str = Field(default="stopped")
voice_enabled: bool = Field(default=False)
protected: bool = Field(default=False)
last_action: str | None = None
discovery_status: str | None = None
discovery_source: str | None = None
needs_review: bool = Field(default=False)
created_at: datetime = Field(default_factory=utc_now)
class Connector(SQLModel, table=True):
id: str = Field(primary_key=True)
name: str
status: str = Field(default="disconnected")
last_test: str = Field(default="never")
secret_ref: str | None = None
capabilities: list[str] = Field(default_factory=list, sa_column=Column(JSON))
type: str = Field(default="Infrastructure")
class NotificationRule(SQLModel, table=True):
__tablename__ = "notification_rules"
id: str = Field(primary_key=True)
name: str
enabled: bool = Field(default=True)
source: str
event_type: str
severity_min: str = Field(default="medium")
categories_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
targets_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
channels_json: list[str] = Field(default_factory=lambda: ["dashboard"], sa_column=Column(JSON))
cooldown_seconds: int = Field(default=3600)
quiet_hours_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
conditions_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
dedupe_key_template: str = Field(default="{source}:{event_type}:{target}:{category}")
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class NotificationEvent(SQLModel, table=True):
__tablename__ = "notification_events"
id: str = Field(primary_key=True)
rule_id: str
source: str
event_type: str
severity: str
title: str
message: str
category: str | None = None
target: str | None = None
dedupe_key: str
channels_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
status: str = Field(default="queued")
reason: str | None = None
payload_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
read: bool = Field(default=False)
created_at: datetime = Field(default_factory=utc_now)
sent_at: datetime | None = None
suppressed_until: datetime | None = None
class NotificationChannel(SQLModel, table=True):
__tablename__ = "notification_channels"
id: str = Field(primary_key=True)
type: str = Field(default="dashboard")
name: str
enabled: bool = Field(default=True)
config_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
secret_refs_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
last_test_at: datetime | None = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class AppSetting(SQLModel, table=True):
__tablename__ = "app_settings"
key: str = Field(primary_key=True)
value_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
is_secret: bool = Field(default=False)
updated_at: datetime = Field(default_factory=utc_now)
class Secret(SQLModel, table=True):
__tablename__ = "secrets"
id: str = Field(primary_key=True)
name: str = Field(index=True)
encrypted_value: str
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
last_used_at: datetime | None = None
used_by_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class Policy(SQLModel, table=True):
id: str = Field(primary_key=True)
action: str
target: str
voice: str
manual: str
risk: str
reason: str | None = None
provenance_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class AuditEvent(SQLModel, table=True):
id: str = Field(primary_key=True)
timestamp: datetime = Field(default_factory=utc_now)
source: str
identity: str = Field(default="manual")
action_id: str
target: str
target_type: str = Field(default="app")
risk: str
policy_decision: str
reason: str | None = None
execution_plan: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
result: str = Field(default="pending")
before_state: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
after_state: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
post_checks: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
redacted_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
class Approval(SQLModel, table=True):
id: str = Field(primary_key=True)
action: str
target: str
source: str = Field(default="manual")
risk: str
status: str = Field(default="pending")
requester: str = Field(default="system")
approved_by: str | None = None
executed_by: str | None = None
action_fingerprint: str | None = None
request_context_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
reason: str | None = None
expires_at: datetime | None = None
created_at: datetime = Field(default_factory=utc_now)
decided_at: datetime | None = None
executed_at: datetime | None = None
dismissed_at: datetime | None = None
job_id: str | None = None
audit_event_id: str | None = None
class JobRun(SQLModel, table=True):
id: str = Field(primary_key=True)
type: str
status: str = Field(default="queued")
source: str = Field(default="manual")
action_id: str
target: str
risk: str
policy_decision: str
steps_json: str = Field(default="[]")
progress: int = Field(default=0)
started_at: datetime | None = Field(default=None)
finished_at: datetime | None = Field(default=None)
duration_ms: int | None = None
audit_event_id: str | None = None
result_summary: str | None = None
class DeploymentJob(SQLModel, table=True):
"""Durable, redacted state for one Deployment Control Plane request.
The plan is immutable and excludes secret values. Secrets remain in the
encrypted Secret store and are resolved only at the bounded controller
request boundary.
"""
__tablename__ = "deployment_jobs"
id: str = Field(primary_key=True)
application_id: str = Field(index=True)
repository: str = Field(index=True)
revision: str = Field(index=True)
plan_sha256: str = Field(index=True)
idempotency_sha256: str = Field(index=True)
status: str = Field(default="queued", index=True)
stage: str = Field(default="accepted")
plan_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
broker_request_id: str | None = Field(default=None, index=True)
result_summary: str | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class WorkMeshPromotion(SQLModel, table=True):
"""Durable approval and reconciliation state for fixed WorkMesh promotion."""
__tablename__ = "workmesh_promotions"
id: str = Field(primary_key=True)
revision: str = Field(index=True)
preflight_sha256: str = Field(index=True)
idempotency_sha256: str = Field(index=True, unique=True)
status: str = Field(default="approval_required", index=True)
stage: str = Field(default="preflight_accepted")
preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
approval_id: str | None = Field(default=None, index=True)
broker_request_id: str | None = Field(default=None, index=True)
result_summary: str | None = None
audit_event_id: str | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class Enrollment(SQLModel, table=True):
"""One immutable application-management boundary established by AppOps."""
__tablename__ = "enrollments"
id: str = Field(primary_key=True)
application_id: str = Field(index=True)
repository: str = Field(index=True)
revision: str = Field(index=True)
profile: str = Field(index=True)
controller_identity: str = Field(index=True)
plan_sha256: str = Field(index=True)
status: str = Field(default="planned", index=True)
target_secret_ref: str = Field(index=True)
oidc_secret_ref: str | None = Field(default=None, index=True)
plan_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
resources_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
provenance_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
last_verified_at: datetime | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class EnrollmentJob(SQLModel, table=True):
"""Restart-safe, redacted evidence for enrollment orchestration."""
__tablename__ = "enrollment_jobs"
id: str = Field(primary_key=True)
enrollment_id: str = Field(index=True)
application_id: str = Field(index=True)
operation: str = Field(default="enroll", index=True)
idempotency_sha256: str = Field(index=True)
status: str = Field(default="queued", index=True)
stage: str = Field(default="planned")
preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
result_summary: str | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class RepoPulseAdoptionJournal(SQLModel, table=True):
"""One durable protocol intent for the fixed RepoPulse legacy adoption."""
__tablename__ = "repopulse_adoption_journal"
__table_args__ = (
CheckConstraint(
"slot = 'repopulse' AND repository = 'jens/repopulse' "
"AND application_id = 'repopulse'",
name="ck_repopulse_adoption_fixed_identity",
),
)
slot: str = Field(default="repopulse", primary_key=True)
repository: str
application_id: str
intent_id: str = Field(index=True)
idempotency_sha256: str
preflight_sha256: str
phase: str
status: str
revision: int = Field(default=1, ge=1)
intent_json: dict[str, Any] = Field(sa_column=Column(JSON, nullable=False))
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class HealthCheck(SQLModel, table=True):
id: str = Field(primary_key=True)
service: str
status: str
checked_at: datetime = Field(default_factory=utc_now)
details: str | None = None
class AiMemoryItem(SQLModel, table=True):
id: str = Field(primary_key=True)
key: str
value: str
category: str = Field(default="app_note")
pinned: bool = Field(default=False)
updated_at: datetime = Field(default_factory=utc_now)
class AiRecommendation(SQLModel, table=True):
id: str = Field(primary_key=True)
action: str
title: str
reasoning: str
target: str
severity: str
suggested_actions: list[str] = Field(default_factory=list, sa_column=Column(JSON))
class KnowledgeChunk(SQLModel, table=True):
__tablename__ = "knowledge_chunks"
id: str = Field(primary_key=True)
source_uri: str = Field(index=True)
source_type: str = Field(default="documentation", index=True)
title: str
section: str | None = None
content: str
content_hash: str = Field(index=True)
updated_at: datetime = Field(default_factory=utc_now)
class DiscoveryRun(SQLModel, table=True):
__tablename__ = "discovery_runs"
id: str = Field(primary_key=True)
source: str = Field(default="portainer")
started_at: datetime = Field(default_factory=utc_now, index=True)
finished_at: datetime | None = None
status: str = Field(default="running")
containers_found: int = Field(default=0)
apps_matched: int = Field(default=0)
new_items: int = Field(default=0)
changed_items: int = Field(default=0)
errors_json: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON))
class DiscoveredContainer(SQLModel, table=True):
__tablename__ = "discovered_containers"
id: str = Field(primary_key=True)
discovery_run_id: str = Field(index=True)
container_id: str = Field(index=True)
name: str
image: str
state: str = Field(default="unknown")
status: str = Field(default="unknown")
ports_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
mounts_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
networks_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
labels_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
suggested_app_type: str = Field(default="unknown")
suggested_category: str = Field(default="unknown")
suggested_criticality: str = Field(default="low")
suggested_connector: str = Field(default="none")
suggested_policy_profile: str = Field(default="default")
import_status: str = Field(default="discovered")
matched_app_id: str | None = None
raw_metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class AppContainerMapping(SQLModel, table=True):
__tablename__ = "app_container_mappings"
id: str = Field(primary_key=True)
app_id: str = Field(index=True)
container_id: str = Field(index=True)
container_name: str
role: str = Field(default="primary")
source: str = Field(default="discovery")
is_primary: bool = Field(default=True)
last_seen_run_id: str | None = None
last_seen_at: datetime = Field(default_factory=utc_now)
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
from sqlalchemy import event
from sqlalchemy.engine import Engine
from sqlmodel import create_engine, Session
from ..core.config import settings
def _configure_sqlite_connection(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA busy_timeout=30000")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
finally:
cursor.close()
def create_app_engine(db_path: Path | str) -> Engine:
resolved_path = Path(db_path)
if resolved_path.parent != Path('.'):
resolved_path.parent.mkdir(parents=True, exist_ok=True)
app_engine = create_engine(
f"sqlite:///{resolved_path}",
echo=False,
connect_args={"check_same_thread": False, "timeout": 30},
pool_size=20,
max_overflow=20,
pool_timeout=10,
)
event.listen(app_engine, "connect", _configure_sqlite_connection)
return app_engine
_db_path = Path(settings.db_path)
DATABASE_URL = f"sqlite:///{_db_path}"
engine = create_app_engine(_db_path)
def get_session():
with Session(engine) as session:
yield session
+363
View File
@@ -0,0 +1,363 @@
from __future__ import annotations
import asyncio
import logging
import re
from collections import defaultdict, deque
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from time import monotonic
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .api.routes_actions import router as actions_router
from .api.routes_ai import router as ai_router
from .api.routes_ai_remediation import router as ai_remediation_router
from .api.routes_ai_security import router as ai_security_router
from .api.routes_approvals import router as approvals_router
from .api.routes_apps import router as apps_router
from .api.routes_audit import router as audit_router
from .api.routes_authentik import router as authentik_router
from .api.routes_connectors import router as connectors_router
from .api.routes_containers import router as containers_router
from .api.routes_deluge import router as deluge_router
from .api.routes_deployments import router as deployments_router
from .api.routes_enrollments import router as enrollments_router
from .api.routes_gitea_actions import router as gitea_actions_router
from .api.routes_health import router as health_router
from .api.routes_integrations import n8n_router
from .api.routes_integrations import router as integrations_router
from .api.routes_inventory import router as inventory_router
from .api.routes_jobs import router as jobs_router
from .api.routes_knowledge import router as knowledge_router
from .api.routes_media_pipeline import router as media_pipeline_router
from .api.routes_nginx_proxy_manager import alias_router as npm_alias_router
from .api.routes_nginx_proxy_manager import router as npm_router
from .api.routes_notifications import router as notifications_router
from .api.routes_observability import router as observability_router
from .api.routes_playbooks import router as playbooks_router
from .api.routes_plex import router as plex_router
from .api.routes_policies import router as policies_router
from .api.routes_prowlarr import router as prowlarr_router
from .api.routes_privatemuse_artifacts import router as privatemuse_artifacts_router
from .api.routes_radarr import router as radarr_router
from .api.routes_runtime import router as runtime_router
from .api.routes_safety_center import router as safety_center_router
from .api.routes_setup import router as setup_router
from .api.routes_sonarr import router as sonarr_router
from .api.routes_stacks import router as stacks_router
from .api.routes_system import router as system_router
from .api.routes_tautulli import router as tautulli_router
from .api.routes_unraid import router as unraid_router
from .api.routes_voice import router as voice_router
from .api.routes_voice_operator import router as voice_operator_router
from .api.routes_widefrog import router as widefrog_router
from .core.config import settings
from .core.security import require_operator_request, require_service_request, voice_auth
from .core.version import APP_VERSION
from .db import session as db_session
from .db.init_db import init_db
from .mcp_server import (
appops_mcp,
mcp_http_app,
restore_tool_metrics,
set_session_running,
)
from .services import knowledge_service
from .services.discovery_scheduler import run_periodic_discovery
from .services.maintenance_scheduler import (
persist_observability,
run_periodic_maintenance,
)
from .services.request_metrics import record_request, restore_request_metrics
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
try:
with Session(db_session.engine) as session:
restore_request_metrics(session)
restore_tool_metrics(session)
knowledge_service.ensure_index_current(session)
except Exception: # noqa: BLE001 - RAG must degrade without taking down the control plane.
logger.exception("Knowledge index startup refresh failed")
# A zero interval is an explicit opt-out. In particular, development and
# test instances must not run background reconciliation merely because the
# ASGI lifespan has started.
discovery_task = (
asyncio.create_task(run_periodic_discovery(settings.discovery_interval_seconds))
if settings.discovery_interval_seconds > 0
else None
)
maintenance_enabled = any(
(
settings.notification_interval_seconds,
settings.connector_health_interval_seconds,
settings.observability_persist_interval_seconds,
settings.restore_drill_interval_seconds,
settings.mcp_canary_interval_seconds if settings.mcp_enabled else 0,
)
)
maintenance_task = asyncio.create_task(run_periodic_maintenance()) if maintenance_enabled else None
async with AsyncExitStack() as stack:
if settings.mcp_enabled:
await stack.enter_async_context(appops_mcp.session_manager.run())
set_session_running(True)
try:
yield
finally:
set_session_running(False)
if discovery_task:
discovery_task.cancel()
if maintenance_task:
maintenance_task.cancel()
if discovery_task:
try:
await discovery_task
except asyncio.CancelledError:
pass
if maintenance_task:
try:
await maintenance_task
except asyncio.CancelledError:
pass
await asyncio.to_thread(persist_observability)
app = FastAPI(title=settings.app_title, version=APP_VERSION, lifespan=lifespan)
_OPERATOR_GET_PREFIXES = (
"/api/admin/",
"/api/operator/",
"/api/settings/secrets",
)
_MUTATION_WINDOWS: dict[str, deque[float]] = defaultdict(deque)
_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
_MAX_MACHINE_BODY_BYTES = 1_048_576
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
def _consume_mutation_budget(identity: str) -> bool:
now = monotonic()
window = _MUTATION_WINDOWS[identity]
while window and now - window[0] >= 60:
window.popleft()
if len(window) >= settings.mutation_rate_limit_per_minute:
return False
window.append(now)
return True
class MachineBodyLimitMiddleware:
"""Apply a real streaming cap before machine-interface body parsing."""
def __init__(self, app: ASGIApp, max_body_bytes: int = _MAX_MACHINE_BODY_BYTES):
self.app = app
self.max_body_bytes = max_body_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
path = str(scope.get("path", ""))
method = str(scope.get("method", "GET")).upper()
is_machine_post = method not in _SAFE_METHODS and (
path == "/mcp" or path.startswith("/mcp/")
or path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
)
if scope["type"] != "http" or not is_machine_post:
await self.app(scope, receive, send)
return
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
chunk = message.get("body", b"")
body.extend(chunk)
if len(body) > self.max_body_bytes:
response = JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"})
await response(scope, receive, send)
return
if not message.get("more_body", False):
break
replayed = False
async def replay_receive() -> Message:
nonlocal replayed
if replayed:
return {"type": "http.disconnect"}
replayed = True
return {"type": "http.request", "body": bytes(body), "more_body": False}
await self.app(scope, replay_receive, send)
@app.middleware("http")
async def private_operator_boundary(request, call_next):
request_started = monotonic()
supplied_request_id = request.headers.get("X-Request-ID", "")
request_id = supplied_request_id if _REQUEST_ID_PATTERN.fullmatch(supplied_request_id) else uuid4().hex
path = request.url.path
def finalize_response(response):
route = request.scope.get("route")
route_path = getattr(route, "path", path)
duration_ms = (monotonic() - request_started) * 1000
record_request(method=request.method, path=str(route_path), status=response.status_code, duration_ms=duration_ms)
response.headers.setdefault("X-Request-ID", request_id)
response.headers.setdefault("Server-Timing", f"app;dur={duration_ms:.2f}")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; "
"img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; "
"script-src 'self'; connect-src 'self' http: https: ws: wss:",
)
return response
is_api = path == "/api" or path.startswith("/api/")
is_voice = path.startswith("/api/voice/")
is_mcp = path == "/mcp" or path.startswith("/mcp/")
is_n8n = path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
is_operator_session_status = path == "/api/operator/session" and request.method in _SAFE_METHODS
if is_mcp or is_n8n:
try:
require_service_request(request, "mcp" if is_mcp else "n8n")
except HTTPException as exc:
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
if is_mcp or is_n8n:
content_length = request.headers.get("content-length")
if content_length and content_length.isdigit() and int(content_length) > 1_048_576:
return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"}))
needs_operator = is_api and not is_voice and not is_n8n and not is_operator_session_status and (
request.method not in _SAFE_METHODS
or any(path.startswith(prefix) for prefix in _OPERATOR_GET_PREFIXES)
)
if needs_operator:
content_length = request.headers.get("content-length")
if content_length and content_length.isdigit() and int(content_length) > 1_048_576:
return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"}))
try:
require_operator_request(request)
except HTTPException as exc:
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
is_read_only_n8n_query = is_n8n and path == "/api/integrations/n8n/knowledge/query"
should_rate_limit = request.method not in _SAFE_METHODS and not is_read_only_n8n_query and (
is_n8n or (needs_operator and settings.operator_auth_required)
)
if should_rate_limit:
client_host = request.client.host if request.client else "unknown"
identity = f"n8n:{client_host}" if is_n8n else f"operator:{client_host}"
if not _consume_mutation_budget(identity):
return finalize_response(JSONResponse(
status_code=429,
content={"detail": "Mutation rate limit exceeded"},
headers={"Retry-After": "60"},
))
response = await call_next(request)
return finalize_response(response)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin, "http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(MachineBodyLimitMiddleware)
app.include_router(health_router)
app.include_router(system_router)
app.include_router(apps_router)
app.include_router(containers_router)
app.include_router(stacks_router)
app.include_router(unraid_router)
app.include_router(connectors_router)
app.include_router(inventory_router)
app.include_router(gitea_actions_router)
app.include_router(playbooks_router)
app.include_router(policies_router)
app.include_router(sonarr_router)
app.include_router(radarr_router)
app.include_router(prowlarr_router)
app.include_router(privatemuse_artifacts_router)
app.include_router(deluge_router)
app.include_router(plex_router)
app.include_router(tautulli_router)
app.include_router(media_pipeline_router)
app.include_router(authentik_router)
app.include_router(npm_router)
app.include_router(npm_alias_router)
app.include_router(widefrog_router)
app.include_router(actions_router)
app.include_router(approvals_router)
app.include_router(audit_router)
app.include_router(jobs_router)
app.include_router(observability_router)
app.include_router(safety_center_router)
app.include_router(notifications_router)
app.include_router(voice_router, dependencies=[Depends(voice_auth)])
app.include_router(voice_operator_router)
app.include_router(ai_router)
app.include_router(ai_security_router)
app.include_router(ai_remediation_router)
app.include_router(setup_router)
app.include_router(runtime_router)
app.include_router(deployments_router)
app.include_router(enrollments_router)
app.include_router(integrations_router)
app.include_router(n8n_router)
app.include_router(knowledge_router)
app.mount("/mcp", mcp_http_app)
@app.get('/api')
def index():
return {'name': 'unraid-appops-gateway', 'status': 'ok'}
def _static_root() -> Path:
root = Path(settings.static_dir)
if not root.is_absolute():
root = Path.cwd() / root
return root
static_root = _static_root()
assets_root = static_root / 'assets'
if assets_root.exists():
app.mount('/assets', StaticFiles(directory=str(assets_root)), name='assets')
def _index_response(index_file: Path) -> FileResponse:
return FileResponse(
index_file,
headers={
'Cache-Control': 'no-store',
},
)
@app.get('/{full_path:path}', include_in_schema=False)
def serve_frontend(full_path: str):
if full_path.startswith('api/'):
raise HTTPException(status_code=404, detail='api_route_not_found')
index_file = static_root / 'index.html'
requested = static_root / full_path
if full_path and requested.exists() and requested.is_file():
return FileResponse(requested)
if index_file.exists():
return _index_response(index_file)
return {'name': 'unraid-appops-gateway', 'status': 'api_only', 'frontend': 'not_built'}
+839
View File
@@ -0,0 +1,839 @@
from __future__ import annotations
import hashlib
import json
import os
from collections import defaultdict
from datetime import UTC, datetime
from functools import wraps
from pathlib import Path
from threading import Lock
from time import perf_counter
from typing import Any
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from sqlmodel import Session, select
from .api.routes_actions import action_registry as build_action_registry
from .api.routes_system import system_summary as build_system_summary
from .connectors.gitea_actions import GiteaActionsConnector
from .core.config import settings
from .core.redaction import redact_message, redact_value
from .core.time import utc_now
from .core.version import APP_VERSION
from .db.models import AppSetting, AuditEvent
from .db.session import engine
from .schemas.deployment import DeploymentPlanV1
from .schemas.enrollment import EnrollmentPlanV1
from .services import knowledge_service, mcp_operations, safety_center_service
from .services.action_execution import mcp_execution_supported
from .services.action_registry import ACTION_RISK, ACTIONS, action_capability
from .services.app_registry import list_active_apps
from .services.audit_logger import log_event
from .services.deployment_broker import DeploymentBrokerClient
from .services.deployment_control_plane import (
DeploymentControlError,
DeploymentControlPlane,
)
from .services.enrollment_plane import EnrollmentError, EnrollmentPlane
from .services.gitea_project_auto_registration import GiteaProjectAutoRegistration
from .services.inventory_discovery import list_inventory_containers
from .services.policy_engine import evaluate_policy
from .services.privatemuse_artifact_installer import ArtifactInstallError
from .services.privatemuse_artifact_installer import install as install_artifact_file
from .services.privatemuse_artifact_installer import preflight as artifact_preflight
from .services.runtime_service import runtime_info
_SESSION_RUNNING = False
_SESSION_VERIFIED_AT: str | None = None
_TOOL_METRICS: dict[str, dict[str, float | int | str | None]] = defaultdict(
lambda: {
"calls": 0,
"errors": 0,
"totalMs": 0.0,
"maxMs": 0.0,
"lastCalledAt": None,
}
)
_TOOL_METRICS_LOCK = Lock()
_TOOL_METRICS_SETTING = "observability:mcp_tool_metrics"
_CANARY_STATUS: dict[str, Any] = {
"status": "never",
"lastCheckedAt": None,
"durationMs": None,
}
_MCP_EXECUTION_LOCK = Lock()
_ARTIFACT_INSTALL_LOCK = Lock()
_SELF_UPGRADE_LOCK = Lock()
def _artifact_roots() -> tuple[Path, Path]:
return (Path(os.environ.get("APPOPS_PRIVATEMUSE_COMFYUI_MODEL_ROOT", "/mnt/privatemuse-comfyui-models")),
Path(os.environ.get("APPOPS_PRIVATEMUSE_ARTIFACT_RECEIPT_ROOT", "/data/privatemuse-artifact-receipts")))
_READ_ONLY_TOOL = ToolAnnotations(
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=False,
)
_OPERATION_RUN_TOOL = ToolAnnotations(
read_only_hint=False,
destructive_hint=True,
idempotent_hint=True,
open_world_hint=False,
)
_LEGACY_APPROVAL_EXECUTION_TOOL = ToolAnnotations(
read_only_hint=False,
destructive_hint=True,
idempotent_hint=True,
open_world_hint=False,
)
_CANCEL_TOOL = ToolAnnotations(
read_only_hint=False,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=False,
)
def _tracked(tool_name: str):
def decorate(function):
@wraps(function)
def wrapped(*args, **kwargs):
started = perf_counter()
failed = False
try:
return function(*args, **kwargs)
except Exception:
failed = True
raise
finally:
duration_ms = (perf_counter() - started) * 1000
with _TOOL_METRICS_LOCK:
metrics = _TOOL_METRICS[tool_name]
metrics["calls"] = int(metrics["calls"]) + 1
metrics["errors"] = int(metrics["errors"]) + int(failed)
metrics["totalMs"] = float(metrics["totalMs"]) + duration_ms
metrics["maxMs"] = max(float(metrics["maxMs"]), duration_ms)
metrics["lastCalledAt"] = datetime.now(UTC).isoformat()
return wrapped
return decorate
def set_session_running(value: bool) -> None:
global _SESSION_RUNNING, _SESSION_VERIFIED_AT
_SESSION_RUNNING = value
_SESSION_VERIFIED_AT = datetime.now(UTC).isoformat() if value else None
def runtime_status() -> dict[str, Any]:
with _TOOL_METRICS_LOCK:
tool_metrics = {
name: {
"calls": int(values["calls"]),
"errors": int(values["errors"]),
"averageMs": round(
float(values["totalMs"]) / max(1, int(values["calls"])), 2
),
"maxMs": round(float(values["maxMs"]), 2),
"lastCalledAt": values["lastCalledAt"],
}
for name, values in _TOOL_METRICS.items()
}
return {
"state": "listening"
if settings.mcp_enabled and _SESSION_RUNNING
else "configured"
if settings.mcp_enabled
else "disabled",
"lastVerifiedAt": _SESSION_VERIFIED_AT,
"toolCount": 25,
"toolMetrics": tool_metrics,
"canary": dict(_CANARY_STATUS),
}
def restore_tool_metrics(session: Session) -> int:
row = session.get(AppSetting, _TOOL_METRICS_SETTING)
payload = (row.value_json or {}).get("tools", {}) if row else {}
restored = 0
with _TOOL_METRICS_LOCK:
if _TOOL_METRICS:
return 0
for name, values in payload.items():
if not isinstance(values, dict):
continue
_TOOL_METRICS[str(name)[:120]] = {
"calls": max(0, int(values.get("calls", 0))),
"errors": max(0, int(values.get("errors", 0))),
"totalMs": max(0.0, float(values.get("totalMs", 0.0))),
"maxMs": max(0.0, float(values.get("maxMs", 0.0))),
"lastCalledAt": values.get("lastCalledAt"),
}
restored += 1
return restored
def persist_tool_metrics(session: Session) -> int:
with _TOOL_METRICS_LOCK:
tools = {name: dict(values) for name, values in _TOOL_METRICS.items()}
row = session.get(AppSetting, _TOOL_METRICS_SETTING) or AppSetting(
key=_TOOL_METRICS_SETTING
)
row.value_json = {"tools": tools, "persistedAt": datetime.now(UTC).isoformat()}
row.is_secret = False
row.updated_at = utc_now()
session.add(row)
session.commit()
return len(tools)
def run_read_only_canary() -> dict[str, Any]:
started = perf_counter()
try:
result = list_apps(limit=1)
status = "ok" if isinstance(result, dict) and "items" in result else "failed"
except Exception: # noqa: BLE001 - a canary must degrade safely on any read failure
status = "failed"
_CANARY_STATUS.update(
{
"status": status,
"lastCheckedAt": datetime.now(UTC).isoformat(),
"durationMs": round((perf_counter() - started) * 1000, 2),
}
)
return dict(_CANARY_STATUS)
appops_mcp = MCPServer(
"Unraid AppOps Gateway",
version=APP_VERSION,
instructions=(
"Private, governed AppOps context. Use read-only tools for inspection. "
"Authenticated MCP mutations are evaluated against central AppOps policy and execute immediately "
"through an allowlisted typed executor when possible. MCP never asks the operator for a human approval: "
"blocked actions stay blocked and actions without a typed executor fail closed. Never invent actions, "
"targets, policy state or execution results. Legacy approval status/execute tools exist only for approval "
"records created before this no-human-approval MCP contract."
),
)
@appops_mcp.tool(name="gitea.actions.runners.status", annotations=_READ_ONLY_TOOL)
@_tracked("gitea.actions.runners.status")
def gitea_actions_runners_status() -> dict[str, Any]:
"""Inspect Gitea Actions runners and queue age; this never restarts a runner."""
return _json(GiteaActionsConnector().snapshot())
def _json(value: Any) -> Any:
if hasattr(value, "model_dump"):
return _json(value.model_dump(mode="json"))
if isinstance(value, list):
return [_json(item) for item in value]
if isinstance(value, dict):
return redact_value({str(key): _json(item) for key, item in value.items()})
return redact_value(value)
@appops_mcp.tool(name="gitea.projects.catalog", annotations=_READ_ONLY_TOOL)
@_tracked("gitea.projects.catalog")
def gitea_projects_catalog() -> dict[str, Any]:
"""List the persisted owned Gitea project catalog and deployment registration state."""
with Session(engine) as session:
return _json(GiteaProjectAutoRegistration.catalog(session))
@appops_mcp.tool(name="gitea.projects.reconcile", annotations=_OPERATION_RUN_TOOL)
@_tracked("gitea.projects.reconcile")
def gitea_projects_reconcile() -> dict[str, Any]:
"""Reconcile all owned Gitea projects through the bounded registration plane."""
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
raise RuntimeError("server_operation_capacity_busy")
try:
with Session(engine) as session:
return _json(GiteaProjectAutoRegistration().sync(session, force=True))
finally:
_MCP_EXECUTION_LOCK.release()
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("get_system_summary")
def get_system_summary() -> dict[str, Any]:
"""Get the current AppOps, Unraid and Portainer summary without changing state."""
with Session(engine) as session:
return _json(build_system_summary(session))
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("list_apps")
def list_apps(limit: int = 100) -> dict[str, Any]:
"""List managed applications and their current policy-safe metadata."""
with Session(engine) as session:
items = list_active_apps(session)[: max(1, min(limit, 250))]
return {"items": [_json(item) for item in items], "count": len(items)}
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("list_containers")
def list_containers(limit: int = 100) -> dict[str, Any]:
"""List the latest inventory container view; no Docker socket or shell access is used."""
with Session(engine) as session:
items = list_inventory_containers(session, include_ignored=False)[
: max(1, min(limit, 250))
]
return {"items": _json(items), "count": len(items)}
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("get_safety_findings")
def get_safety_findings() -> dict[str, Any]:
"""Get current safety summary and redacted findings."""
with Session(engine) as session:
return {
"summary": _json(safety_center_service.get_summary(session)),
"findings": _json(safety_center_service.get_findings(session)),
}
def _mcp_action_registry(session: Session) -> dict[str, Any]:
"""Add the MCP execution posture without changing manual/voice registry semantics."""
registry = build_action_registry(session)
direct = 0
blocked_mutations = 0
read_only = 0
items: list[dict[str, Any]] = []
for raw in registry.get("items", []):
item = dict(raw)
action = str(item.get("id") or "")
target = str(item.get("target") or "")
capability = str(item.get("capability") or "operator")
mcp_policy = evaluate_policy(
session, action=action, target=target, source="automation"
)
original_decision = str(mcp_policy.get("decision") or "blocked")
if capability == "read_only":
mcp_mode = "read_only"
mcp_supported = False
mcp_decision = original_decision
read_only += 1
elif original_decision == "blocked":
mcp_mode = "blocked"
mcp_supported = False
mcp_decision = "blocked"
blocked_mutations += 1
elif mcp_execution_supported(action):
mcp_mode = "direct_typed"
mcp_supported = True
mcp_decision = "allowed"
direct += 1
else:
mcp_mode = "blocked_no_typed_executor"
mcp_supported = False
mcp_decision = "blocked"
blocked_mutations += 1
items.append(
{
**item,
"manualPolicyDecision": item.get("policyDecision"),
"manualExecutionMode": item.get("executionMode"),
"mcpOriginalPolicyDecision": original_decision,
"mcpPolicyDecision": mcp_decision,
"mcpExecutionMode": mcp_mode,
"mcpExecutionSupported": mcp_supported,
"mcpApprovalRequired": False,
}
)
summary = {
**dict(registry.get("summary", {})),
"mcpDirectExecutable": direct,
"mcpBlockedMutations": blocked_mutations,
"mcpReadOnly": read_only,
"mcpApprovalRequired": 0,
}
return {"summary": summary, "items": items}
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("get_action_registry")
def get_action_registry() -> dict[str, Any]:
"""List typed AppOps actions plus the no-human-approval MCP execution posture."""
with Session(engine) as session:
return _json(_mcp_action_registry(session))
def _mcp_preview_mode(action: str, decision: str) -> tuple[str, bool]:
capability = action_capability(action)
if capability == "read_only":
return "read_only", False
if decision == "blocked":
return "blocked", False
if mcp_execution_supported(action):
return "direct_typed", True
return "blocked_no_typed_executor", False
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("preview_action")
def preview_action(action: str, target: str) -> dict[str, Any]:
"""Preview policy for an action. This never executes anything or creates an approval."""
safe_action = action[:160]
with Session(engine) as session:
decision = evaluate_policy(
session, action=safe_action, target=target[:240], source="automation"
)
mcp_mode, mcp_supported = _mcp_preview_mode(
safe_action, str(decision["decision"])
)
return {
"action": safe_action,
"target": target[:240],
"knownAction": safe_action in ACTIONS,
"risk": decision.get("risk", ACTION_RISK.get(safe_action, "critical")),
"policyDecision": decision["decision"],
"reason": redact_message(str(decision["reason"])),
"executed": False,
"mcpApprovalRequired": False,
"mcpExecutionSupported": mcp_supported,
"mcpExecutionMode": mcp_mode,
}
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("search_audit")
def search_audit(query: str = "", limit: int = 30) -> dict[str, Any]:
"""Search recent redacted audit metadata."""
needle = query.casefold().strip()[:200]
with Session(engine) as session:
rows = session.exec(
select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(500)
).all()
matches = []
for row in rows:
value = f"{row.action_id} {row.target} {row.source} {row.result} {row.reason or ''}".casefold()
if needle and needle not in value:
continue
matches.append(
{
"id": row.id,
"timestamp": row.timestamp.isoformat(),
"actionId": row.action_id,
"target": row.target,
"source": row.source,
"risk": row.risk,
"policyDecision": row.policy_decision,
"result": row.result,
"reason": redact_message(str(row.reason or "")),
}
)
if len(matches) >= max(1, min(limit, 100)):
break
return _json({"items": matches, "count": len(matches)})
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
@_tracked("query_knowledge")
def query_knowledge(question: str, limit: int = 6) -> dict[str, Any]:
"""Retrieve grounded AppOps documentation and live context with citations."""
with Session(engine) as session:
return _json(
knowledge_service.query(session, question, limit=max(1, min(limit, 10)))
)
@appops_mcp.tool(name="server.operation.request", annotations=_OPERATION_RUN_TOOL)
@_tracked("server.operation.request")
def request_operation(
action: str,
target: str,
reason: str,
idempotencyKey: str,
) -> dict[str, Any]:
"""Run an exact governed server operation immediately when policy permits; MCP never creates a human approval."""
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
raise RuntimeError("server_operation_capacity_busy")
try:
with Session(engine) as session:
return _json(
mcp_operations.request_operation(
session,
action=action,
target=target,
reason=reason,
idempotency_key=idempotencyKey,
)
)
finally:
_MCP_EXECUTION_LOCK.release()
@appops_mcp.tool(name="privatemuse.artifact.install.preflight", annotations=_READ_ONLY_TOOL)
@_tracked("privatemuse.artifact.install.preflight")
def privatemuse_artifact_install_preflight(
artifact_type: str, source_url: str, sha256: str, filename: str,
source_id: str, license: str, reason: str,
) -> dict[str, Any]:
"""Check one pinned ComfyUI artifact against the fixed persistent model mount."""
payload = {"artifact_type": artifact_type, "source_url": source_url, "sha256": sha256,
"filename": filename, "source_id": source_id, "license": license}
if not reason.strip() or len(reason) > 1000:
raise ArtifactInstallError("a bounded reason is required")
model_root, _ = _artifact_roots()
return _json({**artifact_preflight(payload, model_root=model_root), "executed": False})
@appops_mcp.tool(name="privatemuse.artifact.install", annotations=_OPERATION_RUN_TOOL)
@_tracked("privatemuse.artifact.install")
def privatemuse_artifact_install(
artifact_type: str, source_url: str, sha256: str, filename: str,
source_id: str, license: str, reason: str, expected_preflight_fingerprint: str,
idempotency_key: str,
) -> dict[str, Any]:
"""Install one immutable SHA-pinned artifact into its server-selected ComfyUI directory."""
if not _ARTIFACT_INSTALL_LOCK.acquire(blocking=False):
raise ArtifactInstallError("artifact installation already active")
try:
if not reason.strip() or len(reason) > 1000 or not idempotency_key.strip() or len(idempotency_key) > 200:
raise ArtifactInstallError("bounded reason and idempotency key are required")
payload = {"artifact_type": artifact_type, "source_url": source_url, "sha256": sha256,
"filename": filename, "source_id": source_id, "license": license}
model_root, receipt_root = _artifact_roots()
key_hash = hashlib.sha256(idempotency_key.encode()).hexdigest()
request_fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
request_dir = receipt_root / "mcp-requests"
request_path = request_dir / f"{key_hash}.json"
if request_path.exists():
previous = json.loads(request_path.read_text(encoding="utf-8"))
if previous.get("request_fingerprint") != request_fingerprint:
raise ArtifactInstallError("idempotency key already used for different artifact")
return _json(previous["result"])
checked = artifact_preflight(payload, model_root=model_root)
if checked["fingerprint"] != expected_preflight_fingerprint:
raise ArtifactInstallError("preflight changed; run preflight again")
result = install_artifact_file(payload, model_root=model_root, receipt_root=receipt_root)
with Session(engine) as session:
audit = log_event(session, source="action", identity="mcp", action_id="privatemuse.comfyui.install_artifact",
target="privatemuse/comfyui", risk="high", policy_decision="typed_contract_allowed",
reason=reason, result="success", after_state={"status": result["status"], "sha256": result["sha256"], "bytes": result["bytes"]},
redacted_metadata={"filename": filename, "source_id": source_id})
result["auditId"] = audit.id
request_dir.mkdir(parents=True, exist_ok=True)
temp_path = request_dir / f".{key_hash}.{os.getpid()}.tmp"
with temp_path.open("w", encoding="utf-8") as handle:
json.dump({"request_fingerprint": request_fingerprint, "result": result}, handle, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, request_path)
return _json(result)
finally:
_ARTIFACT_INSTALL_LOCK.release()
@appops_mcp.tool(name="server.operation.status", annotations=_READ_ONLY_TOOL)
@_tracked("server.operation.status")
def operation_status(approvalId: str) -> dict[str, Any]:
"""Legacy: read an approval record created before MCP no-human-approval mode."""
with Session(engine) as session:
return _json(mcp_operations.operation_status(session, approvalId))
@appops_mcp.tool(name="appops.deployment.status", annotations=_READ_ONLY_TOOL)
@_tracked("appops.deployment.status")
def appops_deployment_status() -> dict[str, Any]:
"""Report the exact OCI source revision baked into this AppOps image."""
with Session(engine) as session:
info = runtime_info(session)
return _json({"repository": info["sourceRepository"], "revision": info["sourceRevision"],
"sourceTree": info["sourceTree"], "health": "ok" if info["dbStatus"] == "ok" and info["liveReady"] else "degraded"})
@appops_mcp.tool(name="appops.self_upgrade.preflight", annotations=_READ_ONLY_TOOL)
@_tracked("appops.self_upgrade.preflight")
def appops_self_upgrade_preflight(
repository: str, revision: str, reason: str,
expected_current_revision: str | None = None,
) -> dict[str, Any]:
"""Check a fixed protected-main AppOps revision in the private controller."""
if repository != "jens/unraid-appops-gateway":
raise ValueError("repository must be jens/unraid-appops-gateway")
return _json(DeploymentBrokerClient().appops_self_upgrade_preflight(
revision, reason, expected_current_revision,
))
@appops_mcp.tool(name="appops.self_upgrade.apply", annotations=_OPERATION_RUN_TOOL)
@_tracked("appops.self_upgrade.apply")
def appops_self_upgrade_apply(
repository: str, revision: str, reason: str,
expected_preflight_fingerprint: str, idempotency_key: str,
expected_current_revision: str | None = None,
) -> dict[str, Any]:
"""Queue one exact revision through the controller's validated single-instance executor."""
if repository != "jens/unraid-appops-gateway":
raise ValueError("repository must be jens/unraid-appops-gateway")
if not _SELF_UPGRADE_LOCK.acquire(blocking=False):
raise RuntimeError("appops self-upgrade already active")
try:
with Session(engine) as session:
audit = log_event(
session, source="action", identity="mcp", action_id="appops.self_upgrade",
target="Unraid-AppOps-Gateway", risk="high",
policy_decision="typed_contract_requested",
reason="Typed self-upgrade reason SHA-256 " + hashlib.sha256(reason.encode("utf-8")).hexdigest(),
result="requested",
after_state={"revision": revision},
redacted_metadata={"repository": repository},
)
result = DeploymentBrokerClient().appops_self_upgrade_apply(
revision, reason, expected_current_revision,
expected_preflight_fingerprint, idempotency_key,
)
result["auditId"] = audit.id
return _json(result)
finally:
_SELF_UPGRADE_LOCK.release()
@appops_mcp.tool(name="appops.self_upgrade.status", annotations=_READ_ONLY_TOOL)
@_tracked("appops.self_upgrade.status")
def appops_self_upgrade_status(operation_id: str) -> dict[str, Any]:
"""Read the controller's durable progress and exact deployment receipt."""
return _json(DeploymentBrokerClient().appops_self_upgrade_status(operation_id))
@appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL)
@_tracked("server.operation.execute")
def execute_operation(
approvalId: str, expectedActionFingerprint: str
) -> dict[str, Any]:
"""Legacy: execute an already-approved pre-existing approval record; new MCP calls never create one."""
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
raise RuntimeError("server_operation_capacity_busy")
try:
with Session(engine) as session:
return _json(
mcp_operations.execute_operation(
session,
approval_id=approvalId,
expected_action_fingerprint=expectedActionFingerprint,
)
)
finally:
_MCP_EXECUTION_LOCK.release()
@appops_mcp.tool(name="server.job.status", annotations=_READ_ONLY_TOOL)
@_tracked("server.job.status")
def job_status(jobId: str) -> dict[str, Any]:
"""Read bounded progress and the redacted result of one server-side AppOps job."""
with Session(engine) as session:
return _json(mcp_operations.job_status(session, jobId))
@appops_mcp.tool(name="server.job.cancel", annotations=_CANCEL_TOOL)
@_tracked("server.job.cancel")
def cancel_job(jobId: str) -> dict[str, Any]:
"""Cancel a queued or running AppOps job record; completed jobs remain immutable."""
with Session(engine) as session:
return _json(mcp_operations.cancel_operation_job(session, jobId))
@appops_mcp.tool(name="deployment.preflight", annotations=_READ_ONLY_TOOL)
@_tracked("deployment.preflight")
def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
"""Validate one strict DeploymentPlanV1 without touching application runtime state."""
try:
typed_plan = DeploymentPlanV1.model_validate(plan)
with Session(engine) as session:
return _json(
DeploymentControlPlane().execute(
session,
plan=typed_plan,
idempotency_key=idempotencyKey,
operation="preflight",
)
)
except (ValueError, DeploymentControlError) as exc:
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
@appops_mcp.tool(name="deployment.capabilities", annotations=_READ_ONLY_TOOL)
@_tracked("deployment.capabilities")
def deployment_capabilities() -> dict[str, Any]:
"""Read machine-actionable enrollment and deployment readiness without mutations."""
with Session(engine) as session:
return _json(EnrollmentPlane().capabilities(session))
@appops_mcp.tool(name="enrollment.preflight", annotations=_READ_ONLY_TOOL)
@_tracked("enrollment.preflight")
def enrollment_preflight(plan: dict[str, Any]) -> dict[str, Any]:
"""Read-only takeover-safe preflight for one strict EnrollmentPlanV1."""
try:
typed_plan = EnrollmentPlanV1.model_validate(plan)
with Session(engine) as session:
return _json(EnrollmentPlane().preflight(session, typed_plan))
except (ValueError, EnrollmentError) as exc:
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
@appops_mcp.tool(name="target.enroll", annotations=_OPERATION_RUN_TOOL)
@_tracked("target.enroll")
def target_enroll(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
"""Enroll exactly one clean-profile application identity; no arbitrary target is accepted."""
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
raise RuntimeError("server_operation_capacity_busy")
try:
typed_plan = EnrollmentPlanV1.model_validate(plan)
with Session(engine) as session:
return _json(EnrollmentPlane().enroll(session, typed_plan, idempotency_key=idempotencyKey))
except (ValueError, EnrollmentError) as exc:
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
finally:
_MCP_EXECUTION_LOCK.release()
@appops_mcp.tool(name="enrollment.status", annotations=_READ_ONLY_TOOL)
@_tracked("enrollment.status")
def enrollment_status(enrollmentId: str) -> dict[str, Any]:
"""Read a redacted durable enrollment state and its restart-safe job evidence."""
with Session(engine) as session:
try:
return _json(EnrollmentPlane().get_enrollment(session, enrollmentId))
except EnrollmentError as exc:
return {"status": "not_found", "reason": redact_message(str(exc))}
@appops_mcp.tool(name="deployment.apply", annotations=_OPERATION_RUN_TOOL)
@_tracked("deployment.apply")
def deployment_apply(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
"""Submit one preflighted typed deployment to the private controller."""
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
raise RuntimeError("server_operation_capacity_busy")
try:
typed_plan = DeploymentPlanV1.model_validate(plan)
with Session(engine) as session:
return _json(
DeploymentControlPlane().execute(
session,
plan=typed_plan,
idempotency_key=idempotencyKey,
operation="apply",
)
)
except (ValueError, DeploymentControlError) as exc:
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
finally:
_MCP_EXECUTION_LOCK.release()
@appops_mcp.tool(name="deployment.status", annotations=_READ_ONLY_TOOL)
@_tracked("deployment.status")
def deployment_status(jobId: str) -> dict[str, Any]:
"""Read durable deployment state and refresh a bounded broker request."""
with Session(engine) as session:
try:
return _json(DeploymentControlPlane().refresh(session, jobId))
except DeploymentControlError as exc:
return {"status": "not_found", "reason": redact_message(str(exc))}
@appops_mcp.tool(name="deployment.verify", annotations=_READ_ONLY_TOOL)
@_tracked("deployment.verify")
def deployment_verify(jobId: str) -> dict[str, Any]:
"""Refresh the controller receipt of an exact previously submitted deployment."""
return deployment_status(jobId)
@appops_mcp.tool(name="deployment.rollback", annotations=_OPERATION_RUN_TOOL)
@_tracked("deployment.rollback")
def deployment_rollback(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
"""Ask the controller to run its receipt-bound rollback for one typed plan."""
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
raise RuntimeError("server_operation_capacity_busy")
try:
typed_plan = DeploymentPlanV1.model_validate(plan)
if not typed_plan.rollback_enabled:
return {"status": "blocked", "executed": False, "reason": "rollback_disabled_by_typed_plan"}
with Session(engine) as session:
return _json(
DeploymentControlPlane().execute(
session,
plan=typed_plan,
idempotency_key=idempotencyKey,
operation="rollback",
)
)
except (ValueError, DeploymentControlError) as exc:
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
finally:
_MCP_EXECUTION_LOCK.release()
@appops_mcp.resource("appops://capabilities", mime_type="application/json")
def capabilities() -> str:
"""Describe the immutable security boundary of this MCP server."""
return json.dumps(
{
"scope": "governed_operations",
"transport": "streamable_http",
"mutationTools": True,
"directShell": False,
"dockerSocket": False,
"secrets": "excluded",
"execution": "typed AppOps executors immediately after central policy evaluation",
"approvalRequiredForMcpMutations": False,
"humanApprovalWorkflowExposedByMcp": False,
"blockedActionsRemainBlocked": True,
"unsupportedActionsFailClosed": True,
"legacyApprovalCompatibility": True,
"maxConcurrentExecutions": 1,
"deploymentControlPlane": {
"version": "v1",
"typedPlanOnly": True,
"rawCompose": False,
"directShell": False,
"brokerEvidenceRequired": True,
},
"enrollmentProvisioningPlane": {
"version": "v1",
"typedPlanOnly": True,
"profileControlled": True,
"rawCompose": False,
"bootstrapCredentials": "write_only",
"wildcardPolicies": False,
},
"giteaProjectRegistration": {
"version": "v1",
"automatic": True,
"catalogTool": "gitea.projects.catalog",
"reconcileTool": "gitea.projects.reconcile",
"wildcardDeploymentPolicy": False,
},
}
)
mcp_http_app = appops_mcp.streamable_http_app(
streamable_http_path="/",
stateless_http=True,
json_response=True,
max_request_body_size=1_048_576,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=list(settings.mcp_allowed_hosts or []),
allowed_origins=[settings.frontend_origin],
),
)
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
from .ai_security import Confidence, Severity
RemediationPlanStatus = Literal['draft', 'reviewed', 'approved_for_manual_execution', 'dismissed']
RemediationSourceType = Literal['security_finding', 'security_recommendation', 'media_issue', 'inventory', 'connector', 'policy', 'secret', 'unknown']
RemediationStepType = Literal[
'open_page',
'configure_secret',
'bind_connector',
'review_policy',
'request_approval',
'run_readonly_check',
'manual_external_change',
'blocked_action',
'documentation',
]
DryRunStatus = Literal['pass', 'warning', 'blocked']
class RemediationEvidence(BaseModel):
source: str
label: str
value: Any = None
redacted: bool = False
class RemediationApprovalRequirement(BaseModel):
actionId: str
target: str
reason: str
risk: str
policyDecision: str
class RemediationVerificationCheck(BaseModel):
label: str
endpoint: str
expectedResult: str
source: str
readOnly: bool = True
class RemediationStep(BaseModel):
id: str
label: str
description: str
type: RemediationStepType
actionId: str | None = None
target: str | None = None
href: str | None = None
policyDecision: str = 'allowed'
risk: str = 'low'
requiresApproval: bool = False
canExecute: bool = False
evidence: list[RemediationEvidence] = Field(default_factory=list)
class RemediationPlan(BaseModel):
id: str
title: str
description: str
sourceType: RemediationSourceType
sourceId: str
severity: Severity
confidence: Confidence
status: RemediationPlanStatus = 'draft'
affectedResources: list[str] = Field(default_factory=list)
prerequisites: list[str] = Field(default_factory=list)
steps: list[RemediationStep] = Field(default_factory=list)
verificationChecks: list[RemediationVerificationCheck] = Field(default_factory=list)
blockedActions: list[RemediationStep] = Field(default_factory=list)
approvalRequirements: list[RemediationApprovalRequirement] = Field(default_factory=list)
riskAssessment: str
estimatedImpact: str
rollbackNotes: str
auditPreview: str
createdAt: str
updatedAt: str
executable: bool = False
class RemediationPlanCollection(BaseModel):
status: str
generatedAt: str
total: int
plans: list[RemediationPlan]
class RemediationDryRunStepResult(BaseModel):
stepId: str
label: str
actionId: str | None = None
target: str | None = None
policyDecision: str
risk: str
status: DryRunStatus
reason: str
canExecute: bool = False
class RemediationDryRunResult(BaseModel):
planId: str
status: DryRunStatus
executed: bool = False
reasons: list[str] = Field(default_factory=list)
prerequisites: list[str] = Field(default_factory=list)
unresolvedBlockers: list[str] = Field(default_factory=list)
blockedActions: list[RemediationApprovalRequirement] = Field(default_factory=list)
approvalRequirements: list[RemediationApprovalRequirement] = Field(default_factory=list)
verificationChecks: list[RemediationVerificationCheck] = Field(default_factory=list)
stepResults: list[RemediationDryRunStepResult] = Field(default_factory=list)
class RemediationApprovalProposal(BaseModel):
planId: str
created: bool
executed: bool = False
approvalId: str | None = None
proposals: list[RemediationApprovalRequirement] = Field(default_factory=list)
message: str
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
Severity = Literal['info', 'low', 'medium', 'high', 'critical']
Confidence = Literal['low', 'medium', 'high']
ReviewStatus = Literal['healthy', 'warning', 'critical', 'partial', 'unknown']
FindingStatus = Literal['open', 'acknowledged', 'dismissed', 'resolved']
RecommendationStatus = Literal['open', 'snoozed', 'dismissed', 'accepted']
SecurityReviewScope = Literal['all', 'exposure', 'auth', 'secrets', 'policies', 'connectors', 'audit', 'unraid', 'media']
class SecurityEvidence(BaseModel):
source: str
label: str
value: Any = None
observed: Any = None
expected: Any = None
referenceId: str | None = None
redacted: bool = False
class SecuritySuggestedAction(BaseModel):
label: str
type: Literal['link', 'policy_draft', 'approval_request', 'open_page', 'run_readonly_check']
actionId: str | None = None
target: str | None = None
policyDecision: str = 'allowed'
risk: str = 'low'
href: str | None = None
executable: bool = False
class SecurityFinding(BaseModel):
id: str
title: str
description: str
severity: Severity
confidence: Confidence
category: Literal['exposure', 'auth_coverage', 'coverage', 'connector_config', 'secrets', 'policies', 'connectors', 'audit', 'inventory', 'unraid', 'media']
affectedResources: list[str] = Field(default_factory=list)
evidence: list[SecurityEvidence] = Field(default_factory=list)
suggestedActions: list[SecuritySuggestedAction] = Field(default_factory=list)
status: FindingStatus = 'open'
createdAt: str
riskReason: str
links: list[dict[str, Any]] = Field(default_factory=list)
class SecurityRecommendation(BaseModel):
id: str
title: str
description: str
severity: Severity
confidence: Confidence
evidence: list[SecurityEvidence] = Field(default_factory=list)
affectedResources: list[str] = Field(default_factory=list)
suggestedAction: str
suggestedActions: list[SecuritySuggestedAction] = Field(default_factory=list)
policyDecision: str = 'allowed'
actionTarget: str | None = None
canAutoApply: bool = False
status: RecommendationStatus = 'open'
class SecurityReviewSummary(BaseModel):
status: ReviewStatus
score: int
generatedAt: str
dataCompleteness: dict[str, Any]
criticalFindings: int
highFindings: int = 0
mediumFindings: int = 0
warningFindings: int
infoFindings: int
recommendationsCount: int
connectedSources: list[str]
scoreBreakdown: dict[str, Any] = Field(default_factory=dict)
class SecurityReviewResult(BaseModel):
summary: SecurityReviewSummary
findings: list[SecurityFinding]
recommendations: list[SecurityRecommendation]
+372
View File
@@ -0,0 +1,372 @@
"""Strict, secret-free contracts for managed Unraid deployments.
This module intentionally models a small deployment vocabulary. It is not a
Compose parser, a Docker API wrapper, or a generic remote-command interface.
The controller derives filesystem paths and runtime arguments from this plan
after it has accepted the immutable repository revision.
"""
from __future__ import annotations
import posixpath
import re
from typing import Any, Literal
import yaml
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$")
_REVISION = re.compile(r"^[0-9a-f]{40}$")
_VERSION = re.compile(r"^v?(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){2}(?:-[0-9A-Za-z.-]+)?$")
_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$")
_DOMAIN = re.compile(
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$"
)
_SECRET_REFERENCE = re.compile(r"^[A-Z][A-Z0-9_]{2,120}$")
_SAFE_PATH = re.compile(
r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))(?:[A-Za-z0-9]|\.[A-Za-z0-9])[A-Za-z0-9_./-]{0,239}$"
)
TRUSTED_APPCONFIG_ROOT = "/srv/example/appdata"
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
class DeploymentTopologyV1(_StrictModel):
"""The only runtime shape accepted by V1.
V1 is deliberately compose-only and always private behind the existing
proxy network. A public host binding, Docker socket or host networking is
outside this contract.
"""
kind: Literal["compose"] = "compose"
compose_file: str = ".itworx/deployment.yml"
compose_project: str = Field(min_length=2, max_length=63)
containers: tuple[str, ...] = Field(min_length=1, max_length=8)
proxy_network: str = Field(min_length=2, max_length=63)
internal_port: int = Field(ge=1, le=65535)
restart_policy: Literal["unless-stopped"] = "unless-stopped"
build: RepositoryBuildV1 | None = None
@field_validator("compose_file")
@classmethod
def safe_compose_file(cls, value: str) -> str:
if not _SAFE_PATH.fullmatch(value) or not value.endswith((".yml", ".yaml")):
raise ValueError("compose_file must be a safe repository-relative YAML path")
return value
@field_validator("compose_project", "proxy_network", "containers")
@classmethod
def safe_runtime_name(cls, value: str | tuple[str, ...]) -> str | tuple[str, ...]:
values = value if isinstance(value, tuple) else (value,)
if any(not _NAME.fullmatch(item) for item in values):
raise ValueError("runtime names must use the bounded deployment identifier format")
if len(set(values)) != len(values):
raise ValueError("container names must be unique")
return value
class RepositoryBuildV1(_StrictModel):
"""A controller-revalidated repository build, never a caller shell step."""
context: str = "."
dockerfile: str = "Dockerfile"
@field_validator("context")
@classmethod
def safe_context(cls, value: str) -> str:
if value != "." and not _SAFE_PATH.fullmatch(value):
raise ValueError("build context must be a safe repository-relative path")
return value
@field_validator("dockerfile")
@classmethod
def safe_dockerfile(cls, value: str) -> str:
if not _SAFE_PATH.fullmatch(value) or not value.endswith("Dockerfile"):
raise ValueError("dockerfile must be a safe repository-relative Dockerfile path")
return value
DeploymentTopologyV1.model_rebuild()
class ProxyHostV1(_StrictModel):
hostname: str
certificate_mode: Literal["existing", "managed"] = "managed"
force_ssl: bool = True
http2: bool = True
upload_limit_mb: int = Field(default=256, ge=1, le=2048)
read_timeout_seconds: int = Field(default=180, ge=15, le=900)
@field_validator("hostname")
@classmethod
def safe_hostname(cls, value: str) -> str:
normalized = value.lower().rstrip(".")
if not _DOMAIN.fullmatch(normalized) or normalized.startswith("-"):
raise ValueError("hostname must be a canonical DNS hostname")
return normalized
@model_validator(mode="after")
def require_https_redirect(self) -> ProxyHostV1:
if not self.force_ssl:
raise ValueError("managed proxy hosts must force HTTPS")
return self
class OidcRoleMappingV1(_StrictModel):
administrator_group: str = Field(min_length=1, max_length=120)
support_agent_group: str = Field(min_length=1, max_length=120)
read_only_group: str = Field(min_length=1, max_length=120)
@field_validator("administrator_group", "support_agent_group", "read_only_group")
@classmethod
def safe_group_name(cls, value: str) -> str:
if any(character in value for character in "\r\n\x00"):
raise ValueError("role group names may not contain control characters")
return value
class OidcProvisioningV1(_StrictModel):
provider: Literal["authentik"] = "authentik"
application_slug: str = Field(min_length=2, max_length=63)
redirect_path: str = "/signin-oidc"
role_claim: str = Field(default="groups", min_length=1, max_length=80)
client_secret_ref: str
roles: OidcRoleMappingV1
@field_validator("application_slug")
@classmethod
def safe_application_slug(cls, value: str) -> str:
if not _APP_ID.fullmatch(value):
raise ValueError("application_slug must be a bounded lower-case identifier")
return value
@field_validator("client_secret_ref")
@classmethod
def secret_reference_only(cls, value: str) -> str:
if not _SECRET_REFERENCE.fullmatch(value):
raise ValueError("client_secret_ref must reference a stored AppOps secret, never a value")
return value
@field_validator("redirect_path")
@classmethod
def safe_redirect_path(cls, value: str) -> str:
if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"):
raise ValueError("redirect_path must be a relative HTTPS callback path")
return value
class DeploymentPlanV1(_StrictModel):
"""A validated, immutable deployment intent for one managed application."""
api_version: Literal["itworx.deployment/v1"] = "itworx.deployment/v1"
application_id: str
repository: str
revision: str
release_version: str
image_repository: str
image_tag: str
topology: DeploymentTopologyV1
proxy: ProxyHostV1 | None = None
oidc: OidcProvisioningV1 | None = None
health_path: str = "/healthz"
health_timeout_seconds: int = Field(default=180, ge=15, le=900)
broker_secret_ref: str
rollback_enabled: bool = True
@field_validator("application_id")
@classmethod
def safe_app_id(cls, value: str) -> str:
if not _APP_ID.fullmatch(value):
raise ValueError("application_id must be a bounded lower-case identifier")
return value
@field_validator("repository")
@classmethod
def safe_repository(cls, value: str) -> str:
normalized = value.lower()
if not _REPOSITORY.fullmatch(normalized):
raise ValueError("repository must be a canonical Jens repository")
return normalized
@field_validator("revision")
@classmethod
def immutable_revision(cls, value: str) -> str:
normalized = value.lower()
if not _REVISION.fullmatch(normalized):
raise ValueError("revision must be a full lower-case 40-character Git SHA")
return normalized
@field_validator("release_version")
@classmethod
def semantic_version(cls, value: str) -> str:
if not _VERSION.fullmatch(value):
raise ValueError("release_version must be a semantic version")
return value.removeprefix("v")
@field_validator("image_repository")
@classmethod
def safe_image_repository(cls, value: str) -> str:
normalized = value.lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9._/-]{1,180}", normalized):
raise ValueError("image_repository is not a safe image identifier")
return normalized
@field_validator("image_tag")
@classmethod
def immutable_image_tag(cls, value: str) -> str:
if value.lower() == "latest" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value):
raise ValueError("image_tag must be an explicit non-latest tag")
return value
@field_validator("broker_secret_ref")
@classmethod
def broker_secret_reference_only(cls, value: str) -> str:
if not _SECRET_REFERENCE.fullmatch(value):
raise ValueError("broker_secret_ref must reference a stored AppOps secret, never a value")
return value
@field_validator("health_path")
@classmethod
def safe_health_path(cls, value: str) -> str:
if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"):
raise ValueError("health_path must be a relative path")
return value
@model_validator(mode="after")
def validate_cross_field_contract(self) -> DeploymentPlanV1:
if self.application_id not in self.topology.containers:
raise ValueError("application_id must be one of the managed container names")
if self.topology.compose_project != self.application_id:
raise ValueError("compose_project must equal application_id in DeploymentPlanV1")
if self.image_tag not in {self.release_version, self.revision[:12]}:
raise ValueError("image_tag must be the release version or immutable revision prefix")
if self.oidc and not self.proxy:
raise ValueError("OIDC provisioning requires a managed HTTPS proxy host")
return self
@property
def appdata_path(self) -> str:
return f"{TRUSTED_APPCONFIG_ROOT}/{self.application_id}"
@property
def public_url(self) -> str | None:
return f"https://{self.proxy.hostname}" if self.proxy else None
def validate_compose_runtime_contract(plan: DeploymentPlanV1, raw_yaml: str) -> dict[str, Any]:
"""Validate a repository compose artifact against the narrow V1 policy.
The result is suitable for evidence/audit storage and deliberately contains
no Compose environment values. It rejects mounts, privileged flags and
runtime capabilities that would turn a product deployment into a host
control interface.
"""
try:
document = yaml.safe_load(raw_yaml)
except yaml.YAMLError as exc:
raise ValueError("deployment compose YAML could not be parsed") from exc
if not isinstance(document, dict) or set(document) - {"services", "networks", "name"}:
raise ValueError("deployment compose only permits name, services and networks")
services = document.get("services")
if not isinstance(services, dict) or not services:
raise ValueError("deployment compose must define services")
expected = set(plan.topology.containers)
observed = set(services)
if observed != expected:
raise ValueError("deployment compose services must exactly match the typed plan containers")
for service_name, service in services.items():
if not isinstance(service, dict):
raise TypeError("deployment service must be an object")
forbidden = {
"privileged",
"devices",
"pid",
"ipc",
"cap_add",
"dockerfile",
}
present = forbidden.intersection(service)
if present:
raise ValueError(f"deployment service {service_name} uses forbidden runtime fields")
build = service.get("build")
if plan.topology.build is None:
if build:
raise ValueError("deployment service build is not declared by the typed plan")
else:
if isinstance(build, str):
observed_build = {"context": build, "dockerfile": "Dockerfile"}
elif isinstance(build, dict):
observed_build = {
"context": str(build.get("context") or "."),
"dockerfile": str(build.get("dockerfile") or "Dockerfile"),
}
if set(build) - {"context", "dockerfile"}:
raise ValueError("deployment service build contains unsupported fields")
else:
raise ValueError("deployment service build must be declared by the typed plan")
expected_build = plan.topology.build.model_dump()
if observed_build != expected_build:
raise ValueError("deployment service build does not match the typed plan")
if service.get("network_mode") in {"host", "service:host"}:
raise ValueError("host networking is forbidden")
if service.get("ports"):
raise ValueError("published host ports are forbidden for managed proxy deployments")
security_options = service.get("security_opt") or []
if set(security_options) - {"no-new-privileges:true"}:
raise ValueError("deployment service uses unsupported security options")
if service.get("read_only") is not True:
raise ValueError("deployment services must use a read-only root filesystem")
if service.get("cap_drop") != ["ALL"]:
raise ValueError("deployment services must drop every Linux capability")
if service.get("restart") != "unless-stopped":
raise ValueError("deployment services must use restart: unless-stopped")
image = str(service.get("image") or "")
if image != f"{plan.image_repository}:{plan.image_tag}":
raise ValueError("deployment service image must equal the immutable plan image")
owned_bind_mounts = 0
for mount in service.get("volumes", []) or []:
if isinstance(mount, dict) and mount.get("type") not in {None, "bind"}:
raise ValueError("deployment services permit only application-owned bind mounts")
source = mount.get("source") if isinstance(mount, dict) else str(mount).split(":", 1)[0]
source_text = str(source or "")
normalized_source = posixpath.normpath(source_text)
if "docker.sock" in source_text:
raise ValueError("docker socket and host-control mounts are forbidden")
if (
not source_text.startswith("/")
or normalized_source != source_text
or source_text in {"/", "/proc", "/sys"}
):
raise ValueError("only normalized application-owned bind mounts are permitted")
if source_text != plan.appdata_path and not source_text.startswith(plan.appdata_path + "/"):
raise ValueError("bind mounts must stay below the plan's owned appdata path")
owned_bind_mounts += 1
if not owned_bind_mounts:
raise ValueError("deployment services require an application-owned persistent bind mount")
networks = service.get("networks")
if networks != [plan.topology.proxy_network] and networks != {plan.topology.proxy_network: None}:
raise ValueError("deployment services must use only the typed proxy network")
networks = document.get("networks")
if networks != {plan.topology.proxy_network: {"external": True}}:
raise ValueError("deployment compose must reference exactly one existing proxy network")
return {
"services": sorted(observed),
"proxyNetwork": plan.topology.proxy_network,
"appdataPath": plan.appdata_path,
"image": f"{plan.image_repository}:{plan.image_tag}",
}
+375
View File
@@ -0,0 +1,375 @@
"""Strict, secret-free contract for managed application enrollment.
Enrollment establishes an application identity and its operating boundaries. It
is deliberately separate from a deployment: the caller cannot submit Compose,
host paths, shell commands, callback origins, proxy snippets, or secret values.
"""
from __future__ import annotations
import re
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9._-]{1,80}$")
_REVISION = re.compile(r"^[0-9a-f]{40}$")
_HOSTNAME = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$")
_RELATIVE_PATH = re.compile(r"^/[a-z0-9][a-z0-9/_-]{0,120}$")
_SUBPATH = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
_IMAGE = re.compile(r"^[a-z0-9][a-z0-9._/-]{1,180}$")
_IMAGE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
_VERSION = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$")
DeploymentProfileName = Literal[
"internal-webapp",
"internal-webapp-oidc",
"internal-service",
"repopulse-authenticated",
]
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class EnrollmentTargetV1(_StrictModel):
target_type: Literal["gitea-compose-controller"] = "gitea-compose-controller"
target_identity: str = Field(min_length=3, max_length=80)
container_name: str = Field(min_length=2, max_length=63)
# A small reviewed manifest allow-list keeps source locations explicit while
# allowing the established AppOps artifact layout as well as the default
# contract for future projects. It is intentionally not a free path.
compose_file: Literal[".itworx/deployment.yml", "deploy/appops/compose.yml"] = ".itworx/deployment.yml"
ownership_label: Literal["tech.itworx.enrollment"] = "tech.itworx.enrollment"
@field_validator("target_identity")
@classmethod
def safe_target_identity(cls, value: str) -> str:
if not re.fullmatch(r"[a-z][a-z0-9-]{2,79}", value):
raise ValueError("target_identity must be a bounded lower-case identifier")
return value
@field_validator("container_name")
@classmethod
def safe_container_name(cls, value: str) -> str:
if not _APP_ID.fullmatch(value):
raise ValueError("container_name must be a bounded lower-case identifier")
return value
class EnrollmentStorageV1(_StrictModel):
# The v1 profiles own the data layout. Future profiles may add reviewed
# alternatives, but enrollment must not turn this into a free host path.
appdata_subpath: Literal["data"] = "data"
runtime_uid: int = Field(ge=1, le=65535)
runtime_gid: int = Field(ge=1, le=65535)
mount_role: Literal["application-data"] = "application-data"
@field_validator("appdata_subpath")
@classmethod
def safe_subpath(cls, value: str) -> str:
if not _SUBPATH.fullmatch(value) or value in {".", ".."}:
raise ValueError("appdata_subpath must be one bounded relative directory")
return value
class EnrollmentNetworkV1(_StrictModel):
docker_network: str = Field(min_length=2, max_length=80)
internal_port: int = Field(ge=1, le=65535)
host_publish: Literal[False] = False
@field_validator("docker_network")
@classmethod
def safe_network(cls, value: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", value):
raise ValueError("docker_network must be a bounded Docker network name")
return value
class EnrollmentExposureV1(_StrictModel):
hostname: str
tls_required: Literal[True] = True
upstream_service: str
websocket: bool = False
upload_profile: Literal["standard", "large-upload"] = "standard"
timeout_profile: Literal["standard", "long-running"] = "standard"
@field_validator("hostname")
@classmethod
def safe_hostname(cls, value: str) -> str:
normalized = value.lower().rstrip(".")
if not _HOSTNAME.fullmatch(normalized) or "*" in normalized:
raise ValueError("hostname must be a concrete DNS hostname")
return normalized
@field_validator("upstream_service")
@classmethod
def safe_upstream(cls, value: str) -> str:
if not _APP_ID.fullmatch(value):
raise ValueError("upstream_service must be a bounded application identifier")
return value
class EnrollmentRolesV1(_StrictModel):
administrator: str = "Administrator"
support_agent: str = "SupportAgent"
read_only: str = "ReadOnly"
@field_validator("administrator", "support_agent", "read_only")
@classmethod
def safe_role(cls, value: str) -> str:
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{1,63}", value):
raise ValueError("role names must be bounded identifiers")
return value
class EnrollmentAuthenticationV1(_StrictModel):
mode: Literal["none", "native-oidc"] = "none"
redirect_path: str | None = None
logout_path: str | None = None
roles: EnrollmentRolesV1 | None = None
@field_validator("redirect_path", "logout_path")
@classmethod
def safe_relative_path(cls, value: str | None) -> str | None:
if value is None:
return value
if not _RELATIVE_PATH.fullmatch(value) or "//" in value or any(item in value for item in ("?", "#", "\\")):
raise ValueError("OIDC paths must be relative HTTPS application paths")
return value
@model_validator(mode="after")
def validate_mode(self) -> EnrollmentAuthenticationV1:
if self.mode == "native-oidc" and (not self.redirect_path or self.roles is None):
raise ValueError("native-oidc requires a redirect_path and a complete role model")
if self.mode == "none" and any((self.redirect_path, self.logout_path, self.roles)):
raise ValueError("authentication details are only valid for native-oidc")
return self
class EnrollmentArtifactV1(_StrictModel):
release_version: str
image_repository: str
image_tag: str
health_path: str = "/health"
@field_validator("release_version")
@classmethod
def valid_version(cls, value: str) -> str:
if not _VERSION.fullmatch(value):
raise ValueError("release_version must be a semantic version")
return value.removeprefix("v")
@field_validator("image_repository")
@classmethod
def valid_image(cls, value: str) -> str:
normalized = value.lower()
if not _IMAGE.fullmatch(normalized):
raise ValueError("image_repository must be a bounded image name")
return normalized
@field_validator("image_tag")
@classmethod
def valid_tag(cls, value: str) -> str:
if value.lower() == "latest" or not _IMAGE_TAG.fullmatch(value):
raise ValueError("image_tag must be explicit and cannot be latest")
return value
@field_validator("health_path")
@classmethod
def valid_health_path(cls, value: str) -> str:
if not _RELATIVE_PATH.fullmatch(value) or "//" in value or any(item in value for item in ("?", "#", "\\")):
raise ValueError("health_path must be a bounded relative path")
return value
class EnrollmentSecretsV1(_StrictModel):
oidc_client_secret_required: bool = False
additional_secret_refs: tuple[str, ...] = ()
@field_validator("additional_secret_refs")
@classmethod
def references_only(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(value) > 8 or len(set(value)) != len(value):
raise ValueError("additional_secret_refs must contain up to eight unique references")
for reference in value:
if not re.fullmatch(r"[A-Z][A-Z0-9_]{2,95}", reference):
raise ValueError("secret requirements must be references, never values")
return value
class EnrollmentPolicyV1(_StrictModel):
deployment_apply: Literal[True] = True
deployment_rollback: Literal[True] = True
autonomous_profile: Literal[True] = True
class EnrollmentPlanV1(_StrictModel):
api_version: Literal["itworx.enrollment/v1"] = "itworx.enrollment/v1"
application_id: str
display_name: str = Field(min_length=2, max_length=100)
environment: Literal["production", "staging"] = "production"
repository: str
revision: str
profile: DeploymentProfileName
target: EnrollmentTargetV1
storage: EnrollmentStorageV1
network: EnrollmentNetworkV1
artifact: EnrollmentArtifactV1
exposure: EnrollmentExposureV1 | None = None
authentication: EnrollmentAuthenticationV1 = EnrollmentAuthenticationV1()
secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1()
policy: EnrollmentPolicyV1 = EnrollmentPolicyV1()
@field_validator("application_id")
@classmethod
def safe_application_id(cls, value: str) -> str:
if not _APP_ID.fullmatch(value):
raise ValueError("application_id must be a bounded lower-case identifier")
return value
@field_validator("display_name")
@classmethod
def safe_display_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized or any(character in normalized for character in "\r\n<>"):
raise ValueError("display_name contains unsupported characters")
return normalized
@field_validator("repository")
@classmethod
def safe_repository(cls, value: str) -> str:
normalized = value.lower()
if not _REPOSITORY.fullmatch(normalized):
raise ValueError("repository must be a canonical Jens repository")
return normalized
@field_validator("revision")
@classmethod
def immutable_revision(cls, value: str) -> str:
normalized = value.lower()
if not _REVISION.fullmatch(normalized):
raise ValueError("revision must be a full lower-case 40-character Git SHA")
return normalized
@model_validator(mode="after")
def validate_profile_contract(self) -> EnrollmentPlanV1:
if self.target.container_name != self.application_id:
raise ValueError("target.container_name must equal application_id")
if self.profile == "internal-service":
if self.exposure is not None or self.authentication.mode != "none":
raise ValueError("internal-service cannot request public exposure or OIDC")
elif self.exposure is None:
raise ValueError("web application profiles require managed HTTPS exposure")
if self.exposure and self.exposure.upstream_service != self.application_id:
raise ValueError("exposure.upstream_service must equal application_id")
expects_oidc = self.profile == "internal-webapp-oidc"
if expects_oidc != (self.authentication.mode == "native-oidc"):
raise ValueError("profile and authentication mode must match")
if expects_oidc != self.secrets.oidc_client_secret_required:
raise ValueError("OIDC secret requirement must match the selected profile")
if self.profile == "repopulse-authenticated" and (
self.application_id != "repopulse"
or self.repository != "jens/repopulse"
or self.environment != "production"
or self.target.container_name != "repopulse"
or self.target.compose_file != ".itworx/deployment.yml"
or self.storage.appdata_subpath != "data"
or self.storage.runtime_uid != 10001
or self.storage.runtime_gid != 10001
or self.network.docker_network != "itworx-mcp-hub-npm"
or self.network.internal_port != 8000
or self.artifact.image_repository != "repopulse-managed"
or self.artifact.image_tag != "production"
or self.artifact.health_path != "/health"
or self.exposure is None
or self.exposure.hostname != "repopulse.example.com"
or self.exposure.upstream_service != "repopulse"
or self.authentication.mode != "none"
or self.secrets.additional_secret_refs != ("REPOPULSE_GITEA_TOKEN",)
):
raise ValueError("repopulse-authenticated must use the fixed RepoPulse managed target contract")
return self
@property
def appdata_path(self) -> str:
return f"/srv/example/appdata/{self.application_id}"
@property
def data_path(self) -> str:
return f"{self.appdata_path}/{self.storage.appdata_subpath}"
@property
def public_url(self) -> str | None:
return f"https://{self.exposure.hostname}" if self.exposure else None
class EnrollmentTrustConfigV1(_StrictModel):
"""Root-owned boundaries; secret values remain in the encrypted store."""
controller_identity: str
bootstrap_secret_ref: str
trusted_appdata_root: Literal["/srv/example/appdata"] = "/srv/example/appdata"
approved_networks: tuple[str, ...] = ()
allowed_domain_suffixes: tuple[str, ...] = ()
autonomous_profiles: tuple[DeploymentProfileName, ...] = ()
repopulse_proxy_access_list_id: int | None = Field(default=None, strict=True, ge=1, le=2_147_483_647)
oidc_issuer: str | None = None
oidc_role_claim: Literal["roles"] = "roles"
administrator_group: str | None = None
support_agent_group: str | None = None
read_only_group: str | None = None
@field_validator("controller_identity")
@classmethod
def trusted_controller(cls, value: str) -> str:
if not re.fullmatch(r"[a-z][a-z0-9-]{2,79}", value):
raise ValueError("controller_identity must be a bounded lower-case identifier")
return value
@field_validator("bootstrap_secret_ref")
@classmethod
def trusted_secret_ref(cls, value: str) -> str:
if not re.fullmatch(r"[A-Z][A-Z0-9_]{2,95}", value):
raise ValueError("bootstrap_secret_ref must be a secret reference")
return value
@field_validator("approved_networks")
@classmethod
def trusted_networks(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if not value or len(value) > 8 or len(set(value)) != len(value):
raise ValueError("approved_networks must contain one to eight unique networks")
for item in value:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", item):
raise ValueError("approved network name is invalid")
return value
@field_validator("allowed_domain_suffixes")
@classmethod
def trusted_suffixes(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(value) > 16 or len(set(value)) != len(value):
raise ValueError("allowed_domain_suffixes must be unique")
for item in value:
normalized = item.lower().lstrip(".")
if not _HOSTNAME.fullmatch(normalized) or "*" in normalized:
raise ValueError("allowed domain suffix must be a concrete DNS suffix")
return tuple(item.lower().lstrip(".") for item in value)
@field_validator("oidc_issuer")
@classmethod
def issuer_is_https(cls, value: str | None) -> str | None:
if value is not None and not re.fullmatch(r"https://[a-z0-9.-]+(?:/[a-z0-9._~/-]*)?", value):
raise ValueError("oidc_issuer must be an HTTPS URL without query or fragment")
return value.rstrip("/") if value else value
@model_validator(mode="after")
def validate_oidc_groups(self) -> EnrollmentTrustConfigV1:
groups = (self.administrator_group, self.support_agent_group, self.read_only_group)
if any(groups) and not all(groups):
raise ValueError("all three configured OIDC role groups are required together")
for group in groups:
if group is not None and not re.fullmatch(r"[A-Za-z0-9 _.-]{2,100}", group):
raise ValueError("OIDC role group name is invalid")
return self
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large. Load diff
+614
View File
@@ -0,0 +1,614 @@
from __future__ import annotations
ACTIONS = [
'container.status',
'container.logs',
'container.clear_logs',
'container.restart',
'container.stop',
'runner.fleet.upgrade_v2',
'controller.artifact.promote',
'gateway.clear_cache',
'stack.status',
'stack.redeploy',
'deployment.preflight',
'deployment.targets.sync',
'deployment.runnerless.provision',
'deployment.target.deploy_workmesh',
'deployment.target.promote_workmesh',
'deployment.target.register_portfolio',
'deployment.target.register_privatemuse',
'deployment.target.reconcile_media_codec_dashboard',
'deployment.webhook.repair_privatemuse',
'privatemuse.comfyui.install_artifact',
'deployment.apply',
'deployment.rollback',
'volume.delete',
'unraid.health_summary',
'unraid.vms',
'unraid.vm.start',
'unraid.vm.resume',
'unraid.vm.pause',
'unraid.vm.stop',
'unraid.vm.reboot',
'unraid.vm.force_stop',
'unraid.vm.reset',
'unraid.mover.start',
'setup.status',
'safety.summary',
'media_pipeline.check',
'media_pipeline.diagnose',
'notifications.summary',
'notifications.evaluate_safety',
'sonarr.search_series',
'sonarr.add_series',
'sonarr.health',
'sonarr.queue_status',
'sonarr.wanted_missing',
'sonarr.delete_series',
'sonarr.mass_edit',
'radarr.search_movie',
'radarr.add_movie',
'radarr.health',
'radarr.queue_status',
'radarr.wanted_missing',
'radarr.delete_movie',
'radarr.delete_files',
'radarr.mass_edit',
'jellyfin.scan_library',
'widefrog.health',
'widefrog.queue_status',
'widefrog.failed_jobs',
'widefrog.activity',
'widefrog.retry_failed',
'widefrog.scan_library',
'widefrog.download_status',
'prowlarr.health',
'prowlarr.indexer_status',
'prowlarr.application_status',
'prowlarr.indexer_health',
'prowlarr.search_test',
'prowlarr.sync_status',
'prowlarr.delete_indexer',
'prowlarr.delete_application',
'prowlarr.update_indexer',
'prowlarr.update_application',
'prowlarr.grab_release',
'prowlarr.push_release',
'prowlarr.mass_edit',
'prowlarr.test_all_indexers',
'deluge.health',
'deluge.session_status',
'deluge.torrent_status',
'deluge.active_downloads',
'deluge.stalled_downloads',
'deluge.paused_downloads',
'deluge.speed_status',
'deluge.pause_torrent',
'deluge.resume_torrent',
'deluge.pause_all',
'deluge.resume_all',
'deluge.remove_torrent',
'deluge.remove_torrent_data',
'deluge.delete_data',
'deluge.force_recheck',
'deluge.move_storage',
'deluge.add_torrent',
'deluge.add_magnet',
'deluge.mass_edit',
'deluge.set_options',
'deluge.unknown_mutation',
'plex.health',
'plex.server_status',
'plex.library_status',
'plex.active_sessions',
'plex.recent_media',
'plex.scan_library',
'plex.scan_all_libraries',
'plex.stream_summary',
'tautulli.health',
'tautulli.status',
'tautulli.activity',
'tautulli.history',
'tautulli.users',
'tautulli.libraries',
'tautulli.recently_added',
'tautulli.watch_stats',
'tautulli.stream_summary',
'tautulli.plex_info',
'npm.health',
'npm.proxy_hosts',
'npm.certificates',
'npm.expiring_certificates',
'npm.access_lists',
'npm.exposure_summary',
'npm.host_reachability',
'npm.security_warnings',
'npm.create_proxy_host',
'npm.update_proxy_host',
'npm.delete_proxy_host',
'npm.enable_proxy_host',
'npm.disable_proxy_host',
'npm.renew_certificate',
'npm.delete_certificate',
'npm.modify_access_list',
'npm.create_user',
'npm.update_user',
'npm.delete_user',
'npm.mass_edit',
'npm.unknown_mutation',
'authentik.health',
'authentik.system_status',
'authentik.applications',
'authentik.providers',
'authentik.proxy_providers',
'authentik.oauth2_providers',
'authentik.outposts',
'authentik.outpost_health',
'authentik.flows_summary',
'authentik.policies_summary',
'authentik.events',
'authentik.auth_coverage',
'authentik.security_warnings',
'authentik.create_application',
'authentik.update_application',
'authentik.delete_application',
'authentik.create_provider',
'authentik.update_provider',
'authentik.delete_provider',
'authentik.create_outpost',
'authentik.update_outpost',
'authentik.delete_outpost',
'authentik.create_policy',
'authentik.update_policy',
'authentik.delete_policy',
'authentik.create_user',
'authentik.update_user',
'authentik.delete_user',
'authentik.create_group',
'authentik.update_group',
'authentik.delete_group',
'authentik.rotate_token',
'authentik.force_sync',
'authentik.mass_edit',
'authentik.unknown_mutation',
'tautulli.terminate_session',
'tautulli.delete_newsletter',
'tautulli.delete_notification_log',
'tautulli.delete_notifier',
'tautulli.delete_recently_added',
'tautulli.download_config',
'tautulli.download_database',
'tautulli.download_export',
'tautulli.download_log',
'tautulli.download_plex_log',
'tautulli.notify',
'tautulli.user_management',
'tautulli.mass_edit',
'tautulli.unknown_mutation',
'plex.delete_media',
'plex.delete_library',
'plex.empty_trash',
'plex.clean_bundles',
'plex.optimize_database',
'plex.kill_session',
'plex.user_management',
'plex.force_refresh_all_metadata',
'plex.mass_edit',
'plex.unknown_mutation',
'widefrog.delete_job',
'widefrog.delete_media',
'widefrog.clear_library',
'widefrog.clear_queue',
'widefrog.reset_database',
'widefrog.delete_downloads',
'widefrog.prune',
'playbook.run',
]
ACTION_RISK = {
'container.status': 'low',
'container.logs': 'low',
'container.clear_logs': 'high',
'container.restart': 'medium',
'container.stop': 'high',
'runner.fleet.upgrade_v2': 'high',
'controller.artifact.promote': 'high',
'gateway.clear_cache': 'high',
'stack.status': 'low',
'stack.redeploy': 'high',
'deployment.preflight': 'low',
'deployment.targets.sync': 'high',
'deployment.runnerless.provision': 'high',
'deployment.target.deploy_workmesh': 'high',
'deployment.target.promote_workmesh': 'high',
'deployment.target.register_portfolio': 'high',
'deployment.target.register_privatemuse': 'high',
'deployment.target.reconcile_media_codec_dashboard': 'high',
'deployment.webhook.repair_privatemuse': 'high',
'privatemuse.comfyui.install_artifact': 'high',
'deployment.apply': 'high',
'deployment.rollback': 'high',
'volume.delete': 'critical',
'unraid.health_summary': 'low',
'unraid.vms': 'low',
'unraid.vm.start': 'medium',
'unraid.vm.resume': 'medium',
'unraid.vm.pause': 'high',
'unraid.vm.stop': 'critical',
'unraid.vm.reboot': 'critical',
'unraid.vm.force_stop': 'critical',
'unraid.vm.reset': 'critical',
'unraid.mover.start': 'high',
'setup.status': 'low',
'safety.summary': 'low',
'media_pipeline.check': 'low',
'media_pipeline.diagnose': 'low',
'notifications.summary': 'low',
'notifications.evaluate_safety': 'medium',
'sonarr.search_series': 'low',
'sonarr.add_series': 'low',
'sonarr.health': 'low',
'sonarr.queue_status': 'low',
'sonarr.wanted_missing': 'low',
'sonarr.delete_series': 'critical',
'sonarr.mass_edit': 'critical',
'radarr.search_movie': 'low',
'radarr.add_movie': 'low',
'radarr.health': 'low',
'radarr.queue_status': 'low',
'radarr.wanted_missing': 'low',
'radarr.delete_movie': 'critical',
'radarr.delete_files': 'critical',
'radarr.mass_edit': 'critical',
'jellyfin.scan_library': 'medium',
'widefrog.health': 'low',
'widefrog.queue_status': 'low',
'widefrog.failed_jobs': 'low',
'widefrog.activity': 'low',
'widefrog.retry_failed': 'medium',
'widefrog.scan_library': 'medium',
'widefrog.download_status': 'low',
'prowlarr.health': 'low',
'prowlarr.indexer_status': 'low',
'prowlarr.application_status': 'low',
'prowlarr.indexer_health': 'low',
'prowlarr.search_test': 'medium',
'prowlarr.sync_status': 'low',
'prowlarr.delete_indexer': 'critical',
'prowlarr.delete_application': 'critical',
'prowlarr.update_indexer': 'high',
'prowlarr.update_application': 'high',
'prowlarr.grab_release': 'critical',
'prowlarr.push_release': 'critical',
'prowlarr.mass_edit': 'critical',
'prowlarr.test_all_indexers': 'medium',
'deluge.health': 'low',
'deluge.session_status': 'low',
'deluge.torrent_status': 'low',
'deluge.active_downloads': 'low',
'deluge.stalled_downloads': 'low',
'deluge.paused_downloads': 'low',
'deluge.speed_status': 'low',
'deluge.pause_torrent': 'medium',
'deluge.resume_torrent': 'medium',
'deluge.pause_all': 'high',
'deluge.resume_all': 'high',
'deluge.remove_torrent': 'critical',
'deluge.remove_torrent_data': 'critical',
'deluge.delete_data': 'critical',
'deluge.force_recheck': 'high',
'deluge.move_storage': 'high',
'deluge.add_torrent': 'high',
'deluge.add_magnet': 'high',
'deluge.mass_edit': 'critical',
'deluge.set_options': 'critical',
'deluge.unknown_mutation': 'critical',
'plex.health': 'low',
'plex.server_status': 'low',
'plex.library_status': 'low',
'plex.active_sessions': 'low',
'plex.recent_media': 'low',
'plex.scan_library': 'medium',
'plex.scan_all_libraries': 'high',
'plex.stream_summary': 'low',
'tautulli.health': 'low',
'tautulli.status': 'low',
'tautulli.activity': 'low',
'tautulli.history': 'medium',
'tautulli.users': 'medium',
'tautulli.libraries': 'low',
'tautulli.recently_added': 'low',
'tautulli.watch_stats': 'medium',
'tautulli.stream_summary': 'low',
'tautulli.plex_info': 'low',
'npm.health': 'low',
'npm.proxy_hosts': 'medium',
'npm.certificates': 'low',
'npm.expiring_certificates': 'low',
'npm.access_lists': 'medium',
'npm.exposure_summary': 'medium',
'npm.host_reachability': 'low',
'npm.security_warnings': 'low',
'npm.create_proxy_host': 'critical',
'npm.update_proxy_host': 'critical',
'npm.delete_proxy_host': 'critical',
'npm.enable_proxy_host': 'high',
'npm.disable_proxy_host': 'high',
'npm.renew_certificate': 'high',
'npm.delete_certificate': 'critical',
'npm.modify_access_list': 'critical',
'npm.create_user': 'critical',
'npm.update_user': 'critical',
'npm.delete_user': 'critical',
'npm.mass_edit': 'critical',
'npm.unknown_mutation': 'critical',
'authentik.health': 'low',
'authentik.system_status': 'low',
'authentik.applications': 'medium',
'authentik.providers': 'medium',
'authentik.proxy_providers': 'medium',
'authentik.oauth2_providers': 'medium',
'authentik.outposts': 'medium',
'authentik.outpost_health': 'low',
'authentik.flows_summary': 'medium',
'authentik.policies_summary': 'medium',
'authentik.events': 'medium',
'authentik.auth_coverage': 'medium',
'authentik.security_warnings': 'low',
'authentik.create_application': 'critical',
'authentik.update_application': 'critical',
'authentik.delete_application': 'critical',
'authentik.create_provider': 'critical',
'authentik.update_provider': 'critical',
'authentik.delete_provider': 'critical',
'authentik.create_outpost': 'critical',
'authentik.update_outpost': 'critical',
'authentik.delete_outpost': 'critical',
'authentik.create_policy': 'critical',
'authentik.update_policy': 'critical',
'authentik.delete_policy': 'critical',
'authentik.create_user': 'critical',
'authentik.update_user': 'critical',
'authentik.delete_user': 'critical',
'authentik.create_group': 'critical',
'authentik.update_group': 'critical',
'authentik.delete_group': 'critical',
'authentik.rotate_token': 'critical',
'authentik.force_sync': 'high',
'authentik.mass_edit': 'critical',
'authentik.unknown_mutation': 'critical',
'tautulli.terminate_session': 'high',
'tautulli.delete_newsletter': 'critical',
'tautulli.delete_notification_log': 'critical',
'tautulli.delete_notifier': 'critical',
'tautulli.delete_recently_added': 'critical',
'tautulli.download_config': 'critical',
'tautulli.download_database': 'critical',
'tautulli.download_export': 'critical',
'tautulli.download_log': 'critical',
'tautulli.download_plex_log': 'critical',
'tautulli.notify': 'high',
'tautulli.user_management': 'critical',
'tautulli.mass_edit': 'critical',
'tautulli.unknown_mutation': 'critical',
'plex.delete_media': 'critical',
'plex.delete_library': 'critical',
'plex.empty_trash': 'critical',
'plex.clean_bundles': 'critical',
'plex.optimize_database': 'critical',
'plex.kill_session': 'high',
'plex.user_management': 'critical',
'plex.force_refresh_all_metadata': 'critical',
'plex.mass_edit': 'critical',
'plex.unknown_mutation': 'critical',
'widefrog.delete_job': 'critical',
'widefrog.delete_media': 'critical',
'widefrog.clear_library': 'critical',
'widefrog.clear_queue': 'critical',
'widefrog.reset_database': 'critical',
'widefrog.delete_downloads': 'critical',
'widefrog.prune': 'critical',
'playbook.run': 'medium',
}
READ_ONLY_VERBS = {
'activity',
'active_downloads',
'active_sessions',
'application_status',
'applications',
'auth_coverage',
'certificates',
'check',
'download_status',
'diagnose',
'events',
'expiring_certificates',
'exposure_summary',
'failed_jobs',
'flows_summary',
'health',
'health_summary',
'history',
'host_reachability',
'indexer_health',
'indexer_status',
'libraries',
'library_status',
'logs',
'oauth2_providers',
'outpost_health',
'outposts',
'paused_downloads',
'plex_info',
'preflight',
'policies_summary',
'providers',
'proxy_hosts',
'proxy_providers',
'queue_status',
'recent_media',
'recently_added',
'search_movie',
'search_series',
'security_warnings',
'server_status',
'session_status',
'speed_status',
'stalled_downloads',
'status',
'stream_summary',
'summary',
'sync_status',
'system_status',
'torrent_status',
'users',
'wanted_missing',
'watch_stats',
'vms',
}
DESTRUCTIVE_VERBS = {
'clear_cache',
'clear_library',
'clear_logs',
'clear_queue',
'clean_bundles',
'delete_application',
'delete_certificate',
'delete_data',
'delete_downloads',
'delete_files',
'delete_group',
'delete_indexer',
'delete_job',
'delete_library',
'delete_media',
'delete_movie',
'delete_newsletter',
'delete_notification_log',
'delete_notifier',
'delete_outpost',
'delete_policy',
'delete_provider',
'delete_proxy_host',
'delete_recently_added',
'delete_series',
'delete_user',
'download_config',
'download_database',
'download_export',
'download_log',
'download_plex_log',
'empty_trash',
'force_refresh_all_metadata',
'grab_release',
'mass_edit',
'modify_access_list',
'prune',
'push_release',
'remove_torrent',
'remove_torrent_data',
'reset_database',
'rotate_token',
'set_options',
'stop',
'unknown_mutation',
}
def action_category(action: str) -> str:
return action.split('.', 1)[0] if '.' in action else 'general'
def action_default_target(action: str) -> str:
if action == 'controller.artifact.promote':
return 'tower/deployment-controller'
if action == 'runner.fleet.upgrade_v2':
return 'tower/runner-fleet-v2'
if action == 'deployment.targets.sync':
return 'jens/*'
if action == 'deployment.target.deploy_workmesh':
return 'jens/workmesh'
if action == 'deployment.target.promote_workmesh':
return 'jens/workmesh'
if action == 'deployment.target.register_portfolio':
return 'jens/portfolio'
if action in {
'deployment.target.register_privatemuse',
'deployment.webhook.repair_privatemuse',
}:
return 'jens/privatemuse'
if action == 'deployment.target.reconcile_media_codec_dashboard':
return 'jens/media-codec-dashboard'
category = action_category(action)
if category == 'container':
return 'container'
if category == 'volume':
return 'volume'
if category == 'media_pipeline':
return 'media-pipeline'
return category
def action_label(action: str) -> str:
return action.replace('_', ' ').replace('.', ' / ').title()
def action_capability(action: str) -> str:
verb = action.split('.', 1)[1] if '.' in action else action
if action in {'unraid.vm.stop', 'unraid.vm.reboot', 'unraid.vm.force_stop', 'unraid.vm.reset'}:
return 'destructive'
if verb in DESTRUCTIVE_VERBS or ACTION_RISK.get(action) == 'critical':
return 'destructive'
if verb in READ_ONLY_VERBS:
return 'read_only'
return 'operator'
def action_blast_radius(action: str, capability: str) -> list[str]:
category = action_category(action)
if capability == 'read_only':
return [f'Read-only visibility for {category}; no state changes are performed.']
if category == 'volume':
return ['Storage volume data and dependent apps could be affected if execution were ever enabled.']
if category == 'container':
return ['Target container availability and dependent routes may be affected.']
if category == 'stack':
return ['Multiple containers in the stack may restart or change state.']
if category == 'gateway':
return ['AppOps runtime visibility, cached connector state or operator context may be affected if execution were ever enabled.']
if category in {'authentik', 'npm'}:
return ['Authentication, exposure, routing or access-control behavior may be affected.']
if category in {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}:
return ['Media pipeline state, queues or user-facing media availability may be affected.']
if category == 'unraid':
return ['Unraid host or array operations may be affected.']
return [f'{category} operational state may be affected.']
def action_safeguards(action: str, decision: str, capability: str) -> list[str]:
safeguards = ['Registry detail is read-only and cannot execute this action.']
if capability == 'destructive':
safeguards.append('Destructive or critical actions stay blocked unless an explicit future policy path is designed.')
if decision == 'blocked':
safeguards.append('AppOps Gateway V1 exposes this only as a blocked boundary; no executor is registered.')
safeguards.append('Current policy decision is blocked.')
elif decision == 'approval_required':
safeguards.append('Current policy decision requires operator approval before execution.')
else:
safeguards.append('Allowed policy preview does not bypass preflight, audit or runtime checks.')
if 'token' in action or 'secret' in action:
safeguards.append('Secret-like values are never returned by the registry.')
return safeguards
def action_operator_guidance(action: str, decision: str, capability: str) -> str:
if decision == 'blocked':
return 'AppOps Gateway V1 blocks this action; treat it as governance visibility only and use external/manual tooling until a separate safe execution design exists.'
if decision == 'approval_required':
return 'Use approved operational flows only after reviewing impact and maintenance context.'
if capability == 'read_only':
return 'Safe to inspect for operational awareness.'
return 'Use the existing preflight and audited action flow before any live operation.'
@@ -0,0 +1,551 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlmodel import Session, select
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..db.models import AppSetting, Connector
from ..schemas.ai_remediation import (
RemediationApprovalProposal,
RemediationApprovalRequirement,
RemediationDryRunResult,
RemediationDryRunStepResult,
RemediationEvidence,
RemediationPlan,
RemediationPlanCollection,
RemediationStep,
RemediationVerificationCheck,
)
from . import ai_security_reviewer
from .audit_logger import log_event
from .policy_engine import evaluate_policy
from .secret_store import list_secret_status, secret_storage_status
SOURCE_KEY = 'ai_remediation'
MUTATION_TOKENS = ('delete', 'remove', 'prune', 'format', 'renew', 'create', 'update', 'rotate', 'redeploy', 'restart', 'resume', 'pause', 'retry')
def _now() -> str:
return datetime.now(UTC).isoformat()
def _redact(value: Any) -> Any:
if isinstance(value, str):
lower = value.lower()
if any(token in lower for token in ('token_value', 'secret_value', 'api_key=', 'password=', 'bearer ')):
return '[REDACTED]'
return redact_message(value)
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, dict):
return {str(key): _redact(item) for key, item in value.items() if 'secret' not in str(key).lower() and 'token' not in str(key).lower()}
return value
def _setting_key(kind: str, plan_id: str) -> str:
return f'{SOURCE_KEY}:{kind}:{plan_id}'
def _get_status(session: Session, plan_id: str) -> str:
row = session.get(AppSetting, _setting_key('plan', plan_id))
if not row:
return 'draft'
return str(row.value_json.get('status') or 'draft')
def _set_status(session: Session, plan_id: str, status: str) -> dict[str, Any]:
row = session.get(AppSetting, _setting_key('plan', plan_id))
if not row:
row = AppSetting(key=_setting_key('plan', plan_id), is_secret=False)
row.value_json = {'status': status, 'updatedAt': _now()}
row.updated_at = utc_now()
session.add(row)
session.commit()
_audit(session, f'ai.remediation.plan.{status}', plan_id, {'status': status})
plan = get_plan(session, plan_id)
return {'success': True, 'id': plan_id, 'status': status, 'plan': plan.model_dump() if plan else None}
def _audit(session: Session, action_id: str, target: str, metadata: dict[str, Any] | None = None) -> None:
log_event(
session,
source='manual',
identity='ai_remediation_planner',
action_id=action_id,
target=target,
target_type='ai_remediation',
risk='low',
policy_decision='allowed',
reason='AI Remediation Planner V1 non-executing workflow.',
result='success',
redacted_metadata=_redact(metadata or {}),
)
def _evidence(source: str, label: str, value: Any = None) -> RemediationEvidence:
redacted = _redact(value)
return RemediationEvidence(source=source, label=label, value=redacted, redacted=redacted != value)
def _check(label: str, endpoint: str, expected: str, source: str) -> RemediationVerificationCheck:
return RemediationVerificationCheck(label=label, endpoint=endpoint, expectedResult=expected, source=source, readOnly=True)
def _resource_sample(resources: list[str], limit: int = 5) -> str:
sample = resources[:limit]
if not sample:
return 'unknown resource'
suffix = f' (+{len(resources) - limit} more)' if len(resources) > limit else ''
return ', '.join(sample) + suffix
def _connector_setup_href_for_secret(session: Session, secret_ref: str) -> str:
connector = session.exec(select(Connector).where(Connector.secret_ref == secret_ref)).first()
if connector:
return f'/setup?step=connectors&connector={connector.id}'
return '/secrets'
def _approval(session: Session, action_id: str, target: str, source: str = 'manual') -> RemediationApprovalRequirement:
policy = evaluate_policy(session, action=action_id, target=target, source=source)
return RemediationApprovalRequirement(
actionId=action_id,
target=target,
reason=str(policy.get('reason') or 'Policy evaluation required.'),
risk=str(policy.get('risk') or 'medium'),
policyDecision=str(policy.get('decision') or 'blocked'),
)
def _step(
session: Session,
step_id: str,
label: str,
description: str,
step_type: str,
*,
href: str | None = None,
action_id: str | None = None,
target: str | None = None,
source: str = 'manual',
evidence: list[RemediationEvidence] | None = None,
) -> RemediationStep:
policy = {'decision': 'allowed', 'risk': 'low'}
if action_id and target:
policy = evaluate_policy(session, action=action_id, target=target, source=source)
return RemediationStep(
id=step_id,
label=label,
description=description,
type=step_type, # type: ignore[arg-type]
actionId=action_id,
target=target,
href=href,
policyDecision=str(policy.get('decision') or 'allowed'),
risk=str(policy.get('risk') or 'low'),
requiresApproval=policy.get('decision') == 'approval_required',
canExecute=False,
evidence=evidence or [],
)
def _blocked(session: Session, step_id: str, label: str, action_id: str, target: str, description: str) -> RemediationStep:
return _step(session, step_id, label, description, 'blocked_action', action_id=action_id, target=target)
def _base_plan(
session: Session,
*,
plan_id: str,
title: str,
description: str,
source_type: str,
source_id: str,
severity: str,
confidence: str,
affected: list[str],
prerequisites: list[str],
steps: list[RemediationStep],
verification: list[RemediationVerificationCheck],
blocked: list[RemediationStep],
approvals: list[RemediationApprovalRequirement],
risk: str,
impact: str,
rollback: str,
) -> RemediationPlan:
now = _now()
return RemediationPlan(
id=plan_id,
title=str(_redact(title)),
description=str(_redact(description)),
sourceType=source_type, # type: ignore[arg-type]
sourceId=source_id,
severity=severity, # type: ignore[arg-type]
confidence=confidence, # type: ignore[arg-type]
status=_get_status(session, plan_id), # type: ignore[arg-type]
affectedResources=[str(_redact(item)) for item in affected],
prerequisites=[str(_redact(item)) for item in prerequisites],
steps=steps,
verificationChecks=verification,
blockedActions=blocked,
approvalRequirements=approvals,
riskAssessment=str(_redact(risk)),
estimatedImpact=str(_redact(impact)),
rollbackNotes=str(_redact(rollback)),
auditPreview=f'Plan {plan_id} can be reviewed or dry-run only; no remediation execution is available in V1.',
createdAt=now,
updatedAt=now,
executable=False,
)
def _generic_finding(finding_id: str) -> dict[str, Any]:
title_map = {
'ai-sec-auth-public-host-without-authentik': ('Public host without Authentik mapping', 'high', 'auth_coverage', ['public_host']),
'ai-sec-npm-certificate-expiring-soon': ('NPM certificate expiring soon', 'medium', 'exposure', ['certificate']),
'ai-sec-npm-host-without-certificate': ('NPM public host without certificate', 'high', 'exposure', ['public_host']),
'ai-sec-npm-host-without-ssl-force': ('NPM host does not force SSL', 'medium', 'exposure', ['public_host']),
'ai-sec-auth-outpost-unavailable': ('Expected Authentik outpost unavailable', 'medium', 'auth_coverage', ['authentik']),
'ai-sec-policy-unraid-mover-voice-allowed': ('Unraid mover voice action is allowed', 'high', 'policies', ['unraid.mover.start']),
'ai-sec-media-pipeline-issue': ('Media pipeline needs review', 'medium', 'media', ['media_pipeline']),
}
title, severity, category, affected = title_map.get(finding_id, ('Security finding needs remediation plan', 'medium', 'unknown', ['unknown']))
return {
'id': finding_id,
'title': title,
'description': title,
'severity': severity,
'confidence': 'medium',
'category': category,
'affectedResources': affected,
'evidence': [],
}
def _plan_for_finding(session: Session, finding: dict[str, Any]) -> RemediationPlan:
finding_id = str(finding.get('id') or 'unknown')
title = str(_redact(finding.get('title') or finding_id))
severity = str(finding.get('severity') or 'medium')
confidence = str(finding.get('confidence') or 'medium')
affected = [str(item) for item in finding.get('affectedResources', [])] or ['unknown']
evidence = [_evidence(str(item.get('source', 'security')), str(item.get('label', 'evidence')), item.get('value', item.get('observed'))) for item in finding.get('evidence', []) if isinstance(item, dict)]
if 'missing-secret' in finding_id or finding.get('category') == 'secrets':
secret_ref = affected[0]
setup_href = _connector_setup_href_for_secret(session, secret_ref)
steps = [
_step(session, 'open-secrets', 'Open connector setup', 'Open the write-only connector configuration page.', 'open_page', href=setup_href, evidence=evidence),
_step(session, 'configure-secret', 'Configure required secret', 'Enter the connector secret in setup. The planner never reads or stores the value.', 'configure_secret', href=setup_href),
_step(session, 'test-connector', 'Test connector', 'Run the existing connector test after the secret is configured.', 'run_readonly_check', href=setup_href),
_step(session, 'rerun-review', 'Re-run security review', 'Confirm the finding is resolved after connector status updates.', 'run_readonly_check', href='/ai/security-reviewer'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Remediate missing secret: {secret_ref}', description='Configure the missing connector secret through the write-only vault and validate with read-only checks.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator has the secret value outside AppOps.', 'Secret storage key is configured.'], steps=steps, verification=[_check('Connector status connected', '/api/connectors/{id}/test', 'connected or configured', 'connectors'), _check('Security finding resolved', '/api/ai/security/findings?scope=secrets', 'finding absent or acknowledged', 'ai_security')], blocked=[], approvals=[], risk='Low operational risk; primary risk is entering the wrong secret.', impact='Connector read operations become available after configuration.', rollback='Replace the secret with the previous value in the vault if validation fails.')
if 'auth-public-host-without-authentik' in finding_id or 'authentik-missing-public-hosts' in finding_id:
host_count = len(affected)
host_label = 'public host' if host_count == 1 else 'public hosts'
host_sample = _resource_sample(affected)
host_evidence = evidence + [_evidence('authentik', 'affectedHosts', affected)]
blocked = [
_blocked(session, 'blocked-npm-update', 'Automatic NPM modification blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'NPM host changes are manual in V1.'),
_blocked(session, 'blocked-auth-create', 'Automatic Authentik application/provider creation blocked', 'authentik.create_application', 'authentik', 'Identity-provider changes are manual in V1.'),
]
approvals = [_approval(session, 'authentik.create_application', 'authentik'), _approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')]
steps = [
_step(session, 'open-network', 'Open Exposure Center', f'Review the affected public host list host-by-host: {host_sample}.', 'open_page', href='/network', evidence=host_evidence),
_step(session, 'review-npm-host', 'Review NPM proxy host', 'Confirm host-by-host whether each route should stay public and which access list or external auth applies.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'),
_step(session, 'open-authentik', 'Open Authentik applications', 'Review existing Authentik applications/providers/outposts.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/apps/authentik'),
_step(session, 'manual-auth-change', 'Manually create or verify Authentik coverage', 'Make required Authentik/NPM changes outside this planner for every affected host that should be identity-protected.', 'manual_external_change', href='/apps/authentik'),
_step(session, 'rerun-coverage', 'Re-run Auth Coverage', 'Confirm every affected host is protected or intentionally reviewed.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/network'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Add identity coverage for {host_count} {host_label}', description=f'{title}: {host_sample}', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM and Authentik connectors are configured.', 'Operator can administer Authentik/NPM directly.', 'Each affected public host is reviewed host-by-host before external changes.'], steps=steps, verification=[_check('Auth coverage protected', '/api/apps/authentik/auth-coverage', 'each affected host is protected, likely_protected, or explicitly reviewed', 'authentik')], blocked=blocked, approvals=approvals, risk='High if left unresolved because public routes may bypass identity controls.', impact='Manual external changes may affect public access to one or more hosts.', rollback='Revert external Authentik/NPM changes in their native UIs if access breaks.')
if 'certificate-expiring' in finding_id or 'without-certificate' in finding_id:
blocked = [_blocked(session, 'blocked-cert-renew', 'Automatic certificate renew blocked', 'npm.renew_certificate', 'nginx_proxy_manager', 'Certificate renewal remains manual in NPM for V1.')]
steps = [
_step(session, 'open-npm', 'Open NPM AppDetail certificate panel', 'Inspect certificate metadata and affected domains.', 'open_page', href='/apps/nginx_proxy_manager', evidence=evidence),
_step(session, 'review-cert', 'Review certificate expiry', 'Validate expiry date, provider and covered domains.', 'run_readonly_check', action_id='npm.certificates', target='nginx_proxy_manager', href='/network'),
_step(session, 'manual-renew', 'Manually renew in NPM if needed', 'Perform renewal in NPM directly; this planner will not call renew.', 'manual_external_change', href='/apps/nginx_proxy_manager'),
_step(session, 'rerun-cert-check', 'Re-run NPM cert check', 'Refresh certificate warning data.', 'run_readonly_check', action_id='npm.expiring_certificates', target='nginx_proxy_manager', href='/network'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review expiring NPM certificate', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read certificate metadata.', 'Operator has NPM admin access.'], steps=steps, verification=[_check('Certificate no longer expiring', '/api/apps/nginx-proxy-manager/certificates/expiring?days=30', 'affected certificate absent', 'npm')], blocked=blocked, approvals=[_approval(session, 'npm.renew_certificate', 'nginx_proxy_manager')], risk='Medium; access can fail when TLS expires.', impact='Manual renewal may briefly affect the public host.', rollback='Restore previous certificate state in NPM if renewal introduces errors.')
if 'without-ssl-force' in finding_id or 'ssl force' in title.lower() or 'force ssl' in title.lower():
steps = [
_step(session, 'open-network', 'Open Exposure Center', 'Inspect the affected NPM public host and its SSL policy.', 'open_page', href='/network', evidence=evidence),
_step(session, 'review-proxy-host', 'Review proxy host SSL settings', 'Confirm Force SSL is disabled for the affected host and whether that is intentional.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'),
_step(session, 'manual-ssl-change', 'Manually enable Force SSL in NPM if appropriate', 'Change Force SSL in NPM directly after checking the app still works over HTTPS.', 'manual_external_change', href='/apps/nginx_proxy_manager'),
_step(session, 'rerun-exposure', 'Re-run exposure review', 'Confirm the SSL-force finding is resolved or explicitly acknowledged.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review NPM SSL enforcement', description=f'Force SSL is not enabled for: {_resource_sample(affected)}.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read proxy-host metadata.', 'Operator has NPM admin access.', 'HTTPS certificate is valid for the affected host.'], steps=steps, verification=[_check('Proxy host forces SSL', '/api/apps/nginx-proxy-manager/proxy-hosts', 'affected host has sslForced=true or finding acknowledged', 'npm')], blocked=[_blocked(session, 'blocked-npm-update', 'Automatic NPM proxy-host update blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'Planner V1 does not mutate NPM proxy hosts.')], approvals=[_approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')], risk='Medium; users may reach the host over plain HTTP if Force SSL is disabled.', impact='Manual Force SSL changes can affect clients using HTTP-only URLs.', rollback='Disable Force SSL again in NPM if the host breaks, then review certificate and upstream settings.')
if 'outpost-unavailable' in finding_id or 'outpost' in title.lower():
steps = [
_step(session, 'open-authentik', 'Open Authentik AppDetail', 'Inspect Authentik connector health and outpost metadata.', 'open_page', href='/apps/authentik', evidence=evidence),
_step(session, 'review-outpost-health', 'Review outpost health', 'Read the Authentik outpost health summary and provider count.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/apps/authentik'),
_step(session, 'manual-outpost-review', 'Verify outpost in Authentik', 'Use Authentik directly to check the embedded outpost, providers and integration health.', 'manual_external_change', href='/apps/authentik'),
_step(session, 'rerun-auth-review', 'Re-run Authentik security review', 'Confirm outpost health is healthy or intentionally documented.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/ai/security-reviewer'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review Authentik outpost health', description='Authentik reports the expected outpost as unavailable or unknown.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Authentik connector is configured.', 'Operator can inspect Authentik outposts directly.'], steps=steps, verification=[_check('Outpost health is available', '/api/apps/authentik/outposts/health', 'available=true or explicitly reviewed', 'authentik')], blocked=[_blocked(session, 'blocked-auth-update', 'Automatic Authentik outpost mutation blocked', 'authentik.update_policy', 'authentik', 'Planner V1 does not mutate Authentik topology.')], approvals=[_approval(session, 'authentik.update_policy', 'authentik')], risk='Medium; unknown outpost health can make external auth coverage harder to trust.', impact='Manual Authentik changes can affect public authentication flows.', rollback='Revert outpost/provider changes in Authentik if application access is affected.')
if finding.get('category') == 'media':
steps = [
_step(session, 'open-media', 'Open Media Pipeline', 'Review connector status, stalled downloads and pipeline issues.', 'open_page', href='/media-pipeline', evidence=evidence),
_step(session, 'run-diagnose', 'Run media pipeline diagnose', 'Use the existing read-only diagnose flow to collect current media pipeline state.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/media-pipeline'),
_step(session, 'review-connectors', 'Review missing media connectors', 'Configure only the connectors you intend to use; missing optional connectors should remain visible as partial coverage.', 'bind_connector', href='/setup?step=connectors'),
_step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm media finding is resolved, reduced to known partial coverage or acknowledged.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/safety'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review media pipeline issue', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Media Pipeline route is reachable.', 'Operator knows which media connectors should be active in this deployment.'], steps=steps, verification=[_check('Media pipeline summary reviewed', '/api/media-pipeline/summary', 'status healthy, warning accepted, or partial coverage documented', 'media_pipeline'), _check('Media pipeline issues reviewed', '/api/media-pipeline/issues', 'no unexpected critical issues', 'media_pipeline')], blocked=[_blocked(session, 'blocked-deluge-delete-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')], approvals=[_approval(session, 'media_pipeline.diagnose', 'media-pipeline')], risk='Medium; media connector gaps can hide stalled downloads or processing failures.', impact='Read-only review has no runtime impact; any connector setup is manual.', rollback='Remove or replace connector settings manually if a configured connector is wrong.')
if finding.get('category') == 'policies' or 'policy-' in finding_id or 'voice' in title.lower():
action_id = affected[0] if affected else 'unraid.mover.start'
steps = [
_step(session, 'open-policies', 'Open Policies page', 'Inspect the risky policy rule.', 'open_page', href='/policies', evidence=evidence),
_step(session, 'simulate-voice', 'Simulate voice policy decision', 'Use policy simulation for the affected action and target.', 'review_policy', action_id=action_id, target='unraid', source='voice', href='/policies'),
_step(session, 'draft-policy', 'Create policy draft suggestion', 'Draft a safer blocked or approval-required setting for operator review.', 'documentation', href='/policies'),
_step(session, 'manual-apply', 'Manual policy apply outside V1', 'Apply the policy change through an explicit future policy workflow, not this planner.', 'manual_external_change', href='/policies'),
]
blocked = [_blocked(session, 'blocked-policy-update', 'Automatic policy apply blocked', 'authentik.update_policy', 'policy', 'Planner V1 does not mutate policies.')]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Reduce risky voice policy', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator understands desired policy posture.', 'Policy changes are reviewed manually.'], steps=steps, verification=[_check('Policy no longer voice-allowed', '/api/policies/evaluate', 'blocked or approval_required for voice', 'policies')], blocked=blocked, approvals=[_approval(session, action_id, 'unraid', 'voice')], risk='High when voice can trigger state-changing operations.', impact='Safer policy can require more manual approvals.', rollback='Restore previous policy value from audit/config backup if needed.')
if finding.get('category') == 'inventory' or 'critical-app-unprotected' in finding_id or 'needs review' in title.lower():
app_id = affected[0]
steps = [
_step(session, 'open-inventory', 'Open Inventory/AppDetail', 'Review app import, category and criticality.', 'open_page', href='/inventory', evidence=evidence),
_step(session, 'mark-protected', 'Mark protected manually or review import', 'Use existing inventory workflows to classify the app.', 'manual_external_change', href=f'/apps/{app_id}'),
_step(session, 'review-policy-profile', 'Review policy profile', 'Confirm policy profile matches app criticality.', 'review_policy', href='/policies'),
_step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm protection finding is gone.', 'run_readonly_check', href='/ai/security-reviewer'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review critical app protection', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Inventory source is current.', 'Operator has enough context to classify the app.'], steps=steps, verification=[_check('App managed/protected', '/api/inventory/apps', 'protected=true or reviewed', 'inventory')], blocked=[_blocked(session, 'blocked-policy-mutation', 'Automatic policy mutation blocked', 'stack.redeploy', app_id, 'Planner does not mutate app policy profile.')], approvals=[], risk='High for critical apps without explicit protection.', impact='Manual classification affects future policy decisions.', rollback='Change classification back manually if misclassified.')
if finding.get('category') == 'audit' or 'blocked-attempts' in finding_id:
steps = [
_step(session, 'open-audit', 'Open Audit Log', 'Inspect repeated blocked/failed attempts.', 'open_page', href='/audit', evidence=evidence),
_step(session, 'review-automation', 'Review automation or voice source', 'Identify whether attempts are expected or noisy.', 'documentation', href='/audit'),
_step(session, 'review-policy', 'Review related policy', 'Confirm blocked behavior is intentional.', 'review_policy', href='/policies'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Triage repeated blocked attempts', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Recent audit events are available.'], steps=steps, verification=[_check('Blocked attempts decrease', '/api/audit', 'no repeated attempts in next window', 'audit')], blocked=[], approvals=[], risk='Medium; repeated denials can indicate noisy automation or probing.', impact='May require changing external automation or voice phrasing.', rollback='Re-enable previous automation only after policy review.')
return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Review finding: {title}', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Review source finding evidence.'], steps=[_step(session, 'open-reviewer', 'Open Security Reviewer', 'Inspect finding evidence and links.', 'open_page', href='/ai/security-reviewer', evidence=evidence)], verification=[_check('Finding reviewed', '/api/ai/security/findings', 'status acknowledged or resolved', 'ai_security')], blocked=[], approvals=[], risk='Depends on source finding severity.', impact='No direct impact; V1 is plan-only.', rollback='Dismiss plan if not actionable.')
def _plan_for_media_issue(session: Session, issue: dict[str, Any]) -> RemediationPlan:
issue_id = str(issue.get('id') or issue.get('title') or 'media-issue').lower().replace(' ', '-')
title = str(_redact(issue.get('title') or 'Media pipeline issue'))
affected = [str(item) for item in issue.get('affectedConnectors', issue.get('affected', []))] or ['media_pipeline']
evidence = [_evidence('media_pipeline', 'issue', issue.get('evidence', []))]
if 'widefrog' in title.lower() or 'widefrog' in affected:
steps = [
_step(session, 'open-widefrog', 'Open Widefrog AppDetail', 'Review failed jobs and queue state.', 'open_page', href='/apps/widefrog', evidence=evidence),
_step(session, 'review-failed-jobs', 'Review failed jobs', 'Inspect failure reason before retry.', 'run_readonly_check', action_id='widefrog.failed_jobs', target='widefrog', href='/apps/widefrog'),
_step(session, 'retry-if-approved', 'Plan retry_failed through policy flow', 'Retry can only run through existing policy/approval flow outside this planner.', 'request_approval', action_id='widefrog.retry_failed', target='widefrog', href='/apps/widefrog'),
_step(session, 'verify-queue', 'Verify queue/activity', 'Confirm failed count decreases after manual execution.', 'run_readonly_check', action_id='widefrog.queue_status', target='widefrog', href='/apps/widefrog'),
]
blocked = [_blocked(session, 'blocked-delete-jobs', 'Delete jobs/media blocked', 'widefrog.delete_job', 'widefrog', 'Delete operations are forbidden.')]
return _base_plan(session, plan_id='rem-media-widefrog-failed', title='Plan Widefrog failed jobs recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Widefrog connector configured.', 'Operator confirms retry is safe.'], steps=steps, verification=[_check('Failed jobs decrease', '/api/apps/widefrog/failed', 'failed count lower', 'widefrog')], blocked=blocked, approvals=[_approval(session, 'widefrog.retry_failed', 'widefrog')], risk='Medium; retry can repeat failed processing.', impact='May increase load on media processing.', rollback='Stop external retry workflow if failures repeat.')
steps = [
_step(session, 'open-media', 'Open Media Pipeline', 'Review pipeline issue context.', 'open_page', href='/media-pipeline', evidence=evidence),
_step(session, 'open-deluge', 'Open Deluge detail', 'Review stalled torrents without deleting data.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/apps/deluge'),
_step(session, 'resume-specific', 'Plan resume specific torrent if policy allows', 'Resume must go through existing policy/approval flow outside this planner.', 'request_approval', action_id='deluge.resume_torrent', target='deluge', href='/apps/deluge'),
_step(session, 'verify-stalled', 'Verify stalled count decreases', 'Re-run download checks after manual action.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/media-pipeline'),
]
blocked = [_blocked(session, 'blocked-remove-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')]
return _base_plan(session, plan_id='rem-media-deluge-stalled', title='Plan Deluge stalled downloads recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Deluge connector configured.', 'Operator identifies the specific stalled torrent.'], steps=steps, verification=[_check('Stalled downloads decrease', '/api/apps/deluge/stalled', 'stalled count lower', 'deluge')], blocked=blocked, approvals=[_approval(session, 'deluge.resume_torrent', 'deluge')], risk='Medium; resuming downloads can affect bandwidth and storage.', impact='Download queue may resume network/storage activity.', rollback='Pause the specific torrent through normal policy flow if needed.')
def _inventory_plans(session: Session) -> list[RemediationPlan]:
from .app_registry import list_active_apps
plans: list[RemediationPlan] = []
for app in list_active_apps(session):
if app.needs_review:
finding = {'id': f'inventory-needs-review-{app.id}', 'title': f'Discovered app needs review: {app.name}', 'severity': app.criticality if app.criticality in {'low', 'medium', 'high', 'critical'} else 'medium', 'confidence': 'medium', 'category': 'inventory', 'affectedResources': [app.id], 'evidence': []}
plans.append(_plan_for_finding(session, finding))
break
return plans
def _connector_plans(session: Session) -> list[RemediationPlan]:
plans: list[RemediationPlan] = []
for connector in session.exec(select(Connector)).all():
if connector.status == 'missing_config':
finding = {'id': f'connector-missing-config-{connector.id}', 'title': f'Connector missing config: {connector.name}', 'severity': 'medium', 'confidence': 'high', 'category': 'connectors', 'affectedResources': [connector.id], 'evidence': [{'source': 'connectors', 'label': 'status', 'value': connector.status}]}
plans.append(_plan_for_finding(session, finding))
break
return plans
def _secret_storage_plan(session: Session) -> list[RemediationPlan]:
status = secret_storage_status()
if status.get('status') != 'warning' and status.get('productionReady') is not False:
return []
plan = _base_plan(
session,
plan_id='rem-secret-storage-key-warning',
title='Configure production secret storage key',
description='Configure APPOPS_SECRET_KEY or APPOPS_SECRET_KEY_FILE and back it up with the database.',
source_type='secret',
source_id='secret-storage-key-warning',
severity='medium',
confidence='high',
affected=['secret_storage'],
prerequisites=['Operator can set deployment environment variables.', 'Backup process is available.'],
steps=[
_step(session, 'open-settings', 'Open settings/secrets status', 'Review key source and production readiness.', 'open_page', href='/secrets'),
_step(session, 'read-docs', 'Open Secret Storage docs', 'Follow production key setup guidance.', 'documentation', href='/docs/SECRET_STORAGE.md'),
_step(session, 'configure-key', 'Configure APPOPS_SECRET_KEY', 'Set the key in deployment environment outside this planner.', 'manual_external_change'),
_step(session, 'backup-db-key', 'Back up database with key', 'Back up /data/appops.db and the key material together.', 'documentation'),
_step(session, 'runtime-health', 'Re-run runtime health', 'Confirm production readiness.', 'run_readonly_check', href='/settings'),
],
verification=[_check('Runtime health production ready', '/api/runtime/info', 'secretStorage.productionReady=true', 'runtime')],
blocked=[_blocked(session, 'blocked-key-rotation', 'Automated key rotation blocked', 'secrets.show', 'secret_storage', 'Key automation is not implemented in planner V1.')],
approvals=[],
risk='Medium; missing production key affects encrypted secret durability.',
impact='Deployment environment change and backup process update.',
rollback='Restore previous environment and database backup if startup fails.',
)
return [plan]
def generate_plans(session: Session, audit_run: bool = False, include_media_issues: bool | None = None) -> RemediationPlanCollection:
if include_media_issues is None:
include_media_issues = audit_run
review = ai_security_reviewer.generate_review(session)
findings = [finding.model_dump() for finding in review.findings]
recommendations = [recommendation.model_dump() for recommendation in review.recommendations]
finding_ids = {str(finding.get('id')) for finding in findings}
plans = [_plan_for_finding(session, finding) for finding in findings if finding.get('status') != 'dismissed']
plans.extend(
_plan_for_recommendation(session, recommendation)
for recommendation in recommendations
if recommendation.get('status') != 'dismissed' and str(recommendation.get('id') or '').removeprefix('rec-') not in finding_ids
)
if include_media_issues:
try:
from . import media_pipeline_service
issues = media_pipeline_service.issues(session).get('issues', [])
except Exception as exc: # noqa: BLE001
issues = [{'id': 'media-unavailable', 'title': redact_message(str(exc)), 'affectedConnectors': ['media_pipeline']}]
plans.extend(_plan_for_media_issue(session, issue) for issue in issues if isinstance(issue, dict))
plans.extend(_inventory_plans(session))
plans.extend(_connector_plans(session))
plans.extend(_secret_storage_plan(session))
deduped = list({plan.id: plan for plan in plans}.values())
if audit_run:
_audit(session, 'ai.remediation.generate', 'all', {'plans': len(deduped)})
return RemediationPlanCollection(status='ok', generatedAt=_now(), total=len(deduped), plans=deduped)
def _plan_for_recommendation(session: Session, recommendation: dict[str, Any]) -> RemediationPlan:
source_id = str(recommendation.get('id') or 'recommendation')
finding_id = source_id.removeprefix('rec-')
finding = {
'id': finding_id,
'title': recommendation.get('title') or source_id,
'description': recommendation.get('description') or recommendation.get('suggestedAction') or source_id,
'severity': recommendation.get('severity') or 'medium',
'confidence': recommendation.get('confidence') or 'medium',
'category': 'unknown',
'affectedResources': recommendation.get('affectedResources', []),
'evidence': recommendation.get('evidence', []),
}
plan = _plan_for_finding(session, finding)
return plan.model_copy(update={'id': f'rem-{source_id}', 'sourceType': 'security_recommendation', 'sourceId': source_id, 'status': _get_status(session, f'rem-{source_id}')})
def get_plans(session: Session) -> RemediationPlanCollection:
return generate_plans(session)
def get_plan(session: Session, plan_id: str) -> RemediationPlan | None:
plan = next((item for item in generate_plans(session).plans if item.id == plan_id), None)
if plan:
return plan
if plan_id.startswith('rem-ai-sec-'):
return _plan_for_finding(session, _generic_finding(plan_id.removeprefix('rem-')))
return None
def generate_from_finding(session: Session, finding_id: str) -> RemediationPlan:
finding = ai_security_reviewer.get_finding(session, finding_id) or _generic_finding(finding_id)
plan = _plan_for_finding(session, finding)
_audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_finding', 'sourceId': finding_id})
return plan
def generate_from_recommendation(session: Session, recommendation_id: str) -> RemediationPlan:
recommendation = next((item for item in ai_security_reviewer.get_recommendations(session) if item['id'] == recommendation_id), {'id': recommendation_id, 'title': recommendation_id, 'severity': 'medium', 'confidence': 'medium', 'affectedResources': []})
plan = _plan_for_recommendation(session, recommendation)
_audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_recommendation', 'sourceId': recommendation_id})
return plan
def review_plan(session: Session, plan_id: str) -> dict[str, Any]:
return _set_status(session, plan_id, 'reviewed')
def dismiss_plan(session: Session, plan_id: str) -> dict[str, Any]:
return _set_status(session, plan_id, 'dismissed')
def dry_run(session: Session, plan_id: str) -> RemediationDryRunResult | None:
plan = get_plan(session, plan_id)
if not plan:
return None
step_results: list[RemediationDryRunStepResult] = []
approvals: list[RemediationApprovalRequirement] = []
blocked: list[RemediationApprovalRequirement] = []
reasons: list[str] = ['Planner V1 dry-run only; no steps were executed.']
for step in plan.steps + plan.blockedActions:
decision = step.policyDecision
risk = step.risk
reason = 'No executable action attached.'
if step.actionId and step.target:
policy = evaluate_policy(session, action=step.actionId, target=step.target, source='manual')
decision = str(policy.get('decision') or 'blocked')
risk = str(policy.get('risk') or step.risk)
reason = str(policy.get('reason') or 'Policy evaluated.')
status = 'blocked' if decision == 'blocked' or step.type == 'blocked_action' else ('warning' if decision == 'approval_required' else 'pass')
result = RemediationDryRunStepResult(stepId=step.id, label=step.label, actionId=step.actionId, target=step.target, policyDecision=decision, risk=risk, status=status, reason=reason, canExecute=False)
step_results.append(result)
if step.actionId and step.target and decision == 'approval_required':
approvals.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision))
if step.actionId and step.target and status == 'blocked':
blocked.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision))
missing_secret_names = [item.get('secretRef') for item in list_secret_status(session) if not item.get('hasSecret')]
unresolved = [f'Missing secret: {name}' for name in missing_secret_names if name]
if blocked:
status = 'blocked'
elif approvals or unresolved:
status = 'warning'
else:
status = 'pass'
result = RemediationDryRunResult(planId=plan.id, status=status, executed=False, reasons=reasons, prerequisites=plan.prerequisites, unresolvedBlockers=unresolved, blockedActions=blocked, approvalRequirements=approvals, verificationChecks=plan.verificationChecks, stepResults=step_results)
_audit(session, 'ai.remediation.dry_run', plan_id, {'status': status, 'blocked': len(blocked), 'approvals': len(approvals)})
return result
def create_approval_proposal(session: Session, plan_id: str) -> RemediationApprovalProposal | None:
plan = get_plan(session, plan_id)
if not plan:
return None
proposals = plan.approvalRequirements or [
RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason='Manual approval may be required by policy.', risk=step.risk, policyDecision=step.policyDecision)
for step in plan.steps
if step.actionId and step.target and step.policyDecision == 'approval_required'
]
row = session.get(AppSetting, _setting_key('proposal', plan_id))
if not row:
row = AppSetting(key=_setting_key('proposal', plan_id), is_secret=False)
row.value_json = {'createdAt': _now(), 'proposals': [proposal.model_dump() for proposal in proposals], 'executed': False, 'approvalId': None}
row.updated_at = utc_now()
session.add(row)
session.commit()
_audit(session, 'ai.remediation.approval_proposal', plan_id, {'proposals': len(proposals), 'executed': False})
return RemediationApprovalProposal(planId=plan_id, created=True, executed=False, approvalId=None, proposals=proposals, message='Approval proposal created as planner draft only. No approval or action was executed.')
File diff suppressed because it is too large. Load diff
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import re
from typing import Any
def _text(raw: dict[str, Any]) -> str:
labels = raw.get('labels') if isinstance(raw.get('labels'), dict) else {}
label_text = ' '.join(f'{key} {value}' for key, value in labels.items())
ports = ' '.join(str(port) for port in raw.get('ports', []) if port is not None)
text = f"{raw.get('name', '')} {raw.get('image', '')} {label_text} {ports}".lower()
# Preserve the source while also matching humanized names such as "Gpu Hot"
# against the same conservative patterns used for container IDs and images.
return ' '.join((text, re.sub(r'[\s_]+', '-', text), re.sub(r'[\s-]+', '_', text)))
def _match(text: str, *patterns: str) -> bool:
return any(re.search(pattern, text) for pattern in patterns)
def _base(raw: dict[str, Any]) -> dict[str, Any]:
name = str(raw.get('name') or raw.get('container_id') or 'unknown').strip('/') or 'unknown'
safe_id = re.sub(r'[^a-z0-9_]+', '-', name.lower()).strip('-') or 'unknown'
return {
'suggested_app_id': safe_id.replace('-', '_'),
'display_name': name.replace('-', ' ').replace('_', ' ').title(),
'app_type': 'unknown',
'category': 'unknown',
'criticality': 'low',
'connector': 'none',
'policy_profile': 'generic_unknown',
'protected': False,
'healthcheck_suggestion': None,
'voice_suggestion': 'disabled',
'confidence': 0.2,
'reasons': ['No known pattern matched.'],
'import_status': 'unmapped',
}
def classify_container(raw: dict[str, Any]) -> dict[str, Any]:
text = _text(raw)
result = _base(raw)
def apply(
*,
app_type: str,
category: str,
criticality: str,
connector: str = 'none',
policy_profile: str | None = None,
subcategory: str | None = None,
protected: bool = False,
privacy_sensitive: bool = False,
confidence: float = 0.85,
reason: str,
default_port: int | None = None,
) -> dict[str, Any]:
result.update(
{
'suggested_app_id': app_type,
'display_name': app_type.replace('_', ' ').title(),
'app_type': app_type,
'category': category,
'subcategory': subcategory,
'criticality': criticality,
'connector': connector,
'policy_profile': policy_profile or category,
'protected': protected,
'privacy_sensitive': privacy_sensitive,
'healthcheck_suggestion': {'port': default_port} if default_port else {'type': 'http'},
'voice_suggestion': 'blocked' if protected else 'disabled',
'confidence': confidence,
'reasons': [reason],
'import_status': 'discovered',
}
)
return result
if _match(text, r'\b(binhex-)?sonarr\b', r'arch-sonarr'):
return apply(app_type='sonarr', category='media', criticality='medium', connector='sonarr', policy_profile='media_app', reason='Matched Sonarr name/image pattern.')
if _match(text, r'\b(binhex-)?radarr\b', r'arch-radarr'):
return apply(app_type='radarr', category='media', criticality='medium', connector='radarr', policy_profile='media_app', reason='Matched Radarr name/image pattern.')
if _match(text, r'\bprowlarr\b'):
return apply(app_type='prowlarr', category='media', criticality='medium', connector='prowlarr', policy_profile='media_indexer', reason='Matched Prowlarr pattern.', default_port=9696)
if _match(text, r'\b(binhex-)?deluge(vpn)?\b', r'arch-delugevpn', r'linuxserver/deluge'):
return apply(app_type='deluge', category='downloads', subcategory='torrent_client', criticality='medium', connector='deluge', policy_profile='download_client', reason='Matched Deluge download client pattern.', default_port=8112)
if _match(text, r'\b(binhex-)?plex(pass)?\b', r'arch-plexpass', r'linuxserver/plex', r'plexinc/pms-docker'):
return apply(app_type='plex', category='media', subcategory='media_server', criticality='medium', connector='plex', policy_profile='media_server', reason='Matched Plex media server pattern.', default_port=32400)
if _match(text, r'\bjellyfin\b'):
return apply(app_type='jellyfin', category='media', criticality='medium', connector='none', policy_profile='media_app', reason='Matched Jellyfin media server pattern; no AppOps Jellyfin connector is registered.')
if _match(text, r'\b(binhex-)?tautulli\b', r'linuxserver/tautulli', r'tautulli/tautulli'):
return apply(app_type='tautulli', category='media', subcategory='plex_analytics', criticality='medium', connector='tautulli', policy_profile='media_analytics', privacy_sensitive=True, reason='Matched Tautulli Plex analytics pattern.', default_port=8181)
if _match(text, r'\b(qbittorrent|deluge|sabnzbd|nzbget)\b'):
app_type = next((name for name in ['qbittorrent', 'deluge', 'sabnzbd', 'nzbget'] if name in text), 'downloads')
return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download client pattern.')
if _match(text, r'\bauthentik\b', r'\bauthentik-(server|worker)\b', r'ghcr\.io/goauthentik/(server|proxy)', r'beryju/authentik', r'\bgoauthentik\b'):
return apply(
app_type='authentik',
category='auth',
subcategory='identity_provider',
criticality='critical',
connector='authentik',
protected=True,
policy_profile='auth_provider',
reason='Matched Authentik identity provider pattern.',
default_port=9000,
)
if _match(text, r'\b(postgres|postgresql|mariadb|mysql)\b'):
app_type = next((name for name in ['postgres', 'postgresql', 'mariadb', 'mysql'] if name in text), 'database')
return apply(app_type=app_type, category='database', criticality='critical', protected=True, policy_profile='database', reason='Matched database pattern.')
if _match(text, r'\bredis\b'):
return apply(app_type='redis', category='database', criticality='high', protected=True, policy_profile='database', reason='Matched Redis pattern.')
if _match(text, r'\b(nginxproxymanager|nginx-proxy-manager|jc21/nginx-proxy-manager|npm)\b'):
return apply(
app_type='nginx_proxy_manager',
category='network',
subcategory='reverse_proxy',
criticality='critical',
connector='nginx_proxy_manager',
protected=True,
policy_profile='reverse_proxy',
reason='Matched Nginx Proxy Manager reverse proxy pattern.',
default_port=81,
)
if _match(text, r'\bportainer\b'):
return apply(app_type='portainer', category='infrastructure', criticality='critical', protected=True, policy_profile='protected_service', reason='Matched Portainer pattern.')
if _match(text, r'\b(homeassistant|home-assistant|home_assistant)\b'):
return apply(app_type='home_assistant', category='home_automation', criticality='high', protected=True, policy_profile='protected_service', reason='Matched Home Assistant pattern.')
if _match(text, r'\bgitea\b'):
return apply(app_type='gitea', category='dev', criticality='medium', policy_profile='dev_tool', reason='Matched Gitea pattern.')
if _match(text, r'\bwidefrog\b'):
return apply(app_type='widefrog', category='media', criticality='medium', connector='widefrog', policy_profile='media_custom_app', reason='Matched Widefrog pattern.', default_port=1207)
if _match(text, r'\b(adguard|adguardhome|adguard-home)\b'):
return apply(app_type='adguard_home', category='network', criticality='high', protected=True, policy_profile='network_service', reason='Matched AdGuard network service pattern.')
if _match(text, r'\b(vaultwarden|bitwarden)\b'):
return apply(app_type='vaultwarden', category='auth', criticality='critical', protected=True, privacy_sensitive=True, policy_profile='auth_provider', reason='Matched credential vault pattern.')
if _match(text, r'\b(adminer|phpmyadmin)\b'):
app_type = 'adminer' if 'adminer' in text else 'phpmyadmin'
return apply(app_type=app_type, category='database', criticality='high', protected=True, policy_profile='database_admin', reason='Matched database administration pattern.')
if _match(text, r'\b(audiobookshelf|audiodeck|bazarr|seerr|komga|lidarr|mylar3?|immich|peertube|romm|tdarr|grimmory|listenarr|readmeabook)\b'):
app_type = next((name for name in ['audiobookshelf', 'audiodeck', 'bazarr', 'seerr', 'komga', 'lidarr', 'mylar3', 'immich', 'peertube', 'romm', 'tdarr', 'grimmory', 'listenarr', 'readmeabook'] if name.replace('_', '-') in text or name in text), 'media_app')
protected = app_type == 'immich'
return apply(app_type=app_type, category='media', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='media_app', reason='Matched media library or companion app pattern.')
if _match(text, r'\b(paperless-ngx|paperless_ngx|stirlingpdf|stirling-pdf|leafwiki|infrabinder-local|infrabinder_local)\b'):
app_type = next((name for name in ['paperless_ngx', 'stirlingpdf', 'leafwiki', 'infrabinder_local'] if name.replace('_', '-') in text or name in text), 'document_service')
protected = app_type in {'paperless_ngx', 'infrabinder_local'}
return apply(app_type=app_type, category='productivity', subcategory='documents', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=True, policy_profile='document_service', reason='Matched document or knowledge-management service pattern.')
if _match(text, r'\b(jdownloader2?|metube|slskd|unpackerr)\b'):
app_type = next((name for name in ['jdownloader2', 'metube', 'slskd', 'unpackerr'] if name in text), 'download_tool')
return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download workflow pattern.')
if _match(text, r'\b(ollama|anythingllm|litellm|chromadb|hivekeep|odysseus-aio|odysseus_aio)\b'):
app_type = next((name for name in ['ollama', 'anythingllm', 'litellm', 'chromadb', 'hivekeep', 'odysseus_aio'] if name.replace('_', '-') in text or name in text), 'ai_service')
category = 'database' if app_type == 'chromadb' else 'infrastructure'
protected = app_type in {'chromadb', 'hivekeep', 'odysseus_aio'}
return apply(app_type=app_type, category=category, criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='database' if app_type == 'chromadb' else 'ai_service', reason='Matched local AI platform pattern.')
if _match(text, r'\b(searxng|flaresolverr|postfix-relay|postfix_relay)\b'):
app_type = next((name for name in ['searxng', 'flaresolverr', 'postfix_relay'] if name.replace('_', '-') in text or name in text), 'network_service')
return apply(app_type=app_type, category='network', criticality='medium', protected=app_type == 'postfix_relay', policy_profile='network_service', reason='Matched network support service pattern.')
if _match(text, r'\b(unraid-appops-gateway|unraid_appops_gateway|watchtower)\b'):
app_type = 'unraid_appops_gateway' if 'appops' in text else 'watchtower'
return apply(app_type=app_type, category='infrastructure', criticality='critical' if app_type == 'unraid_appops_gateway' else 'high', protected=True, policy_profile='protected_service', reason='Matched infrastructure control-plane service pattern.')
if _match(text, r'\b(organizrv?2?|organizr|unraid-progressive-web-app|unraid_progressive_web_app|ntfy)\b'):
app_type = next((name for name in ['organizrv2', 'unraid_progressive_web_app', 'ntfy'] if name.replace('_', '-') in text or name in text), 'infrastructure_service')
return apply(app_type=app_type, category='infrastructure', criticality='medium', policy_profile='infrastructure_service', reason='Matched self-hosted infrastructure support service pattern.')
if _match(text, r'\b(retroarch|rickroll)\b'):
app_type = 'retroarch' if 'retroarch' in text else 'rickroll'
return apply(app_type=app_type, category='media', criticality='low', policy_profile='media_app', reason='Matched media or entertainment service pattern.')
if _match(text, r'\bdockdeck\b', r'dockdeck:local'):
return apply(app_type='dockdeck', category='infrastructure', subcategory='service_portal', criticality='medium', privacy_sensitive=True, policy_profile='internal_service', reason='Matched the local DockDeck self-hosted start dashboard pattern.', default_port=1218)
if _match(text, r'\blaunchpad\b', r'launchpad:local'):
return apply(app_type='launchpad', category='infrastructure', subcategory='service_portal', criticality='medium', policy_profile='internal_service', reason='Matched the local Launchpad service portal pattern.')
if _match(text, r'\bmedia-codec-dashboard\b', r'\bmedia_codec_dashboard\b'):
return apply(app_type='media_codec_dashboard', category='media', subcategory='media_observability', criticality='medium', privacy_sensitive=True, policy_profile='media_app', reason='Matched the local media codec observability dashboard pattern.')
if _match(text, r'\bgeointel\b'):
return apply(app_type='geointel', category='ai', subcategory='geospatial_workbench', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local GeoIntel GeoAI workbench pattern.')
if _match(text, r'\bopscenter-it-tools\b', r'\bopscenter_it_tools\b'):
return apply(app_type='opscenter_it_tools', category='dev', subcategory='operations_tooling', criticality='high', protected=True, privacy_sensitive=True, policy_profile='dev_tool', reason='Matched the local OpsCenter diagnostics workbench pattern.')
if _match(text, r'\bprojectbrain\b'):
return apply(app_type='projectbrain', category='productivity', subcategory='project_operations', criticality='high', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local ProjectBrain project operating system pattern.')
if _match(text, r'\bpsforge\b'):
return apply(app_type='psforge', category='productivity', subcategory='project_planning', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local PSForge planning and documentation workbench pattern.')
if _match(text, r'\bquizmonitor\b', r'\bquiz-monitor\b'):
return apply(app_type='quizmonitor', category='ai', subcategory='knowledge_assistant', criticality='high', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local QuizMonitor AI knowledge platform pattern.')
if _match(text, r'\bspoortracker\b'):
return apply(app_type='spoortracker', category='monitoring', subcategory='transport_monitoring', criticality='low', policy_profile='monitoring', reason='Matched the local SpoorTracker live transport monitoring pattern.')
if _match(text, r'\bcertscope\b'):
return apply(app_type='certscope', category='monitoring', subcategory='domain_inspection', criticality='low', policy_profile='monitoring', reason='Matched the local CertScope domain posture inspector pattern.')
if _match(text, r'\bps4-http\b', r'\bps4_http\b'):
return apply(app_type='ps4_http', category='infrastructure', subcategory='http_service', criticality='low', policy_profile='infrastructure_service', reason='Matched the local PS4 HTTP support service pattern.')
if _match(text, r'\b(zabbix|grafana|uptime-kuma|glances|netdata|netronome|unraid-stats|unraid_stats|system-monitor|system_monitor|gpu-hot|gpu_hot)\b'):
app_type = next((name for name in ['zabbix', 'grafana', 'uptime-kuma', 'glances', 'netdata', 'netronome', 'unraid_stats', 'system_monitor', 'gpu_hot'] if name.replace('_', '-') in text or name in text), 'monitoring')
return apply(app_type=app_type.replace('-', '_'), category='monitoring', criticality='medium', policy_profile='monitoring', reason='Matched monitoring pattern.')
return result
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import re
from sqlmodel import Session, select
from ..db.models import App
_APPOPS_ROLLBACK_ID = re.compile(
r'^unraid_appops_gateway(?:_1216)?_pre_\d{8}t\d{6}z$',
re.IGNORECASE,
)
def is_appops_deployment_container_name(value: str | None) -> bool:
key = str(value or '').strip().lower().replace('_', '-')
return key in {'unraid-appops-gateway', 'unraid-appops-gateway-1216'} or bool(
re.fullmatch(r'unraid-appops-gateway(?:-1216)?-pre-\d{8}t\d{6}z', key)
)
def is_historical_app_record(app: App) -> bool:
"""Keep deployment history out of active product views without deleting it."""
return bool(_APPOPS_ROLLBACK_ID.fullmatch(str(app.id or '').strip()))
def category_label(value: str | None) -> str:
words = str(value or 'Unknown').strip().replace('_', ' ').replace('-', ' ').title()
replacements = {'Ai': 'AI', 'Api': 'API', 'It': 'IT'}
return ' '.join(replacements.get(word, word) for word in words.split()) or 'Unknown'
def list_active_apps(session: Session, *, order_by: str = 'name') -> list[App]:
apps = [app for app in session.exec(select(App)).all() if not is_historical_app_record(app)]
# A port-specific canonical record supersedes the old unqualified 1211 record.
if any(app.id == 'unraid_appops_gateway_1216' for app in apps):
apps = [app for app in apps if app.id != 'unraid_appops_gateway']
key = (lambda app: app.id.lower()) if order_by == 'id' else (lambda app: app.name.lower())
return sorted(apps, key=key)
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import hashlib
import json
import uuid
from datetime import timedelta
from sqlmodel import Session, select
from ..db.models import Approval
from ..core.time import utc_now
VALID_DECISIONS = {'approved', 'rejected'}
class ApprovalStateError(ValueError):
pass
def approval_fingerprint(action: str, target: str, source: str, risk: str, context: dict | None = None) -> str:
canonical_context = json.dumps(context or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
canonical = "\x1f".join([*(part.strip() for part in (action, target, source, risk)), canonical_context])
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def get_approval(session: Session, approval_id: str) -> Approval | None:
approval = session.get(Approval, approval_id)
if approval and not approval.action_fingerprint:
approval.action_fingerprint = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json)
session.add(approval)
session.commit()
session.refresh(approval)
if approval and _expire_if_needed(session, approval):
session.refresh(approval)
return approval
def list_approvals(session: Session, *, include_dismissed: bool = False):
statement = select(Approval).order_by(Approval.created_at.desc())
if not include_dismissed:
statement = statement.where(Approval.dismissed_at.is_(None))
approvals = session.exec(statement).all()
changed = any(_expire_if_needed(session, approval, commit=False) for approval in approvals)
if changed:
session.commit()
for approval in approvals:
session.refresh(approval)
return approvals
def dismiss_expired_approvals(session: Session) -> list[Approval]:
"""Archive only terminal expired approvals while retaining their audit evidence."""
approvals = list_approvals(session)
dismissed_at = utc_now()
dismissed = [approval for approval in approvals if approval.status == 'expired']
for approval in dismissed:
approval.dismissed_at = dismissed_at
session.add(approval)
if dismissed:
session.commit()
for approval in dismissed:
session.refresh(approval)
return dismissed
def _expire_if_needed(session: Session, approval: Approval, *, commit: bool = True) -> bool:
if approval.status != 'pending' or approval.expires_at is None or approval.expires_at > utc_now():
return False
approval.status = 'expired'
approval.decided_at = utc_now()
session.add(approval)
if commit:
session.commit()
return True
def create_approval(
session: Session,
*,
action: str,
target: str,
source: str,
risk: str,
reason: str | None = None,
requester: str = 'manual',
expires_in_minutes: int = 30,
context: dict | None = None,
) -> Approval:
approval = Approval(
id=f'appr-{uuid.uuid4().hex[:12]}',
action=action,
target=target,
source=source,
risk=risk,
status='pending',
requester=requester,
reason=reason,
expires_at=(utc_now() + timedelta(minutes=expires_in_minutes)),
request_context_json=context or {},
action_fingerprint=approval_fingerprint(action, target, source, risk, context),
)
session.add(approval)
session.commit()
session.refresh(approval)
return approval
def update_approval_status(session: Session, approval_id: str, status: str, *, actor: str = "operator") -> Approval | None:
approval = get_approval(session, approval_id)
if not approval:
return None
if status not in VALID_DECISIONS:
raise ApprovalStateError('Approval status must be approved or rejected.')
if approval.status == 'expired':
raise ApprovalStateError('Approval has expired and can no longer be decided.')
if approval.status != 'pending':
raise ApprovalStateError(f'Approval is already {approval.status}.')
approval.status = status
approval.decided_at = utc_now()
approval.approved_by = actor if status == 'approved' else None
session.add(approval)
session.commit()
session.refresh(approval)
return approval
def mark_approval_executed(
session: Session,
approval: Approval,
*,
job_id: str,
audit_event_id: str,
actor: str = "operator",
) -> Approval:
if approval.status != 'approved':
raise ApprovalStateError('Only an approved request can be executed.')
expected = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json)
if not approval.action_fingerprint or approval.action_fingerprint != expected:
raise ApprovalStateError('Approval action context changed after review.')
approval.status = 'executed'
approval.executed_at = utc_now()
approval.job_id = job_id
approval.audit_event_id = audit_event_id
approval.executed_by = actor
session.add(approval)
session.commit()
session.refresh(approval)
return approval
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlmodel import Session, select
from ..db.models import AuditEvent
from ..core.time import utc_now
from ..core.redaction import redact_message, redact_value
def _redact_mapping(value: dict[str, Any] | None) -> dict[str, Any] | None:
return redact_value(value) if value else None
def log_event(
session: Session,
*,
source: str,
identity: str,
action_id: str,
target: str,
risk: str,
policy_decision: str,
reason: str,
result: str,
target_type: str = 'app',
execution_plan: dict[str, Any] | None = None,
before_state: dict[str, Any] | None = None,
after_state: dict[str, Any] | None = None,
post_checks: dict[str, Any] | None = None,
redacted_metadata: dict[str, Any] | None = None,
) -> AuditEvent:
event = AuditEvent(
id=f'audit-{uuid.uuid4().hex[:12]}',
timestamp=utc_now(),
source=source,
identity=identity,
action_id=redact_message(action_id),
target=redact_message(target),
target_type=redact_message(target_type),
risk=risk,
policy_decision=policy_decision,
reason=redact_message(reason),
execution_plan=_redact_mapping(execution_plan) or {},
result=result,
before_state=_redact_mapping(before_state),
after_state=_redact_mapping(after_state),
post_checks=_redact_mapping(post_checks),
redacted_metadata=_redact_mapping(redacted_metadata),
)
session.add(event)
session.commit()
session.refresh(event)
return event
def list_events(session: Session, *, limit: int = 200, offset: int = 0):
statement = (
select(AuditEvent)
.order_by(AuditEvent.timestamp.desc(), AuditEvent.id.desc())
.offset(max(0, offset))
.limit(max(1, min(limit, 500)))
)
return session.exec(statement).all()
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector
from ..connectors.deluge import DelugeConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.plex import PlexConnector
from ..connectors.portainer import PortainerConnector
from ..connectors.prowlarr import ProwlarrConnector
from ..connectors.radarr import RadarrConnector
from ..connectors.sonarr import SonarrConnector
from ..connectors.tautulli import TautulliConnector
from ..connectors.unraid import UnraidConnector
from ..connectors.widefrog import WidefrogConnector
from ..core.time import utc_now
from ..db.models import App, Connector, NotificationChannel
from . import notification_service, safety_center_service
from .setup_service import apply_stored_connector_settings
_TESTERS: dict[str, Callable[[], Any]] = {
'authentik': AuthentikConnector,
'deluge': DelugeConnector,
'nginx_proxy_manager': NginxProxyManagerConnector,
'plex': PlexConnector,
'portainer': PortainerConnector,
'prowlarr': ProwlarrConnector,
'radarr': RadarrConnector,
'sonarr': SonarrConnector,
'tautulli': TautulliConnector,
'unraid': UnraidConnector,
'widefrog': WidefrogConnector,
}
def _stored_status(status: str) -> str:
return 'error' if status == 'unreachable' else status
def test_and_persist_connector(session: Session, connector_id: str) -> dict[str, Any]:
connector = session.get(Connector, connector_id)
if not connector:
return {'status': 'disconnected', 'reason': 'connector_not_found'}
apply_stored_connector_settings(session, {connector_id})
if connector_id == 'home_assistant':
channel = session.get(NotificationChannel, 'home_assistant')
result = (
notification_service.test_channel_connection(session, 'home_assistant')
if channel
else {'status': 'missing_config', 'reason': 'notification_channel_not_found'}
)
else:
factory = _TESTERS.get(connector_id)
result = factory().test_connection() if factory else {'status': 'error', 'reason': 'test_not_implemented'}
status = str(result.get('status') or 'disconnected')
persisted_status = _stored_status(status)
connector.status = persisted_status
connector.last_test = utc_now().isoformat()
session.add(connector)
app = session.get(App, connector_id)
if app and app.connector_status != persisted_status:
app.connector_status = persisted_status
session.add(app)
session.commit()
safety_center_service.invalidate_cache()
return {**result, 'status': status}
def refresh_connector_statuses(session: Session) -> dict[str, Any]:
connector_ids = list(session.exec(select(Connector.id).order_by(Connector.id)).all())
results: dict[str, str] = {}
for connector_id in connector_ids:
if connector_id not in _TESTERS and connector_id != 'home_assistant':
continue
result = test_and_persist_connector(session, connector_id)
results[connector_id] = str(result.get('status') or 'disconnected')
return {
'success': True,
'status': 'ok',
'tested': len(results),
'connected': sum(status == 'connected' for status in results.values()),
'results': results,
}
@@ -0,0 +1,296 @@
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
import httpx
from ..connectors.portainer import PortainerConnector
from ..core.config import settings
from ..core.redaction import redact_message
from ..db.models import JobRun
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.job_runner import complete_job, create_job, start_job
from ..services.policy_engine import evaluate_policy
def _widefrog_health_endpoint() -> tuple[str | None, str | None]:
raw_url = str(settings.apis.get("WIDEFROG_URL") or "").strip()
if not raw_url:
return None, "missing_config"
candidate = (
raw_url if raw_url.startswith(("http://", "https://")) else f"http://{raw_url}"
)
try:
parsed = urlparse(candidate)
except Exception:
return None, "invalid_widefrog_url"
host = parsed.hostname
if not host:
return None, "invalid_widefrog_url"
port = parsed.port or 1207
return f"{parsed.scheme}://{host}:{port}/", None
def _widefrog_health_check() -> dict[str, Any]:
health_url, reason = _widefrog_health_endpoint()
if not health_url:
return {"status": "error", "reason": reason or "missing_config"}
try:
with httpx.Client(timeout=4.0, verify=settings.verify_tls) as client:
response = client.get(health_url)
except (httpx.RequestError, ValueError) as exc:
return {"status": "error", "reason": redact_message(str(exc))}
if response.status_code >= 500:
return {
"status": "error",
"reason": f"widefrog health check failed: HTTP {response.status_code}",
}
if response.status_code >= 400:
return {
"status": "warning",
"reason": f"widefrog health returned HTTP {response.status_code}",
}
return {"status": "ok"}
def _format_success_result(
summary: str,
*,
steps: list[str] | None = None,
before_state: dict[str, Any] | None = None,
after_state: dict[str, Any] | None = None,
post_checks: list[str] | None = None,
) -> dict[str, Any]:
return {
"success": True,
"decision": "allowed",
"status": "success",
"policy_decision": "allowed",
"result": "success",
"message": summary,
"plan": steps or [],
"steps": steps or [],
"before_state": before_state or {},
"after_state": after_state or {},
"post_checks": post_checks or [],
}
def _format_blocked_result(audit_id: str, reason: str) -> dict[str, Any]:
return {
"success": False,
"decision": "blocked",
"status": "failed",
"policy_decision": "blocked",
"result": "blocked",
"message": reason,
"auditId": audit_id,
}
def _format_approval_result(
audit_id: str, approval_id: str, reason: str
) -> dict[str, Any]:
return {
"success": False,
"decision": "approval_required",
"status": "failed",
"policy_decision": "approval_required",
"result": "approval_required",
"message": reason,
"approvalId": approval_id,
"auditId": audit_id,
}
def execute_container_restart(
*,
session,
action: str,
target: str,
source: str = "manual",
risk: str = "medium",
steps: list[str] | None = None,
approved: bool = False,
policy_override: dict[str, Any] | None = None,
):
plan_steps = steps or [
"Resolve container",
"Restart container",
"Post-check health",
]
if policy_override is not None:
decision = {
"decision": str(policy_override.get("decision") or "blocked"),
"reason": str(policy_override.get("reason") or "Policy override."),
"risk": str(policy_override.get("risk") or risk),
}
elif approved:
decision = {
"decision": "allowed",
"reason": "Exact action context was approved by an AppOps operator.",
"risk": risk,
}
else:
decision = evaluate_policy(session, action=action, target=target, source=source)
if decision["decision"] == "blocked":
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision["decision"],
reason=decision["reason"],
result="blocked",
execution_plan={"plan": plan_steps, "policyReason": decision["reason"]},
)
return _format_blocked_result(audit.id, decision["reason"])
if decision["decision"] == "approval_required":
approval = create_approval(
session,
action=action,
target=target,
source=source,
risk=risk,
reason=decision["reason"],
requester=source,
)
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision["decision"],
reason=decision["reason"],
result="approval_requested",
execution_plan={"plan": plan_steps, "approvalId": approval.id},
redacted_metadata={"approval_id": approval.id},
)
return _format_approval_result(
audit.id, approval.id, "Action requires approval before execution."
)
# Allowed restart flow
job: JobRun = create_job(
session,
action_id=action,
target=target,
source=source,
risk=risk,
policy_decision=decision["decision"],
steps=plan_steps,
)
start_job(session, job)
try:
connector = PortainerConnector()
precheck = connector.get_container_status(target)
if precheck.get("status") != "ok":
# keep execution context in logs while still allowing best-effort continuation
precheck = {
"status": "warn",
"container": {"name": target},
"reason": precheck.get("reason"),
}
restart_result = connector.restart_container(target)
if restart_result.get("status") != "ok":
raise RuntimeError(
str(restart_result.get("reason", "container_restart_failed"))
)
post_check = {"container": restart_result.get("container", target)}
if target == "widefrog":
health = _widefrog_health_check()
post_checks = [
f"widefrog health: {health.get('status')}",
f"widefrog reason: {health.get('reason', '')}".strip(),
]
post_check["widefrog_health"] = health
else:
post_checks = []
health = {}
completed = complete_job(
session,
job,
status="succeeded",
result_summary="Container restart executed through Portainer connector.",
)
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision["decision"],
reason=decision["reason"],
result="success",
execution_plan={
"plan": plan_steps,
"jobId": completed.id,
"precheck": precheck,
},
before_state=precheck,
after_state=post_check,
post_checks=health if target == "widefrog" else None,
)
result = _format_success_result(
"Container restart executed.",
steps=plan_steps,
before_state=precheck,
after_state=post_check,
post_checks=post_checks,
)
result["jobId"] = completed.id
result["auditId"] = audit.id
return result
except Exception as exc:
failed = complete_job(
session,
job,
status="failed",
result_summary=f"Container restart failed: {redact_message(str(exc))}",
)
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
risk=risk,
policy_decision=decision["decision"],
reason="Container restart execution failed.",
result="failed",
execution_plan={"plan": plan_steps, "jobId": failed.id},
after_state={"status": "failed", "reason": redact_message(str(exc))},
)
return {
"success": False,
"decision": "failed",
"status": "failed",
"policy_decision": decision["decision"],
"result": "failed",
"message": redact_message(str(exc)),
"jobId": failed.id,
"auditId": audit.id,
}
@@ -0,0 +1,382 @@
from __future__ import annotations
import json
import re
import time
from datetime import datetime
from typing import Any
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
from ..connectors.portainer import PortainerConnector
from ..core.config import settings
from ..core.redaction import redact_message
from .deployment_broker import DeploymentBrokerClient
SOURCE_REPOSITORY = "jens/unraid-appops-gateway"
GITEA_OWNER = "Jens"
GITEA_REPOSITORY = "Unraid-AppOps-Gateway"
REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"
HELPER_NAME = "itworx-controller-artifact-promoter"
CONTROLLER_CONTAINER = "gitea-deploy-control"
CONTROLLER_VERSION = "2.1.5"
HELPER_LABEL = "itworx.controller.promoter"
_HELPER_TIMEOUT_SECONDS = 240
_REVISION = re.compile(r"^[0-9a-f]{40}$")
_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$")
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$")
class ControllerArtifactPromotionError(RuntimeError):
pass
def helper_container_spec(*, revision: str, image_id: str) -> dict[str, Any]:
if not _REVISION.fullmatch(revision):
raise ValueError("controller_promoter_revision_invalid")
if not _IMAGE_ID.fullmatch(image_id):
raise ValueError("controller_promoter_image_invalid")
command = f"""set -Eeuo pipefail
revision='{revision}'
version='{CONTROLLER_VERSION}'
repo='/gitea-repositories/jens/unraid-appops-gateway.git'
work='/mnt/cache/appdata/gitea-deploy-runners/.controller-promoter-{revision[:12]}'
rm -rf -- "$work"
install -d -m 0700 "$work"
cleanup() {{ rm -rf -- "$work"; }}
trap cleanup EXIT
command -v git >/dev/null
command -v docker >/dev/null
docker compose version >/dev/null
command -v python3 >/dev/null
command -v tar >/dev/null
command -v curl >/dev/null
git --git-dir="$repo" cat-file -e "$revision^{{commit}}"
tree="$(git --git-dir="$repo" rev-parse "$revision^{{tree}}")"
git --git-dir="$repo" archive --format=tar "$revision" \
ops/gitea-deploy-broker \
scripts/prepare_controller_artifact.py \
scripts/reconcile_controller_state.py \
scripts/run_repopulse_adoption.py > "$work/controller.tar"
archive_sha="$(sha256sum "$work/controller.tar" | cut -d ' ' -f 1)"
git --git-dir="$repo" show "$revision:scripts/upgrade_deployment_controller.sh" > "$work/upgrade.sh"
chmod 0700 "$work/upgrade.sh"
bash "$work/upgrade.sh" \
--archive "$work/controller.tar" \
--archive-sha256 "$archive_sha" \
--source-repository '{SOURCE_REPOSITORY}' \
--source-revision "$revision" \
--source-tree "$tree" \
--controller-version "$version" \
--confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2
"""
return {
"Image": image_id,
"User": "0:0",
"Cmd": ["/bin/bash", "-lc", command],
"Labels": {
HELPER_LABEL: "true",
"itworx.source.repository": SOURCE_REPOSITORY,
"itworx.source.revision": revision,
"itworx.controller.version": CONTROLLER_VERSION,
},
"HostConfig": {
"Binds": [
"/mnt/cache:/mnt/cache",
"/mnt/user:/mnt/user",
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
"/var/run/docker.sock:/var/run/docker.sock",
],
"NetworkMode": "host",
"Privileged": False,
"SecurityOpt": ["no-new-privileges:true"],
"RestartPolicy": {"Name": "no"},
},
}
class ControllerArtifactPromoter:
def __init__(
self,
*,
portainer: PortainerConnector | None = None,
broker: DeploymentBrokerClient | None = None,
) -> None:
self.portainer = portainer or PortainerConnector()
self.broker = broker or DeploymentBrokerClient()
@staticmethod
def _gitea_base_url() -> str:
raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/")
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
raise ControllerArtifactPromotionError("controller_promoter_gitea_url_invalid")
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
@staticmethod
def _gitea_token() -> str:
token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "").strip()
if not token:
raise ControllerArtifactPromotionError("controller_promoter_gitea_token_missing")
return token
@staticmethod
def _status_key(item: dict[str, Any]) -> tuple[datetime, int] | None:
raw = str(item.get("updated_at") or item.get("created_at") or "")
if not _TIMESTAMP.fullmatch(raw):
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
identifier = item.get("id")
return parsed, identifier if isinstance(identifier, int) and not isinstance(identifier, bool) else -1
def _validated_main_revision(self) -> str:
branch = "main"
base = self._gitea_base_url()
headers = {
"Authorization": f"token {self._gitea_token()}",
"Accept": "application/json",
"User-Agent": "itworx-controller-artifact-promoter/1",
}
timeout = max(2.0, min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8)))
verify = bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls))
owner = quote(GITEA_OWNER, safe="")
repository = quote(GITEA_REPOSITORY, safe="")
try:
with httpx.Client(timeout=timeout, verify=verify, follow_redirects=False) as client:
branch_response = client.get(
f"{base}/api/v1/repos/{owner}/{repository}/branches/{branch}",
headers=headers,
)
if branch_response.status_code != 200:
raise ControllerArtifactPromotionError("controller_promoter_main_unavailable")
branch_payload = branch_response.json()
commit = branch_payload.get("commit") if isinstance(branch_payload, dict) else None
revision = str(commit.get("id") or "") if isinstance(commit, dict) else ""
if (
not isinstance(branch_payload, dict)
or branch_payload.get("name") != branch
or branch_payload.get("protected") is not True
or not _REVISION.fullmatch(revision)
):
raise ControllerArtifactPromotionError("controller_promoter_main_not_protected")
status_response = client.get(
f"{base}/api/v1/repos/{owner}/{repository}/statuses/{revision}",
params={"limit": 100, "page": 1},
headers=headers,
)
if status_response.status_code != 200:
raise ControllerArtifactPromotionError("controller_promoter_status_unavailable")
statuses = status_response.json()
except ControllerArtifactPromotionError:
raise
except (httpx.RequestError, ValueError) as exc:
raise ControllerArtifactPromotionError(
"controller_promoter_gitea_unavailable"
) from exc
if not isinstance(statuses, list) or len(statuses) > 100:
raise ControllerArtifactPromotionError("controller_promoter_status_invalid")
latest: tuple[tuple[datetime, int], str] | None = None
for item in statuses:
if not isinstance(item, dict) or item.get("context") != REQUIRED_PUSH_CONTEXT:
continue
state = str(item.get("status") or "")
if state not in {"pending", "success", "failure", "error", "warning", "neutral", "cancelled"}:
continue
key = self._status_key(item)
if key is not None and (latest is None or key > latest[0]):
latest = (key, state)
if latest is None or latest[1] != "success":
raise ControllerArtifactPromotionError("controller_promoter_main_not_validated")
return revision
def _portainer_endpoint(self) -> str:
config = self.portainer._config()
if config is None or not config.endpoint_id:
raise ControllerArtifactPromotionError("controller_promoter_portainer_unavailable")
return config.endpoint_id
def _controller_image_id(self) -> str:
listed = self.portainer.list_containers()
containers = listed.get("containers") if isinstance(listed, dict) else None
candidate = next(
(
item
for item in containers or []
if isinstance(item, dict) and item.get("name") == CONTROLLER_CONTAINER
),
None,
)
if candidate is None or candidate.get("state") != "running":
raise ControllerArtifactPromotionError("controller_promoter_controller_unavailable")
identifier = str(candidate.get("id") or "")
endpoint = self._portainer_endpoint()
try:
response = self.portainer._request(
"GET",
f"/api/endpoints/{endpoint}/docker/containers/{quote(identifier, safe='')}/json",
)
payload = response.json()
except (RuntimeError, ValueError) as exc:
raise ControllerArtifactPromotionError(
"controller_promoter_controller_inspect_failed"
) from exc
image_id = str(payload.get("Image") or "") if isinstance(payload, dict) else ""
if not _IMAGE_ID.fullmatch(image_id):
raise ControllerArtifactPromotionError("controller_promoter_controller_image_unpinned")
return image_id
def _existing_helper(self) -> dict[str, Any] | None:
listed = self.portainer.list_containers()
containers = listed.get("containers") if isinstance(listed, dict) else None
return next(
(
item
for item in containers or []
if isinstance(item, dict) and item.get("name") == HELPER_NAME
),
None,
)
def _remove_helper(self, identifier: str) -> None:
endpoint = self._portainer_endpoint()
self.portainer._request(
"DELETE",
f"/api/endpoints/{endpoint}/docker/containers/{quote(identifier, safe='')}",
params={"v": "1", "force": "0"},
)
def _run_helper(self, *, revision: str, image_id: str) -> dict[str, Any]:
endpoint = self._portainer_endpoint()
existing = self._existing_helper()
if existing is not None:
identifier = str(existing.get("id") or "")
if existing.get("state") == "running":
raise ControllerArtifactPromotionError("controller_promoter_already_running")
if identifier:
try:
inspect = self.portainer._request(
"GET",
f"/api/endpoints/{endpoint}/docker/containers/{quote(identifier, safe='')}/json",
).json()
except (RuntimeError, ValueError) as exc:
raise ControllerArtifactPromotionError(
"controller_promoter_helper_inspect_failed"
) from exc
labels = ((inspect.get("Config") or {}).get("Labels") or {}) if isinstance(inspect, dict) else {}
if not isinstance(labels, dict) or labels.get(HELPER_LABEL) != "true":
raise ControllerArtifactPromotionError("controller_promoter_name_conflict")
self._remove_helper(identifier)
spec = helper_container_spec(revision=revision, image_id=image_id)
try:
created_response = self.portainer._request(
"POST",
f"/api/endpoints/{endpoint}/docker/containers/create",
params={"name": HELPER_NAME},
json=spec,
)
created = created_response.json()
helper_id = str(created.get("Id") or "") if isinstance(created, dict) else ""
if not helper_id:
raise ControllerArtifactPromotionError("controller_promoter_create_failed")
self.portainer._request(
"POST",
f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/start",
)
except ControllerArtifactPromotionError:
raise
except (RuntimeError, ValueError) as exc:
raise ControllerArtifactPromotionError("controller_promoter_start_failed") from exc
deadline = time.monotonic() + _HELPER_TIMEOUT_SECONDS
state: dict[str, Any] = {}
while time.monotonic() < deadline:
try:
payload = self.portainer._request(
"GET",
f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/json",
).json()
except (RuntimeError, ValueError) as exc:
raise ControllerArtifactPromotionError(
"controller_promoter_helper_inspect_failed"
) from exc
state = payload.get("State") if isinstance(payload, dict) and isinstance(payload.get("State"), dict) else {}
if state.get("Running") is False:
break
time.sleep(2)
else:
return {
"status": "outcome_unknown",
"revision": revision,
"reason": "controller_promoter_timeout",
"helperId": helper_id,
}
exit_code = state.get("ExitCode")
logs = self.portainer.get_container_logs(helper_id, tail=200)
log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else []
tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:]
if exit_code != 0:
try:
self._remove_helper(helper_id)
except RuntimeError:
pass
return {
"status": "failed",
"revision": revision,
"reason": "controller_promoter_helper_failed",
"exitCode": exit_code,
"logTail": redact_message(tail),
}
try:
self._remove_helper(helper_id)
except RuntimeError:
pass
return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)}
def run(self) -> dict[str, Any]:
revision = self._validated_main_revision()
current = self.broker.controller_health()
artifact = current.get("artifact") if isinstance(current, dict) else None
if (
isinstance(artifact, dict)
and artifact.get("sourceRevision") == revision
and artifact.get("controllerVersion") == CONTROLLER_VERSION
):
return {
"status": "already_applied",
"revision": revision,
"controllerVersion": CONTROLLER_VERSION,
}
result = self._run_helper(revision=revision, image_id=self._controller_image_id())
if result.get("status") != "promoted":
return result
verified = self.broker.controller_health()
artifact = verified.get("artifact") if isinstance(verified, dict) else None
if (
not isinstance(artifact, dict)
or artifact.get("sourceRevision") != revision
or artifact.get("controllerVersion") != CONTROLLER_VERSION
):
return {
"status": "outcome_unknown",
"revision": revision,
"reason": "controller_promoter_postcheck_failed",
}
return {
"status": "promoted",
"revision": revision,
"controllerVersion": CONTROLLER_VERSION,
"artifactSha256": artifact.get("artifactSha256"),
}
@@ -0,0 +1,35 @@
"""Write-only import of the fixed controller enrollment bootstrap credential."""
from __future__ import annotations
import json
import sys
from sqlmodel import Session
from ..db.session import engine
from .repopulse_secret_import import (
BOOTSTRAP_SECRET_REF,
MAX_SECRET_BYTES,
import_controller_bootstrap,
)
def main() -> int:
raw = sys.stdin.buffer.read(MAX_SECRET_BYTES + 1)
if len(raw) > MAX_SECRET_BYTES:
print(json.dumps({"status": "blocked", "reason": "credential input too large"}))
return 2
try:
token = raw.decode("utf-8")
with Session(engine) as session:
outcome = import_controller_bootstrap(session, token)
except (UnicodeDecodeError, ValueError):
print(json.dumps({"status": "blocked", "reason": "credential import rejected"}))
return 2
print(json.dumps({"status": outcome, "secretRef": BOOTSTRAP_SECRET_REF}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+562
View File
@@ -0,0 +1,562 @@
"""Client for the private, typed deployment broker surface.
The client only sends a canonical DeploymentPlanV1 to a configured controller.
It neither exposes a command field nor calls a Docker socket/Portainer exec
endpoint. The controller independently validates repository, revision and
runtime topology before it can mutate production.
"""
from __future__ import annotations
import hashlib
import hmac
import ipaddress
import json
import os
import re
import stat
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from ..schemas.deployment import DeploymentPlanV1
_REQUEST_ID = re.compile(r"^[0-9a-f]{64}$")
_MAX_DELIVERY_STATUS_BYTES = 512 * 1024
_MAX_RUNNER_FLEET_STATUS_BYTES = 512 * 1024
_MAX_CONTROLLER_HEALTH_BYTES = 64 * 1024
_WORKMESH_REVISION = re.compile(r"^[0-9a-f]{40}$")
_WORKMESH_PREFLIGHT_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_WORKMESH_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
class DeploymentBrokerClient:
"""Minimal HMAC client for the controller's versioned deployment API."""
def __init__(
self,
*,
base_url: str | None = None,
verify_tls: bool | None = None,
ca_file: str | None = None,
):
configured_url = base_url or settings.apis.get("DEPLOYMENT_BROKER_URL")
self.base_url = str(configured_url or "").strip().rstrip("/")
self.verify_tls = (
bool(settings.apis.get("DEPLOYMENT_BROKER_VERIFY_TLS"))
if verify_tls is None
else verify_tls
)
configured_ca_file = (
settings.apis.get("DEPLOYMENT_BROKER_CA_FILE")
if ca_file is None
else ca_file
)
self.ca_file = str(configured_ca_file or "").strip() or None
self.timeout_seconds = max(
2.0,
min(30.0, float(settings.apis.get("DEPLOYMENT_BROKER_TIMEOUT_SECONDS") or 10)),
)
def configuration_error(self) -> str | None:
if not self.base_url:
return "deployment_broker_url_missing"
parsed = urlparse(self.base_url)
loopback_http = False
if parsed.scheme == "http":
hostname = parsed.hostname or ""
try:
loopback_http = ipaddress.ip_address(hostname).is_loopback
except ValueError:
loopback_http = hostname.lower() == "localhost"
if (
not (parsed.scheme == "https" or loopback_http)
or not parsed.netloc
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
return "deployment_broker_url_invalid"
if parsed.scheme == "https":
if not self.verify_tls:
return "deployment_broker_tls_verification_required"
if self.ca_file and not self._trusted_ca_file_is_safe(self.ca_file):
return "deployment_broker_ca_file_invalid"
return None
@staticmethod
def _trusted_ca_file_is_safe(value: str) -> bool:
"""Require an AppOps-owned readable CA bundle that others cannot write."""
path = Path(value)
if not path.is_absolute():
return False
try:
info = path.lstat()
except OSError:
return False
if (
path.is_symlink()
or not stat.S_ISREG(info.st_mode)
or not 1 <= info.st_size <= 1024 * 1024
):
return False
if os.name != "nt":
return (
info.st_uid == os.geteuid()
and not (stat.S_IMODE(info.st_mode) & 0o022)
and os.access(path, os.R_OK)
)
return True
def is_configured(self) -> bool:
return self.configuration_error() is None
def _verify_option(self) -> bool | str:
parsed = urlparse(self.base_url)
if parsed.scheme == "https" and self.ca_file:
return self.ca_file
return self.verify_tls
@staticmethod
def _canonical_payload(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
@staticmethod
def _signature(
secret: str,
method: str,
path: str,
timestamp: str,
nonce: str,
body: bytes,
) -> str:
"""Bind the HMAC to one versioned HTTP request, not merely its body."""
body_digest = hashlib.sha256(body).hexdigest()
signed = (
f"itworx-controller-request/v2\n{method.upper()}\n{path}\n"
f"{timestamp}\n{nonce}\n{body_digest}"
).encode("ascii")
return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
def _request(
self,
method: str,
path: str,
*,
secret: str,
payload: dict[str, Any] | None = None,
error_response_fields: frozenset[str] = frozenset(),
) -> dict[str, Any]:
if not self.is_configured():
return {"status": "missing_config", "reason": self.configuration_error()}
if not secret or len(secret) < 32:
return {"status": "missing_secret", "reason": "deployment_broker_secret_missing_or_weak"}
body = self._canonical_payload(payload) if payload is not None else b""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
headers = {
"Accept": "application/json",
"X-ITWorx-Deployment-Timestamp": timestamp,
"X-ITWorx-Deployment-Nonce": nonce,
"X-ITWorx-Deployment-Signature": self._signature(
secret, method, path, timestamp, nonce, body
),
}
if payload is not None:
headers["Content-Type"] = "application/json"
try:
with httpx.Client(
timeout=self.timeout_seconds,
verify=self._verify_option(),
) as client:
response = client.request(method, f"{self.base_url}{path}", content=body, headers=headers)
except httpx.RequestError as exc:
return {"status": "unreachable", "reason": redact_message(str(exc))}
if response.status_code in {401, 403}:
return {"status": "unauthorized", "reason": "deployment_broker_authentication_failed"}
try:
decoded = response.json()
except ValueError:
decoded = {}
if not isinstance(decoded, dict):
decoded = {}
if response.status_code >= 400:
rejected = {
"status": str(decoded.get("status") or "rejected"),
"reason": redact_message(str(decoded.get("reason") or f"deployment_broker_http_{response.status_code}")),
}
for field in error_response_fields:
value = decoded.get(field)
if isinstance(value, str) and len(value) <= 256:
rejected[field] = value
return rejected
return {"status": str(decoded.get("status") or "ok"), **decoded}
def controller_health(self) -> dict[str, Any]:
"""Read the bounded root-controller health and release identity."""
if not self.is_configured():
return {"status": "missing_config", "reason": self.configuration_error()}
try:
with httpx.Client(
timeout=self.timeout_seconds,
verify=self._verify_option(),
follow_redirects=False,
) as client, client.stream(
"GET",
f"{self.base_url}/health",
headers={"Accept": "application/json"},
) as response:
if response.status_code != 200:
return {
"status": "unavailable",
"reason": f"controller_health_http_{response.status_code}",
}
body = bytearray()
for chunk in response.iter_bytes():
body.extend(chunk)
if len(body) > _MAX_CONTROLLER_HEALTH_BYTES:
return {
"status": "unavailable",
"reason": "controller_health_response_too_large",
}
except httpx.RequestError as exc:
return {"status": "unreachable", "reason": redact_message(str(exc))}
try:
decoded = json.loads(bytes(body).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {"status": "unavailable", "reason": "controller_health_invalid_response"}
return (
decoded
if isinstance(decoded, dict)
else {"status": "unavailable", "reason": "controller_health_invalid_response"}
)
def preflight(self, plan: DeploymentPlanV1, *, secret: str) -> dict[str, Any]:
return self._request(
"POST",
"/v1/appops/deployments/preflight",
secret=secret,
payload={"plan": plan.model_dump(mode="json")},
)
def appops_self_upgrade_preflight(
self, revision: str, reason: str, expected_current_revision: str | None,
) -> dict[str, Any]:
"""Ask the private controller to validate one fixed protected-main target."""
return self._request(
"POST", "/v1/appops/self-upgrade/preflight",
secret=settings.mcp_token or "",
payload={"repository": "jens/unraid-appops-gateway", "revision": revision,
"reason": reason, "expectedCurrentRevision": expected_current_revision},
)
def appops_self_upgrade_apply(
self, revision: str, reason: str, expected_current_revision: str | None,
preflight_sha256: str, idempotency_key: str,
) -> dict[str, Any]:
return self._request(
"POST", "/v1/appops/self-upgrade/apply",
secret=settings.mcp_token or "",
payload={"repository": "jens/unraid-appops-gateway", "revision": revision,
"reason": reason, "expectedCurrentRevision": expected_current_revision,
"preflightSha256": preflight_sha256, "idempotencyKey": idempotency_key},
)
def appops_self_upgrade_status(self, operation_id: str) -> dict[str, Any]:
if not re.fullmatch(r"[0-9a-f]{64}", operation_id):
return {"status": "invalid_operation_id"}
return self._request(
"GET", f"/v1/appops/self-upgrade/status/{operation_id}",
secret=settings.mcp_token or "",
)
def apply(self, plan: DeploymentPlanV1, *, secret: str, idempotency_key: str) -> dict[str, Any]:
return self._request(
"POST",
"/v1/appops/deployments/apply",
secret=secret,
payload={"plan": plan.model_dump(mode="json"), "idempotencyKey": idempotency_key},
)
def rollback(self, plan: DeploymentPlanV1, *, secret: str, idempotency_key: str) -> dict[str, Any]:
return self._request(
"POST",
"/v1/appops/deployments/rollback",
secret=secret,
payload={"plan": plan.model_dump(mode="json"), "idempotencyKey": idempotency_key},
)
def enrollment_preflight(self, enrollment: dict[str, Any], *, secret: str) -> dict[str, Any]:
"""Validate a bounded target enrollment with the controller, read-only."""
return self._request(
"POST",
"/v1/appops/enrollments/preflight",
secret=secret,
payload={"enrollment": enrollment},
)
def enrollment_capabilities(self, *, secret: str) -> dict[str, Any]:
"""Read the bootstrap-protected enrollment controller capability."""
return self._request(
"POST",
"/v1/appops/enrollments/capabilities",
secret=secret,
payload={},
)
def repopulse_adoption_capabilities(self, *, secret: str) -> dict[str, Any]:
"""Read fixed RepoPulse adoption readiness through bootstrap HMAC v2."""
return self._request(
"POST",
"/v1/appops/repopulse/adoption/capabilities",
secret=secret,
payload={},
)
def repopulse_adoption_reserve(
self,
*,
secret: str,
intent_id: str,
idempotency_sha256: str,
preflight_sha256: str,
) -> dict[str, Any]:
"""Reserve the fixed controller intent through bootstrap HMAC v2."""
return self._request(
"POST",
"/v1/appops/repopulse/adoption/reserve",
secret=secret,
payload={
"intentId": intent_id,
"idempotencySha256": idempotency_sha256,
"preflightSha256": preflight_sha256,
},
)
def repopulse_adoption_status(self, *, secret: str) -> dict[str, Any]:
"""Read sanitized fixed adoption state through bootstrap HMAC v2."""
return self._request(
"GET",
"/v1/appops/repopulse/adoption/status",
secret=secret,
)
def repopulse_adoption_execute(
self,
*,
secret: str,
intent_id: str,
idempotency_sha256: str,
preflight_sha256: str,
preflight_projection: dict[str, Any],
target_credential: str,
) -> dict[str, Any]:
"""Request the one fixed migration; the controller owns every effect."""
if urlparse(self.base_url).scheme != "https" or self.configuration_error() is not None:
return {
"status": "missing_config",
"reason": "repopulse_adoption_private_tls_required",
}
if (
not isinstance(target_credential, str)
or not 32 <= len(target_credential) <= 256
or not target_credential.isascii()
or not target_credential.isprintable()
):
return {"status": "invalid", "reason": "repopulse_target_credential_invalid"}
return self._request(
"POST",
"/v1/appops/repopulse/adoption/execute",
secret=secret,
payload={
"intentId": intent_id,
"idempotencySha256": idempotency_sha256,
"preflightSha256": preflight_sha256,
"preflightProjection": preflight_projection,
"targetCredential": target_credential,
},
)
def enroll_target(
self,
enrollment: dict[str, Any],
*,
secret: str,
idempotency_key: str,
) -> dict[str, Any]:
"""Create exactly one controller-owned target through bootstrap HMAC."""
return self._request(
"POST",
"/v1/appops/enrollments",
secret=secret,
payload={"enrollment": enrollment, "idempotencyKey": idempotency_key},
)
def configure_enrollment_runtime(
self,
runtime: dict[str, Any],
*,
secret: str,
) -> dict[str, Any]:
"""Write profile-owned runtime values using that target's own HMAC."""
return self._request(
"POST",
"/v1/appops/enrollments/runtime",
secret=secret,
payload={"runtime": runtime},
)
def unenroll_target(
self,
target: dict[str, Any],
*,
secret: str,
) -> dict[str, Any]:
"""Compensate an untouched target through the bootstrap boundary only."""
return self._request(
"POST",
"/v1/appops/enrollments/rollback",
secret=secret,
payload={"target": target},
)
def status(self, request_id: str, *, secret: str) -> dict[str, Any]:
if not _REQUEST_ID.fullmatch(request_id):
return {"status": "invalid", "reason": "invalid_broker_request_id"}
return self._request(
"GET",
f"/v1/appops/deployments/{request_id}",
secret=secret,
)
def workmesh_promotion_preflight(self, revision: str, *, secret: str) -> dict[str, Any]:
"""Preflight only the fixed WorkMesh product target at one full SHA."""
if not _WORKMESH_REVISION.fullmatch(revision):
return {"status": "invalid", "reason": "invalid_workmesh_revision"}
return self._request(
"POST",
"/v1/appops/workmesh/promotions/preflight",
secret=secret,
payload={"revision": revision},
)
def workmesh_promotion_apply(
self,
revision: str,
*,
secret: str,
preflight_sha256: str,
idempotency_key: str,
) -> dict[str, Any]:
"""Queue a promotion only when its approved preflight digest still matches."""
if not _WORKMESH_REVISION.fullmatch(revision):
return {"status": "invalid", "reason": "invalid_workmesh_revision"}
if not _WORKMESH_PREFLIGHT_SHA256.fullmatch(preflight_sha256):
return {"status": "invalid", "reason": "invalid_workmesh_preflight_digest"}
if not _WORKMESH_IDEMPOTENCY.fullmatch(idempotency_key):
return {"status": "invalid", "reason": "invalid_workmesh_idempotency_key"}
return self._request(
"POST",
"/v1/appops/workmesh/promotions/apply",
secret=secret,
payload={
"revision": revision,
"preflightSha256": preflight_sha256,
"idempotencyKey": idempotency_key,
},
error_response_fields=frozenset({"request", "repository", "revision", "operation"}),
)
def workmesh_promotion_status(self, request_id: str, *, secret: str) -> dict[str, Any]:
"""Read one HMAC-authenticated fixed-target promotion receipt projection."""
return self.status(request_id, secret=secret)
def delivery_status(self) -> dict[str, Any]:
"""Read bounded, secret-free runnerless delivery health from controller.
This endpoint deliberately sends no deployment credential: the
controller publishes only a private-network, read-only projection of
its durable webhook inbox. All mutations keep using the HMAC-bound
typed request surface above.
"""
if not self.is_configured():
return {"status": "missing_config", "reason": self.configuration_error()}
try:
with httpx.Client(
timeout=self.timeout_seconds,
verify=self._verify_option(),
follow_redirects=False,
) as client, client.stream(
"GET",
f"{self.base_url}/v1/gitea/deliveries",
headers={"Accept": "application/json"},
) as response:
if response.status_code != 200:
return {"status": "unavailable", "reason": f"delivery_status_http_{response.status_code}"}
body = bytearray()
for chunk in response.iter_bytes():
body.extend(chunk)
if len(body) > _MAX_DELIVERY_STATUS_BYTES:
return {"status": "unavailable", "reason": "delivery_status_response_too_large"}
except httpx.RequestError as exc:
return {"status": "unreachable", "reason": redact_message(str(exc))}
try:
decoded = json.loads(bytes(body).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {"status": "unavailable", "reason": "delivery_status_invalid_response"}
return decoded if isinstance(decoded, dict) else {"status": "unavailable", "reason": "delivery_status_invalid_response"}
def runner_fleet_upgrade_v2(self, *, reason: str) -> dict[str, Any]:
"""Request the one fixed Tower runner-fleet v2 migration.
The root controller derives and verifies the exact protected-main
revision itself. The caller cannot select a script path, runner name,
revision or Docker target.
"""
return self._request(
"POST",
"/v1/appops/runner-fleet/upgrade-v2",
secret=settings.mcp_token or "",
payload={"reason": reason[:1000]},
)
def runner_fleet_status(self) -> dict[str, Any]:
"""Read the controller's private, bounded CI sandbox projection.
This is deliberately read-only and sends no credential. The controller
derives the payload from fixed runner identities and its private DinD
daemon; AppOps never receives a Docker socket or a cleanup selector.
"""
if not self.is_configured():
return {"status": "missing_config", "reason": self.configuration_error()}
try:
with httpx.Client(
timeout=self.timeout_seconds,
verify=self._verify_option(),
follow_redirects=False,
) as client, client.stream(
"GET",
f"{self.base_url}/v1/runner-fleet",
headers={"Accept": "application/json"},
) as response:
if response.status_code != 200:
return {"status": "unavailable", "reason": f"runner_fleet_status_http_{response.status_code}"}
body = bytearray()
for chunk in response.iter_bytes():
body.extend(chunk)
if len(body) > _MAX_RUNNER_FLEET_STATUS_BYTES:
return {"status": "unavailable", "reason": "runner_fleet_status_response_too_large"}
except httpx.RequestError as exc:
return {"status": "unreachable", "reason": redact_message(str(exc))}
try:
decoded = json.loads(bytes(body).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {"status": "unavailable", "reason": "runner_fleet_status_invalid_response"}
return decoded if isinstance(decoded, dict) else {"status": "unavailable", "reason": "runner_fleet_status_invalid_response"}
@@ -0,0 +1,613 @@
"""Deployment Control Plane V1 orchestration.
The service is intentionally a bounded state machine. It validates a typed
plan, records durable idempotent jobs, and talks only to dedicated API clients.
There is no free-form command, Compose content, Docker socket or host path
input anywhere on the public API.
"""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from typing import Any, Literal
from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.portainer import PortainerConnector
from ..connectors.unraid import UnraidConnector
from ..core.time import utc_now
from ..db.models import DeploymentJob, Policy
from ..schemas.deployment import DeploymentPlanV1
from .audit_logger import log_event
from .deployment_broker import DeploymentBrokerClient
from .policy_engine import evaluate_policy
from .secret_store import get_secret, has_secret, secret_storage_status
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
_TERMINAL = {"preflight_failed", "succeeded", "failed", "rolled_back", "rollback_failed"}
_BROKER_EVIDENCE_FIELDS = {"request", "repository", "revision", "status"}
_OIDC_EVIDENCE_FIELDS = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
_PROXY_EVIDENCE_FIELDS = {"success", "status", "hostId", "hostname"}
_AUTO_ROLLBACK_REPOSITORY = "jens/itworx_mcp_hub"
_AUTO_ROLLBACK_APPLICATION_ID = "itworx-mcp-hub"
class DeploymentControlError(ValueError):
pass
def _canonical_plan(plan: DeploymentPlanV1) -> tuple[dict[str, Any], str]:
value = plan.model_dump(mode="json")
digest = hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return value, digest
def _idempotency_digest(value: str) -> str:
if not _IDEMPOTENCY_KEY.fullmatch(value):
raise DeploymentControlError("idempotency key must contain 8 to 128 safe characters")
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _check(name: str, status: Literal["passed", "blocked", "warning", "unknown"], detail: str) -> dict[str, str]:
return {"name": name, "status": status, "detail": detail}
class DeploymentControlPlane:
"""Durable orchestrator for strict DeploymentPlanV1 requests."""
def __init__(
self,
*,
broker: DeploymentBrokerClient | None = None,
portainer: PortainerConnector | None = None,
npm: NginxProxyManagerConnector | None = None,
authentik: AuthentikConnector | None = None,
unraid: UnraidConnector | None = None,
):
self.broker = broker or DeploymentBrokerClient()
self.portainer = portainer or PortainerConnector()
self.npm = npm or NginxProxyManagerConnector()
self.authentik = authentik or AuthentikConnector()
self.unraid = unraid or UnraidConnector()
def preflight(
self,
session: Session,
plan: DeploymentPlanV1,
*,
operation: Literal["preflight", "apply", "rollback"] = "apply",
) -> dict[str, Any]:
checks: list[dict[str, str]] = []
policy_action = "deployment.rollback" if operation == "rollback" else "deployment.apply"
policy = evaluate_policy(
session,
action=policy_action,
target=plan.application_id,
source="manual",
)
exact_policy = session.exec(
select(Policy).where(
(Policy.action == policy_action) & (Policy.target == plan.application_id)
)
).first()
if policy.get("decision") == "allowed" and exact_policy is not None:
checks.append(_check("deployment_policy", "passed", "Exact-target deployment policy is present."))
else:
checks.append(_check("deployment_policy", "blocked", "Deployment requires an explicit allowed exact-target policy."))
if has_secret(session, plan.broker_secret_ref):
checks.append(_check("broker_secret_reference", "passed", "Stored controller secret is available by reference."))
else:
checks.append(_check("broker_secret_reference", "blocked", "The referenced deployment-broker secret is not configured."))
containers = self.portainer.list_containers()
if containers.get("status") != "ok":
checks.append(_check("portainer_inventory", "blocked", "Portainer inventory is unavailable for collision preflight."))
else:
names = {str(item.get("name") or "").casefold() for item in containers.get("containers", [])}
collisions = sorted(name for name in plan.topology.containers if name.casefold() in names)
# Existing expected containers are not unsafe by themselves: an exact
# controller receipt decides whether an apply is an upgrade or a
# conflicting takeover.
detail = "No managed container name is currently present." if not collisions else "Existing containers require controller ownership verification: " + ", ".join(collisions)
checks.append(_check("container_ownership", "warning" if collisions else "passed", detail))
if plan.proxy:
hosts = self.npm.proxy_hosts()
if not hosts.get("success"):
checks.append(_check("proxy_hostname", "blocked", "Nginx Proxy Manager inventory is unavailable."))
else:
matches = [
item
for item in hosts.get("items", [])
if plan.proxy.hostname in {str(value).lower() for value in item.get("domainNames", [])}
]
if not matches:
checks.append(_check("proxy_hostname", "passed", "Requested hostname is free in the NPM inventory."))
elif all(
str(item.get("forwardHost") or "") == plan.application_id
and int(item.get("forwardPort") or 0) == plan.topology.internal_port
for item in matches
):
checks.append(_check("proxy_hostname", "passed", "Existing hostname already targets the planned managed container."))
else:
checks.append(_check("proxy_hostname", "blocked", "Requested hostname is owned by a different NPM target."))
if plan.oidc:
auth_status = self.authentik.test_connection()
if auth_status.get("status") != "connected":
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable for the requested OIDC plan."))
else:
apps = self.authentik.applications()
if not apps.get("success"):
checks.append(_check("oidc_provider", "blocked", "Authentik applications cannot be inspected."))
else:
found = [item for item in apps.get("items", []) if item.get("slug") == plan.oidc.application_slug]
checks.append(_check("oidc_provider", "warning" if found else "passed", "Existing Authentik application will be ownership-checked." if found else "OIDC application can be provisioned from the bounded template."))
storage = secret_storage_status()
if not (storage.get("productionReady") or storage.get("status") == "ready"):
checks.append(_check("oidc_secret_reference", "blocked", "Encrypted secret storage is not ready for the referenced OIDC client secret."))
elif has_secret(session, plan.oidc.client_secret_ref):
checks.append(_check("oidc_secret_reference", "passed", "The write-only OIDC client secret reference is available for the bounded provider setup."))
else:
checks.append(_check("oidc_secret_reference", "blocked", "The referenced OIDC client secret is not configured in encrypted secret storage."))
shares = self.unraid.get_share_summary()
if shares.get("status") in {"ok", "connected"}:
checks.append(_check("trusted_appdata_root", "passed", f"Appdata path is derived as {plan.appdata_path}."))
else:
checks.append(_check("trusted_appdata_root", "warning", f"Appdata is derived as {plan.appdata_path}; Unraid share inventory is currently unavailable."))
broker_secret = get_secret(session, plan.broker_secret_ref, touch=False)
if broker_secret:
result = self.broker.preflight(plan, secret=broker_secret)
status = str(result.get("status") or "unknown")
if status in {"ok", "preflight_ok", "already_deployed"}:
checks.append(_check("deployment_controller", "passed", "Controller accepted immutable repository and topology preflight."))
elif status in {"missing_config", "missing_secret"}:
checks.append(_check("deployment_controller", "blocked", "Deployment controller is not configured for the typed API."))
else:
checks.append(_check("deployment_controller", "blocked", "Controller rejected typed deployment preflight."))
else:
checks.append(_check("deployment_controller", "blocked", "Controller authentication secret is unavailable."))
blockers = [item["name"] for item in checks if item["status"] == "blocked"]
warnings = [item["name"] for item in checks if item["status"] == "warning"]
return {
"status": "blocked" if blockers else "passed",
"blockers": blockers,
"warnings": warnings,
"checks": checks,
"appdataPath": plan.appdata_path,
"publicUrl": plan.public_url,
}
def create_job(
self,
session: Session,
*,
plan: DeploymentPlanV1,
idempotency_key: str,
operation: Literal["preflight", "apply", "rollback"],
) -> DeploymentJob:
plan_json, plan_digest = _canonical_plan(plan)
idempotency_digest = _idempotency_digest(f"{operation}:{idempotency_key}")
existing = session.exec(
select(DeploymentJob).where(DeploymentJob.idempotency_sha256 == idempotency_digest)
).first()
if existing:
if existing.plan_sha256 != plan_digest:
raise DeploymentControlError("idempotency key was already used for a different deployment plan")
return existing
job = DeploymentJob(
id=f"deploy-{uuid.uuid4().hex[:16]}",
application_id=plan.application_id,
repository=plan.repository,
revision=plan.revision,
plan_sha256=plan_digest,
idempotency_sha256=idempotency_digest,
status="queued",
stage="accepted",
plan_json=plan_json,
evidence_json={"operation": operation},
)
session.add(job)
session.commit()
session.refresh(job)
return job
@staticmethod
def serialize_job(job: DeploymentJob, *, replay: bool = False) -> dict[str, Any]:
return {
"id": job.id,
"applicationId": job.application_id,
"repository": job.repository,
"revision": job.revision,
"status": job.status,
"stage": job.stage,
"preflight": job.preflight_json,
"evidence": job.evidence_json,
"result": job.result_summary,
"brokerRequestId": job.broker_request_id,
"createdAt": job.created_at.isoformat(),
"updatedAt": job.updated_at.isoformat(),
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
"idempotentReplay": replay,
}
def _complete_external_integrations(
self,
session: Session,
plan: DeploymentPlanV1,
) -> tuple[bool, dict[str, Any], str]:
"""Complete only plan-derived edge/OIDC integration after live proof.
Application environment secret delivery remains an explicit controller
enrollment responsibility. This code resolves the same write-only
reference only inside the backend to create/verify the bounded OIDC
provider, and never writes it to an API result or audit record.
"""
evidence: dict[str, Any] = {}
if plan.oidc:
client_secret = get_secret(session, plan.oidc.client_secret_ref, touch=False)
if not client_secret:
return False, evidence, "Referenced OIDC client secret is unavailable after deployment."
oidc_result = self.authentik.ensure_deployment_oidc_application(
application_slug=plan.oidc.application_slug,
launch_url=plan.public_url or "",
redirect_uri=f"{plan.public_url}{plan.oidc.redirect_path}",
client_secret=client_secret,
administrator_group=plan.oidc.roles.administrator_group,
support_agent_group=plan.oidc.roles.support_agent_group,
read_only_group=plan.oidc.roles.read_only_group,
)
evidence["oidc"] = {
key: oidc_result[key] for key in _OIDC_EVIDENCE_FIELDS if key in oidc_result
}
if not oidc_result.get("success") or oidc_result.get("status") not in {"created", "already_managed"}:
return False, evidence, "Typed OIDC provider/application setup was not verified."
if plan.proxy:
proxy_result = self.npm.ensure_managed_proxy_host(
hostname=plan.proxy.hostname,
forward_host=plan.application_id,
forward_port=plan.topology.internal_port,
certificate_mode=plan.proxy.certificate_mode,
force_ssl=plan.proxy.force_ssl,
http2=plan.proxy.http2,
upload_limit_mb=plan.proxy.upload_limit_mb,
read_timeout_seconds=plan.proxy.read_timeout_seconds,
)
evidence["proxy"] = {
key: proxy_result[key] for key in _PROXY_EVIDENCE_FIELDS if key in proxy_result
}
if not proxy_result.get("success") or proxy_result.get("status") not in {"created", "already_managed"}:
return False, evidence, "Typed reverse-proxy setup was not verified."
return True, evidence, "External integration is verified."
def _complete_apply_job(
self,
session: Session,
job: DeploymentJob,
plan: DeploymentPlanV1,
) -> None:
success, integration_evidence, message = self._complete_external_integrations(session, plan)
job.evidence_json = {
**dict(job.evidence_json or {}),
"externalIntegration": integration_evidence,
}
if success:
job.status = "succeeded"
job.stage = "controller_and_edge_verified"
job.result_summary = "Deployment controller and required edge integration returned verified evidence."
job.completed_at = utc_now()
return
job.evidence_json = {
**dict(job.evidence_json or {}),
"postApplyVerification": {"status": "failed", "detail": message},
}
if (
plan.repository.casefold() != _AUTO_ROLLBACK_REPOSITORY
or plan.application_id != _AUTO_ROLLBACK_APPLICATION_ID
):
job.status = "failed"
job.stage = "external_integration_failed"
job.result_summary = message
job.completed_at = utc_now()
return
self._attempt_auto_rollback(session, job, plan, reason=message)
def _attempt_auto_rollback(
self,
session: Session,
job: DeploymentJob,
plan: DeploymentPlanV1,
*,
reason: str,
) -> None:
"""Restore only the controller-retained prior receipt after verification failure."""
policy = evaluate_policy(
session,
action="deployment.rollback",
target=plan.application_id,
source="manual",
)
exact_policies = session.exec(
select(Policy).where(
(Policy.action == "deployment.rollback")
& (Policy.target == plan.application_id)
)
).all()
exact_policy = exact_policies[0] if len(exact_policies) == 1 else None
policy_is_exact_and_strict = (
exact_policy is not None
and exact_policy.manual == "allowed"
and exact_policy.voice == "blocked"
and exact_policy.risk == "high"
and policy.get("policy_id") == exact_policy.id
)
if policy.get("decision") != "allowed" or not policy_is_exact_and_strict:
job.evidence_json = {
**dict(job.evidence_json or {}),
"autoRollback": {"status": "blocked", "reason": "exact-target-policy"},
}
job.status = "failed"
job.stage = "external_integration_failed"
job.result_summary = reason
job.completed_at = utc_now()
return
secret = get_secret(session, plan.broker_secret_ref)
if not secret:
job.evidence_json = {
**dict(job.evidence_json or {}),
"autoRollback": {"status": "blocked", "reason": "controller-secret"},
}
job.status = "failed"
job.stage = "external_integration_failed"
job.result_summary = reason
job.completed_at = utc_now()
return
rollback_key = f"auto-rollback:{job.id}"
rollback = self.broker.rollback(plan, secret=secret, idempotency_key=rollback_key)
rollback_status = str(rollback.get("status") or "failed")
rollback_request = str(rollback.get("request") or "") or None
rollback_evidence = {
key: rollback[key]
for key in _BROKER_EVIDENCE_FIELDS
if key in rollback
}
rollback_evidence["status"] = rollback_status
job.evidence_json = {
**dict(job.evidence_json or {}),
"autoRollback": rollback_evidence,
}
if rollback_request:
job.broker_request_id = rollback_request
if rollback_status == "rolled_back":
job.status = "rolled_back"
job.stage = "auto_rollback_verified"
job.result_summary = (
"Post-apply verification failed; the controller restored the prior verified deployment."
)
job.completed_at = utc_now()
elif rollback_status in {"accepted", "already_queued", "running"}:
if not rollback_request:
job.status = "failed"
job.stage = "auto_rollback_failed"
job.evidence_json = {
**dict(job.evidence_json or {}),
"autoRollback": {
**rollback_evidence,
"reason": "missing-controller-request",
},
}
job.result_summary = (
"Post-apply verification failed and automatic rollback "
"returned no pollable controller request."
)
job.completed_at = utc_now()
return
job.status = "rolling_back"
job.stage = "auto_rollback_queued"
job.result_summary = (
"Post-apply verification failed; automatic rollback is in progress."
)
job.completed_at = None
else:
job.status = "failed"
job.stage = "auto_rollback_failed"
job.result_summary = (
"Post-apply verification failed and the controller did not verify rollback."
)
job.completed_at = utc_now()
def execute(
self,
session: Session,
*,
plan: DeploymentPlanV1,
idempotency_key: str,
operation: Literal["preflight", "apply", "rollback"],
) -> dict[str, Any]:
job = self.create_job(session, plan=plan, idempotency_key=idempotency_key, operation=operation)
if job.status != "queued":
return self.serialize_job(job, replay=True)
preflight = self.preflight(session, plan, operation=operation)
job.preflight_json = preflight
job.updated_at = utc_now()
if preflight["status"] != "passed":
job.status = "preflight_failed"
job.stage = "preflight"
job.result_summary = "Deployment preflight was blocked."
job.completed_at = utc_now()
session.add(job)
session.commit()
log_event(
session,
source="deployment-control-plane",
identity="operator",
action_id=f"deployment.{operation}",
target=plan.application_id,
target_type="deployment",
risk="high",
policy_decision="blocked",
reason="Typed deployment preflight failed closed.",
result="blocked",
execution_plan={"planSha256": job.plan_sha256, "operation": operation},
post_checks={"preflight": preflight},
)
return self.serialize_job(job)
if operation == "preflight":
job.status = "preflight_succeeded"
job.stage = "preflight"
job.result_summary = "Typed deployment preflight passed."
job.completed_at = utc_now()
session.add(job)
session.commit()
return self.serialize_job(job)
secret = get_secret(session, plan.broker_secret_ref)
if not secret:
raise DeploymentControlError("deployment broker secret was unavailable after preflight")
broker_result = (
self.broker.apply(plan, secret=secret, idempotency_key=idempotency_key)
if operation == "apply"
else self.broker.rollback(plan, secret=secret, idempotency_key=idempotency_key)
)
broker_status = str(broker_result.get("status") or "failed")
job.evidence_json = {
**dict(job.evidence_json or {}),
"brokerStatus": broker_status,
"brokerEvidence": {
key: broker_result[key] for key in _BROKER_EVIDENCE_FIELDS if key in broker_result
},
}
job.broker_request_id = str(broker_result.get("request") or "") or None
job.updated_at = utc_now()
if broker_status in {"accepted", "already_queued", "running"}:
job.status = "applying" if operation == "apply" else "rolling_back"
job.stage = "controller_queued"
job.result_summary = "Deployment controller accepted the typed request."
elif operation == "apply" and broker_status in {"deployed", "already_deployed"}:
self._complete_apply_job(session, job, plan)
elif operation == "rollback" and broker_status == "rolled_back":
job.status = "rolled_back"
job.stage = "rollback_verified"
job.result_summary = "Deployment controller returned verified rollback evidence."
job.completed_at = utc_now()
else:
job.status = "failed" if operation == "apply" else "rollback_failed"
job.stage = "controller_rejected"
job.result_summary = "Deployment controller rejected the typed request."
job.completed_at = utc_now()
session.add(job)
session.commit()
log_event(
session,
source="deployment-control-plane",
identity="operator",
action_id=f"deployment.{operation}",
target=plan.application_id,
target_type="deployment",
risk="high",
policy_decision="allowed" if job.status not in {"failed", "rollback_failed"} else "blocked",
reason="Typed deployment request sent to the private controller.",
result="success" if job.status not in {"failed", "rollback_failed"} else "failed",
execution_plan={"planSha256": job.plan_sha256, "operation": operation},
after_state={"status": job.status, "stage": job.stage, "brokerRequestId": job.broker_request_id},
redacted_metadata={"brokerStatus": broker_status},
)
return self.serialize_job(job)
def refresh(self, session: Session, job_id: str) -> dict[str, Any]:
job = session.get(DeploymentJob, job_id)
if not job:
raise DeploymentControlError("deployment job was not found")
if job.status in _TERMINAL or not job.broker_request_id:
return self.serialize_job(job)
plan = DeploymentPlanV1.model_validate(job.plan_json)
secret = get_secret(session, plan.broker_secret_ref)
if not secret:
job.status = "failed"
job.stage = "controller_auth_lost"
job.result_summary = "Deployment controller secret is no longer available."
job.completed_at = utc_now()
else:
result = self.broker.status(job.broker_request_id, secret=secret)
status = str(result.get("status") or "unknown")
job.evidence_json = {
**dict(job.evidence_json or {}),
"brokerStatus": status,
"brokerEvidence": {
key: result[key] for key in _BROKER_EVIDENCE_FIELDS if key in result
},
}
if status in {"deployed", "already_deployed"}:
auto_rollback = dict(job.evidence_json or {}).get("autoRollback")
if job.status == "rolling_back":
job.status = "failed" if isinstance(auto_rollback, dict) else "rollback_failed"
job.stage = (
"auto_rollback_failed"
if isinstance(auto_rollback, dict)
else "controller_failed"
)
job.result_summary = (
"Rollback request returned a deployed state and was not accepted as rollback proof."
)
job.completed_at = utc_now()
else:
self._complete_apply_job(session, job, plan)
elif status == "rolled_back":
job.status = "rolled_back"
auto_rollback = dict(job.evidence_json or {}).get("autoRollback")
if isinstance(auto_rollback, dict):
job.evidence_json = {
**dict(job.evidence_json or {}),
"autoRollback": {**auto_rollback, "status": "rolled_back"},
}
job.stage = "auto_rollback_verified"
job.result_summary = (
"Post-apply verification failed; the controller verified automatic rollback."
)
else:
job.stage = "rollback_verified"
job.result_summary = "Deployment controller verified rollback evidence."
job.completed_at = utc_now()
elif status in {"failed", "non_forward", "rejected"}:
auto_rollback = dict(job.evidence_json or {}).get("autoRollback")
if isinstance(auto_rollback, dict) and job.status == "rolling_back":
job.status = "failed"
job.stage = "auto_rollback_failed"
job.evidence_json = {
**dict(job.evidence_json or {}),
"autoRollback": {**auto_rollback, "status": status},
}
job.result_summary = (
"Post-apply verification failed and automatic rollback was not verified."
)
else:
job.status = "failed" if job.status == "applying" else "rollback_failed"
job.stage = "controller_failed"
job.result_summary = "Deployment controller reported a typed deployment failure."
job.completed_at = utc_now()
job.updated_at = utc_now()
session.add(job)
session.commit()
return self.serialize_job(job)
def list_jobs(self, session: Session, *, limit: int = 100) -> list[dict[str, Any]]:
rows = session.exec(
select(DeploymentJob).order_by(DeploymentJob.created_at.desc()).limit(max(1, min(limit, 250)))
).all()
return [self.serialize_job(row) for row in rows]
@@ -0,0 +1,537 @@
from __future__ import annotations
import json
import re
import time
from typing import Any
from urllib.parse import quote
from ..connectors.portainer import PortainerConnector
from ..core.redaction import redact_message
_REPOSITORY = re.compile(r"^jens/[a-z0-9_.-]+$")
_REVISION = re.compile(r"^[0-9a-f]{40}$")
_BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$")
_SLUG = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$")
_COMPOSE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
_CONTAINER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
_CONTROLLER = "gitea-deploy-control"
_MAX_CONTAINERS = 32
# Fixed application-owned mutation. Callers never provide a command or host
# path. Repository-derived topology is validated before it reaches this script,
# then re-bound to the exact immutable Git revision inside the controller.
_REGISTER_SCRIPT = r'''
import fcntl
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import time
path = Path("/opt/gitea-deploy/projects.json")
host_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
repository, revision, branch, raw_project = sys.argv[1:5]
project = json.loads(raw_project)
if path.is_symlink() or not path.is_file():
raise SystemExit(20)
info = path.stat()
if not stat.S_ISREG(info.st_mode):
raise SystemExit(21)
try:
if host_path.is_symlink() or not host_path.is_file() or not os.path.samefile(path, host_path):
raise SystemExit(36)
except OSError:
raise SystemExit(36)
mutation_path = host_path
if project.get("branch") != branch:
raise SystemExit(37)
compose_files = project.get("compose_files")
if not isinstance(compose_files, list) or len(compose_files) != 1:
raise SystemExit(38)
compose_file = compose_files[0]
if not isinstance(compose_file, str) or not compose_file:
raise SystemExit(38)
owner, name = repository.split("/", 1)
bare_repo = Path("/gitea-repositories") / owner / f"{name}.git"
if not bare_repo.is_dir():
raise SystemExit(24)
def git_run(*args):
return subprocess.run(
["git", f"--git-dir={bare_repo}", *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=10,
check=False,
)
try:
commit_result = git_run("rev-parse", f"{revision}^{{commit}}")
except Exception:
raise SystemExit(25)
commit = commit_result.stdout.strip()
if commit_result.returncode != 0 or commit != revision:
raise SystemExit(25)
try:
branch_result = git_run("rev-parse", f"refs/heads/{branch}")
except Exception:
raise SystemExit(26)
branch_head = branch_result.stdout.strip()
if branch_result.returncode != 0 or not branch_head:
raise SystemExit(26)
try:
ancestry = git_run("merge-base", "--is-ancestor", revision, branch_head)
except Exception:
raise SystemExit(27)
if ancestry.returncode != 0:
raise SystemExit(27)
try:
compose = git_run("cat-file", "-e", f"{revision}:{compose_file}")
except Exception:
raise SystemExit(28)
if compose.returncode != 0:
raise SystemExit(28)
try:
lock_handle = lock_path.open("a+", encoding="utf-8")
except Exception:
raise SystemExit(29)
with lock_handle as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX)
except Exception:
raise SystemExit(30)
try:
projects = json.loads(mutation_path.read_text(encoding="utf-8"))
except Exception:
raise SystemExit(31)
if not isinstance(projects, dict):
raise SystemExit(22)
current = projects.get(repository)
changed = current is None
if current is not None:
if not isinstance(current, dict):
raise SystemExit(23)
allowed_extra_keys = {
"env_source",
"environment",
"gitea_repository_id",
"gitea_webhook_secret_file",
"validation",
}
extra_keys = set(current) - set(project)
if not extra_keys.issubset(allowed_extra_keys):
raise SystemExit(23)
canonical_current = {key: current.get(key) for key in project}
if canonical_current != project:
raise SystemExit(23)
env_source = current.get("env_source")
if env_source is not None:
if (
not isinstance(env_source, str)
or not env_source.startswith("/srv/example/appdata/")
or len(env_source) > 512
or ".." in Path(env_source).parts
):
raise SystemExit(23)
environment = current.get("environment")
if environment is not None:
if not isinstance(environment, dict) or len(environment) > 64:
raise SystemExit(23)
for key, value in environment.items():
if (
not isinstance(key, str)
or not key
or len(key) > 128
or not isinstance(value, str)
or len(value) > 4096
):
raise SystemExit(23)
runnerless_keys = {
"gitea_repository_id",
"gitea_webhook_secret_file",
"validation",
}
present_runnerless_keys = runnerless_keys.intersection(current)
if present_runnerless_keys and present_runnerless_keys != runnerless_keys:
raise SystemExit(23)
repository_id = current.get("gitea_repository_id")
if repository_id is not None and (
not isinstance(repository_id, int)
or isinstance(repository_id, bool)
or repository_id <= 0
):
raise SystemExit(23)
webhook_secret_file = current.get("gitea_webhook_secret_file")
if webhook_secret_file is not None:
expected_secret_file = repository.replace("/", "__")
if webhook_secret_file != expected_secret_file:
raise SystemExit(23)
validation = current.get("validation")
if validation is not None:
expected_validation = {
"validation_gate_version": 1,
"required": True,
"contexts": ["Managed validation / full (push)"],
"timeout_seconds": 3600 if repository == "jens/mediarr" else 1800,
"poll_interval_seconds": 5,
"max_poll_interval_seconds": 30,
}
if validation != expected_validation:
raise SystemExit(23)
if changed:
backup = mutation_path.with_name(
f"projects.json.appops-backup-{int(time.time())}-{os.getpid()}"
)
try:
shutil.copy2(mutation_path, backup)
except Exception:
raise SystemExit(32)
updated = dict(projects)
updated[repository] = project
temporary = mutation_path.with_name(f".{mutation_path.name}.{os.getpid()}.tmp")
payload = json.dumps(updated, indent=2, sort_keys=True) + "\n"
try:
with temporary.open("w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, stat.S_IMODE(info.st_mode))
except Exception:
try:
temporary.unlink(missing_ok=True)
except Exception:
pass
raise SystemExit(33)
try:
os.replace(temporary, mutation_path)
except Exception:
try:
temporary.unlink(missing_ok=True)
except Exception:
pass
raise SystemExit(34)
directory_fd = None
try:
directory_fd = os.open(str(mutation_path.parent), os.O_RDONLY)
os.fsync(directory_fd)
except Exception:
raise SystemExit(35)
finally:
if directory_fd is not None:
os.close(directory_fd)
print("registered" if changed else "already-registered", flush=True)
'''
def _safe_compose_file(value: str) -> str:
path = str(value or "").strip().replace("\\", "/")
if not path or path.startswith("/") or len(path) > 240:
raise ValueError("Compose file must be a bounded repository-relative path")
parts = path.split("/")
if any(part in {"", ".", ".."} for part in parts):
raise ValueError("Compose file path cannot contain empty or traversal segments")
if not path.lower().endswith((".yml", ".yaml")):
raise ValueError("Compose file must use a .yml or .yaml suffix")
return path
class DeploymentTargetRegistry:
"""Narrow bridge for exact-SHA Compose deployment target onboarding."""
def __init__(self, portainer: PortainerConnector | None = None):
self.portainer = portainer or PortainerConnector()
@staticmethod
def _slug(repository: str) -> str:
return repository.split("/", 1)[1]
@staticmethod
def default_compose_project(repository: str) -> str:
raw = DeploymentTargetRegistry._slug(repository).lower()
value = re.sub(r"[^a-z0-9_-]+", "-", raw).strip("-_")
if not value:
raise ValueError("Repository name cannot be converted to a Compose project")
return value[:63]
@staticmethod
def _project(
repository: str,
*,
branch: str,
compose_file: str,
compose_project: str,
containers: list[str],
health_timeout: int,
verify_oci_revision: bool,
) -> dict[str, object]:
slug = DeploymentTargetRegistry._slug(repository)
return {
"branch": branch,
"slug": slug,
"source_dir": f"/mnt/cache/appdata/gitea-deploy-runners/sources/{slug}",
"exact_source": True,
"compose_files": [compose_file],
"compose_project": compose_project,
"containers": containers,
"verify_oci_revision": verify_oci_revision,
"health_timeout": health_timeout,
}
@staticmethod
def validate(
repository: str,
revision: str,
*,
branch: str = "main",
compose_file: str = "docker-compose.yml",
compose_project: str | None = None,
containers: list[str] | None = None,
health_timeout: int = 180,
) -> tuple[str, str, str, str, str, list[str], int]:
normalized_repository = str(repository or "").strip().lower()
normalized_revision = str(revision or "").strip().lower()
normalized_branch = str(branch or "").strip()
if not _REPOSITORY.fullmatch(normalized_repository):
raise ValueError("Repository must be a canonical Jens owner/name identifier")
slug = DeploymentTargetRegistry._slug(normalized_repository)
if not _SLUG.fullmatch(slug):
raise ValueError("Repository name cannot be used as a bounded deployment slug")
if not _REVISION.fullmatch(normalized_revision):
raise ValueError("Revision must be a full lowercase 40-character Git SHA")
if (
not _BRANCH.fullmatch(normalized_branch)
or ".." in normalized_branch
or "//" in normalized_branch
or normalized_branch.endswith("/")
):
raise ValueError("Default branch is outside the bounded Git ref contract")
normalized_compose_file = _safe_compose_file(compose_file)
normalized_project = str(
compose_project
or DeploymentTargetRegistry.default_compose_project(normalized_repository)
).strip().lower()
if not _COMPOSE_PROJECT.fullmatch(normalized_project):
raise ValueError("Compose project is outside the bounded naming contract")
normalized_containers = [str(item or "").strip() for item in (containers or [slug])]
if not normalized_containers or len(normalized_containers) > _MAX_CONTAINERS:
raise ValueError("Deployment target must contain between 1 and 32 containers")
if any(not _CONTAINER.fullmatch(item) for item in normalized_containers):
raise ValueError("Container name is outside the bounded naming contract")
if len(set(normalized_containers)) != len(normalized_containers):
raise ValueError("Container names must be unique")
timeout = int(health_timeout)
if timeout < 30 or timeout > 900:
raise ValueError("Health timeout must be between 30 and 900 seconds")
return (
normalized_repository,
normalized_revision,
normalized_branch,
normalized_compose_file,
normalized_project,
normalized_containers,
timeout,
)
def register_compose_target(
self,
repository: str,
revision: str,
*,
branch: str = "main",
compose_file: str = "docker-compose.yml",
compose_project: str | None = None,
containers: list[str] | None = None,
health_timeout: int = 180,
verify_oci_revision: bool = False,
) -> dict[str, Any]:
try:
(
repository,
revision,
branch,
compose_file,
compose_project,
containers,
health_timeout,
) = self.validate(
repository,
revision,
branch=branch,
compose_file=compose_file,
compose_project=compose_project,
containers=containers,
health_timeout=health_timeout,
)
except (TypeError, ValueError) as exc:
return {"status": "invalid", "reason": str(exc)}
status = self.portainer.get_container_status(_CONTROLLER)
if status.get("status") != "ok":
return {
"status": "controller_unavailable",
"reason": redact_message(
str(status.get("reason") or status.get("status") or "unavailable")
),
}
controller = dict(status.get("container") or {})
if controller.get("name") != _CONTROLLER or controller.get("state") != "running":
return {
"status": "controller_unavailable",
"reason": "deployment controller is not running",
}
container_id = str(controller.get("id") or "")
config = self.portainer._config()
if not config or not container_id:
return {
"status": "connector_unavailable",
"reason": "Portainer deployment control is unavailable",
}
project = self._project(
repository,
branch=branch,
compose_file=compose_file,
compose_project=compose_project,
containers=containers,
health_timeout=health_timeout,
verify_oci_revision=bool(verify_oci_revision),
)
command = [
"python3",
"-c",
_REGISTER_SCRIPT,
repository,
revision,
branch,
json.dumps(project, separators=(",", ":"), sort_keys=True),
]
endpoint = quote(str(config.endpoint_id), safe="")
safe_container = quote(container_id, safe="")
try:
created = self.portainer._request(
"POST",
f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec",
json={
"AttachStdout": False,
"AttachStderr": False,
"Tty": False,
"Cmd": command,
},
).json()
exec_id = str(created.get("Id") or "")
if not exec_id:
return {
"status": "error",
"reason": "deployment target registration could not start",
}
safe_exec = quote(exec_id, safe="")
self.portainer._request(
"POST",
f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start",
json={"Detach": True, "Tty": False},
)
deadline = time.monotonic() + 25
exit_code: int | None = None
while time.monotonic() < deadline:
inspected = self.portainer._request(
"GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json"
).json()
if not bool(inspected.get("Running")):
raw_exit = inspected.get("ExitCode")
exit_code = int(raw_exit) if raw_exit is not None else -1
break
time.sleep(0.25)
except (RuntimeError, ValueError, TypeError) as exc:
return {"status": "error", "reason": redact_message(str(exc))}
if exit_code is None:
return {
"status": "error",
"reason": "deployment target registration timed out",
}
if exit_code == 23:
return {
"status": "conflict",
"reason": "repository already has a different deployment target",
}
preflight_reasons = {
20: "deployment projects registry is unavailable or unsafe",
21: "deployment projects registry is not a regular file",
22: "deployment projects registry does not contain an object",
24: "mounted bare repository is unavailable",
25: "exact revision cannot be resolved in the mounted bare repository",
26: "default branch cannot be resolved in the mounted bare repository",
27: "exact revision is not an ancestor of the default branch in the mounted bare repository",
28: "configured Compose file is absent at the exact revision",
29: "deployment registry lock file cannot be opened",
30: "deployment registry lock cannot be acquired",
31: "deployment projects registry cannot be read as JSON",
32: "deployment projects registry backup cannot be created",
33: "deployment projects registry replacement file cannot be durably written",
34: "deployment projects registry replacement cannot be installed atomically",
35: "deployment projects registry directory durability sync failed after replacement",
36: "deployment projects registry host alias is unavailable or does not match the controller registry",
37: "controller project branch does not match the requested default branch",
38: "controller project Compose contract is invalid",
}
if exit_code in preflight_reasons:
return {
"status": "validation_failed",
"reason": preflight_reasons[exit_code],
}
if exit_code != 0:
return {
"status": "validation_failed",
"reason": "controller rejected the bounded repository/revision preflight; projects.json was not changed",
}
return {
"status": "registered",
"repository": repository,
"revision": revision,
"branch": branch,
"slug": self._slug(repository),
"compose_file": compose_file,
"compose_project": compose_project,
"container": containers[0] if len(containers) == 1 else None,
"containers": containers,
}
def register_single_container(
self,
repository: str,
revision: str,
*,
branch: str = "main",
compose_file: str = "docker-compose.yml",
) -> dict[str, Any]:
slug = str(repository or "").strip().lower().split("/", 1)[-1]
return self.register_compose_target(
repository,
revision,
branch=branch,
compose_file=compose_file,
containers=[slug],
)
+327
View File
@@ -0,0 +1,327 @@
from __future__ import annotations
import asyncio
import logging
from sqlmodel import Session, select
from ..db import session as db_session
from ..db.models import Policy
from .audit_logger import log_event
from .gitea_project_auto_registration import GiteaProjectAutoRegistration
from .inventory_discovery import run_discovery
from .mcp_hub_deployment_reconciliation import McpHubDeploymentReconciliation
from .media_codec_dashboard_deployment_reconciliation import (
MediaCodecDashboardDeploymentReconciliation,
)
from .privatemuse_deployment_enrollment import PrivateMuseDeploymentEnrollment
logger = logging.getLogger(__name__)
_MCP_HUB_APPLICATION_ID = "itworx-mcp-hub"
_MCP_HUB_REPOSITORY = "jens/itworx_mcp_hub"
_MCP_HUB_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback")
def _reconcile_mcp_hub_exact_policies(session: Session) -> dict[str, object]:
"""Create only the exact MCP Hub deployment policies after state proof."""
existing_by_action: dict[str, Policy | None] = {}
for action in _MCP_HUB_DEPLOYMENT_ACTIONS:
matches = session.exec(
select(Policy).where(
(Policy.action == action) & (Policy.target == _MCP_HUB_APPLICATION_ID)
)
).all()
if len(matches) > 1:
return {
"status": "conflict",
"action": action,
"created": 0,
"policyIds": [],
}
existing = matches[0] if matches else None
if existing is not None and (
existing.manual != "allowed"
or existing.voice != "blocked"
or existing.risk != "high"
):
return {
"status": "conflict",
"action": action,
"created": 0,
"policyIds": [],
}
existing_by_action[action] = existing
policy_ids: list[str] = []
created = 0
for action in _MCP_HUB_DEPLOYMENT_ACTIONS:
existing = existing_by_action[action]
if existing is not None:
policy_ids.append(existing.id)
continue
suffix = action.rsplit(".", 1)[-1]
policy_id = f"mcp-hub-reconciled-{suffix}"
if session.get(Policy, policy_id) is not None:
return {
"status": "conflict",
"action": action,
"created": 0,
"policyIds": [],
}
session.add(
Policy(
id=policy_id,
action=action,
target=_MCP_HUB_APPLICATION_ID,
voice="blocked",
manual="allowed",
risk="high",
reason=(
"Exact MCP Hub deployment policy created only after "
"fail-closed runtime state reconciliation."
),
provenance_json={
"createdBy": "mcp-hub-state-reconciliation/v1",
"repository": _MCP_HUB_REPOSITORY,
"policyTemplateVersion": "exact-target/v1",
},
)
)
policy_ids.append(policy_id)
created += 1
session.commit()
return {"status": "ready", "created": created, "policyIds": policy_ids}
def _ensure_mcp_hub_exact_policies() -> dict[str, object]:
with Session(db_session.engine) as session:
return _reconcile_mcp_hub_exact_policies(session)
def _audit_privatemuse_enrollment(enrollment: dict[str, object]) -> None:
status = str(enrollment.get("status") or "error")
failure_reason = str(enrollment.get("reason") or "")
reason = "Bounded startup reconciliation for the existing PrivateMuse deployment target."
if failure_reason:
reason = f"{reason} Enrollment result: {status}; {failure_reason}"
with Session(db_session.engine) as session:
log_event(
session,
source="system",
identity="appops-startup",
action_id="deployment.target.enroll",
target="jens/privatemuse",
target_type="deployment-target",
risk="high",
policy_decision="allowed",
reason=reason,
result="success" if status == "enrolled" else "failed",
after_state={
"status": status,
"repository": enrollment.get("repository", "jens/privatemuse"),
"legacyRevision": enrollment.get("legacy_revision", ""),
"composeProject": enrollment.get("compose_project", ""),
"containers": enrollment.get("containers", []),
},
redacted_metadata={"failureReason": failure_reason} if failure_reason else None,
)
def _audit_mcp_hub_reconciliation(result: dict[str, object]) -> None:
status = str(result.get("status") or "error")
failure_reason = str(result.get("reason") or "")
reason = "Fail-closed startup reconciliation for the verified MCP Hub deployment state."
if failure_reason:
reason = f"{reason} Reconciliation result: {status}; {failure_reason}"
with Session(db_session.engine) as session:
log_event(
session,
source="system",
identity="appops-startup",
action_id="deployment.state.reconcile",
target="jens/itworx_mcp_hub",
target_type="deployment-target",
risk="high",
policy_decision="allowed",
reason=reason,
result="success" if status == "reconciled" else "failed",
after_state={
"status": status,
"repository": result.get("repository", "jens/itworx_mcp_hub"),
"composeProject": result.get("composeProject", "itworx-mcp-hub"),
"policyStatus": result.get("policyStatus", "not_checked"),
"policyIds": result.get("policyIds", []),
"policiesCreated": result.get("policiesCreated", 0),
},
redacted_metadata={"failureReason": failure_reason} if failure_reason else None,
)
def _audit_media_codec_dashboard_reconciliation(result: dict[str, object]) -> None:
status = str(result.get("status") or "error")
failure_reason = str(result.get("reason") or "")
reason = "Fail-closed startup reconciliation for the verified Media Codec Dashboard deployment state."
if failure_reason:
reason = f"{reason} Reconciliation result: {status}; {failure_reason}"
with Session(db_session.engine) as session:
log_event(
session,
source="system",
identity="appops-startup",
action_id="deployment.state.reconcile",
target="jens/media-codec-dashboard",
target_type="deployment-target",
risk="high",
policy_decision="allowed",
reason=reason,
result="success" if status == "reconciled" else "failed",
after_state={
"status": status,
"repository": result.get("repository", "jens/media-codec-dashboard"),
"liveRevision": result.get("live_revision", ""),
"previousRecordedRevision": result.get("previous_recorded_revision", ""),
"container": result.get("container", ""),
},
redacted_metadata={"failureReason": failure_reason} if failure_reason else None,
)
def _sync_gitea_projects() -> dict[str, object]:
with Session(db_session.engine) as session:
result = GiteaProjectAutoRegistration().sync(session)
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
changed = int(summary.get("changedThisSync", 0) or 0)
if changed:
log_event(
session,
source="system",
identity="appops-project-sync",
action_id="deployment.targets.reconcile",
target="jens/*",
target_type="deployment-project-catalog",
risk="medium",
policy_decision="allowed",
reason="Automatic Gitea project registration reconciled changed owned repositories.",
result="success" if result.get("status") == "ok" else "failed",
after_state={
"status": result.get("status"),
"total": summary.get("total", 0),
"registered": summary.get("registered", 0),
"knownUnconfigured": summary.get("knownUnconfigured", 0),
"conflicts": summary.get("conflicts", 0),
"errors": summary.get("errors", 0),
"changed": changed,
},
)
return result
async def _run_gitea_project_sync() -> None:
try:
result = await asyncio.to_thread(_sync_gitea_projects)
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
if result.get("status") != "ok":
logger.warning(
"Gitea project registration sync degraded: status=%s reason=%s",
result.get("status"),
result.get("reason", "unspecified"),
)
else:
logger.info(
"Gitea project registration sync complete: total=%s registered=%s changed=%s",
summary.get("total", 0),
summary.get("registered", 0),
summary.get("changedThisSync", 0),
)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - catalog sync must never take down AppOps.
logger.exception("Gitea project registration sync failed")
async def run_periodic_discovery(interval_seconds: int) -> None:
"""Reconcile deployment project catalog, bounded target state and inventory."""
await _run_gitea_project_sync()
try:
enrollment = await asyncio.to_thread(PrivateMuseDeploymentEnrollment().run)
await asyncio.to_thread(_audit_privatemuse_enrollment, enrollment)
if enrollment.get("status") != "enrolled":
logger.warning(
"PrivateMuse deployment enrollment did not complete: status=%s reason=%s",
enrollment.get("status"),
enrollment.get("reason", "unspecified"),
)
else:
logger.info("PrivateMuse deployment target is enrolled")
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - enrollment failure must not take down AppOps.
logger.exception("PrivateMuse deployment enrollment failed")
try:
reconciliation = await asyncio.to_thread(McpHubDeploymentReconciliation().run)
if reconciliation.get("status") == "reconciled":
policy_result = await asyncio.to_thread(_ensure_mcp_hub_exact_policies)
reconciliation = {
**reconciliation,
"policyStatus": policy_result.get("status", "error"),
"policyIds": policy_result.get("policyIds", []),
"policiesCreated": policy_result.get("created", 0),
}
if policy_result.get("status") != "ready":
reconciliation = {
**reconciliation,
"status": "policy_conflict",
"reason": "exact MCP Hub deployment policy reconciliation failed closed",
}
await asyncio.to_thread(_audit_mcp_hub_reconciliation, reconciliation)
if reconciliation.get("status") != "reconciled":
logger.warning(
"MCP Hub deployment reconciliation did not complete: status=%s reason=%s",
reconciliation.get("status"),
reconciliation.get("reason", "unspecified"),
)
else:
logger.info("MCP Hub deployment state is reconciled")
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - reconciliation failure must not take down AppOps.
logger.exception("MCP Hub deployment reconciliation failed")
try:
reconciliation = await asyncio.to_thread(
MediaCodecDashboardDeploymentReconciliation().run
)
await asyncio.to_thread(
_audit_media_codec_dashboard_reconciliation, reconciliation
)
if reconciliation.get("status") != "reconciled":
logger.warning(
"Media Codec Dashboard deployment reconciliation did not complete: status=%s reason=%s",
reconciliation.get("status"),
reconciliation.get("reason", "unspecified"),
)
else:
logger.info("Media Codec Dashboard deployment state is reconciled")
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - reconciliation failure must not take down AppOps.
logger.exception("Media Codec Dashboard deployment reconciliation failed")
if interval_seconds <= 0:
return
await asyncio.sleep(min(10, interval_seconds))
while True:
try:
await asyncio.to_thread(run_discovery)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - discovery must never take down the gateway.
logger.exception("Scheduled inventory discovery failed")
await _run_gitea_project_sync()
await asyncio.sleep(interval_seconds)
+986
View File
@@ -0,0 +1,986 @@
"""Bounded enrollment orchestration for a new managed application identity.
The plane is intentionally not a generic infrastructure API. It creates a
single controller target from a trusted profile, generates write-only
credentials, and only then creates exact-target deployment policies. Every
resource identifier and error returned from this module is sanitized.
"""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import uuid
from typing import Any, Literal
from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.portainer import PortainerConnector
from ..core.time import utc_now
from ..db.models import AppSetting, Enrollment, EnrollmentJob, Policy
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
from .audit_logger import log_event
from .deployment_broker import DeploymentBrokerClient
from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary
from .secret_store import (
delete_secret,
get_secret,
has_secret,
secret_storage_status,
set_secret,
)
TRUST_SETTING_KEY = "deployment_enrollment:trust"
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
_TERMINAL = {"ready", "failed", "compensation_required", "preflight_failed"}
_CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "appdataPath"}
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
class EnrollmentError(ValueError):
pass
def _canonical(value: EnrollmentPlanV1) -> tuple[dict[str, Any], str]:
payload = value.model_dump(mode="json")
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
return payload, digest
def _idempotency_digest(operation: str, value: str) -> str:
if not _IDEMPOTENCY_KEY.fullmatch(value):
raise EnrollmentError("idempotency key must contain 8 to 128 safe characters")
return hashlib.sha256(f"{operation}:{value}".encode()).hexdigest()
def _check(
code: str,
status: Literal["passed", "blocked", "warning", "unknown"],
detail: str,
) -> dict[str, str]:
return {"code": code, "status": status, "detail": detail}
def _secret_ref(application_id: str, kind: str) -> str:
return f"ENROLL_{application_id.upper().replace('-', '_')}_{kind}"
def _credential() -> str:
# 64 URL-safe characters provide a high-entropy printable HMAC key while
# remaining suitable for the controller's mode-0600 credential file.
return secrets.token_urlsafe(48)
def _fingerprint(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool:
"""Enrollment always needs a configured production-grade key.
The general secret store intentionally supports a development fallback for
offline UI work. Enrollment generates controller and OIDC credentials, so
it must never accept that deterministic fallback as a write target.
"""
return bool(storage.get("productionReady"))
class EnrollmentPlane:
def __init__(
self,
*,
broker: DeploymentBrokerClient | None = None,
portainer: PortainerConnector | None = None,
npm: NginxProxyManagerConnector | None = None,
authentik: AuthentikConnector | None = None,
):
self.broker = broker or DeploymentBrokerClient()
self.portainer = portainer or PortainerConnector()
self.npm = npm or NginxProxyManagerConnector()
self.authentik = authentik or AuthentikConnector()
@staticmethod
def get_trust_config(session: Session) -> EnrollmentTrustConfigV1 | None:
row = session.get(AppSetting, TRUST_SETTING_KEY)
if not row or row.is_secret:
return None
try:
return EnrollmentTrustConfigV1.model_validate(row.value_json)
except ValueError:
return None
@staticmethod
def set_trust_config(session: Session, value: EnrollmentTrustConfigV1) -> dict[str, Any]:
row = session.get(AppSetting, TRUST_SETTING_KEY) or AppSetting(
key=TRUST_SETTING_KEY, is_secret=False
)
row.value_json = value.model_dump(mode="json")
row.is_secret = False
row.updated_at = utc_now()
session.add(row)
session.commit()
return EnrollmentPlane.trust_status(session)
@staticmethod
def trust_status(session: Session) -> dict[str, Any]:
trust = EnrollmentPlane.get_trust_config(session)
storage = secret_storage_status()
if trust is None:
return {
"status": "needs_setup",
"blockers": ["enrollment_trust_not_configured"],
"configured": False,
"secretStore": storage,
}
return {
"status": "ready" if _enrollment_secret_store_ready(storage) else "blocked",
"blockers": [] if _enrollment_secret_store_ready(storage) else ["secret_store_not_production_ready"],
"configured": True,
"controllerIdentity": trust.controller_identity,
"trustedAppdataRoot": trust.trusted_appdata_root,
"approvedNetworks": list(trust.approved_networks),
"allowedDomainSuffixes": list(trust.allowed_domain_suffixes),
"autonomousProfiles": list(trust.autonomous_profiles),
"repopulseProxyAccessListId": trust.repopulse_proxy_access_list_id,
"oidcConfigured": bool(trust.oidc_issuer and trust.administrator_group),
"bootstrapSecretConfigured": has_secret(session, trust.bootstrap_secret_ref),
"secretStore": storage,
}
def capabilities(self, session: Session) -> dict[str, Any]:
trust = self.get_trust_config(session)
storage = secret_storage_status()
configured = trust is not None
bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref))
controller_capability: dict[str, Any] = {}
if bootstrap and self.broker.is_configured() and trust is not None:
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
if bootstrap_secret:
controller_capability = self.broker.enrollment_capabilities(secret=bootstrap_secret)
broker_ready = bool(
bootstrap
and self.broker.is_configured()
and controller_capability.get("status") == "ready"
)
npm_ready = self.npm.is_configured()
oidc_configured = bool(
trust
and trust.oidc_issuer
and trust.administrator_group
and trust.support_agent_group
and trust.read_only_group
and self.authentik.is_configured()
)
certificate_ready = bool(npm_ready)
def item(ready: bool, code: str) -> dict[str, str]:
return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code}
return {
"apiVersion": "itworx.enrollment-capabilities/v1",
"trust": item(configured, "enrollment_trust_not_configured"),
"targetEnrollment": item(broker_ready, "deployment_executor_unavailable"),
"secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"),
"runtimeDeployment": item(broker_ready, "deployment_executor_unavailable"),
"reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"),
"tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"),
"oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"),
"policyProvisioning": item(configured, "policy_template_unavailable"),
"rollback": item(broker_ready, "deployment_executor_unavailable"),
"verification": item(broker_ready, "deployment_executor_unavailable"),
"controller": {
"status": str(controller_capability.get("status") or "unavailable"),
"reason": str(controller_capability.get("reason") or "deployment_executor_unavailable"),
},
"profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")],
}
@staticmethod
def _allowed_hostname(hostname: str, trust: EnrollmentTrustConfigV1) -> bool:
return any(hostname == suffix or hostname.endswith(f".{suffix}") for suffix in trust.allowed_domain_suffixes)
@staticmethod
def _existing(session: Session, application_id: str, repository: str) -> tuple[Enrollment | None, Enrollment | None]:
by_application = session.exec(
select(Enrollment).where(Enrollment.application_id == application_id)
).first()
by_repository = session.exec(
select(Enrollment).where(Enrollment.repository == repository)
).first()
return by_application, by_repository
def _controller_payload(
self,
plan: EnrollmentPlanV1,
*,
enrollment_id: str,
target_credential: str | None = None,
) -> dict[str, Any]:
profile = profile_contract(plan.profile)
assert profile is not None # validated before this method is called
payload: dict[str, Any] = {
"api_version": "itworx.controller-enrollment/v1",
"enrollment_id": enrollment_id,
"application_id": plan.application_id,
"repository": plan.repository,
"revision": plan.revision,
"profile": plan.profile,
"container_name": plan.target.container_name,
"compose_file": plan.target.compose_file,
"appdata_path": plan.appdata_path,
"data_path": plan.data_path,
"runtime_uid": plan.storage.runtime_uid,
"runtime_gid": plan.storage.runtime_gid,
"network": plan.network.docker_network,
"internal_port": plan.network.internal_port,
"proxy_hostname": plan.exposure.hostname if plan.exposure else None,
"image_repository": plan.artifact.image_repository,
"image_tag": plan.artifact.image_tag,
"health_path": plan.artifact.health_path,
"runtime_environment_keys": list(profile["runtimeEnvironmentKeys"]),
}
if target_credential is not None:
payload["target_credential"] = target_credential
return payload
def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]:
checks: list[dict[str, str]] = []
trust = self.get_trust_config(session)
profile = profile_contract(plan.profile)
if trust is None:
checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured."))
else:
checks.append(_check("enrollment_trust", "passed", "Root-owned enrollment boundaries are configured."))
if plan.target.target_identity == trust.controller_identity:
checks.append(_check("controller_identity", "passed", "Requested controller matches the trusted enrollment target."))
else:
checks.append(_check("deployment_executor_unavailable", "blocked", "Requested controller is not the configured enrollment target."))
if plan.network.docker_network in trust.approved_networks:
checks.append(_check("network", "passed", "Requested Docker network is within the trusted profile boundary."))
else:
checks.append(_check("network_unavailable", "blocked", "Requested Docker network is not approved for enrollment."))
if plan.profile in trust.autonomous_profiles:
checks.append(_check("autonomous_profile", "passed", "Deployment profile is approved for autonomous enrollment."))
else:
checks.append(_check("policy_template_unavailable", "blocked", "Deployment profile is not approved for autonomous enrollment."))
if profile is None:
checks.append(_check("policy_template_unavailable", "blocked", "Requested server-side deployment profile is unavailable."))
else:
checks.append(_check("deployment_profile", "passed", "Server-side deployment profile is available."))
if plan.profile == "repopulse-authenticated":
gitea_token = get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False)
checks.append(_check(
"repopulse_gitea_token_unavailable",
"passed" if gitea_token else "blocked",
"The existing encrypted RepoPulse Gitea token is available by reference."
if gitea_token else "The existing encrypted RepoPulse Gitea token is unavailable.",
))
if has_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN")):
checks.append(_check(
"repopulse_maintenance_token_collision", "blocked",
"An existing write-only RepoPulse maintenance token cannot be adopted by a new enrollment.",
))
else:
checks.append(_check(
"repopulse_maintenance_token", "passed",
"A new write-only RepoPulse maintenance token can be generated for this enrollment.",
))
if trust is None or trust.repopulse_proxy_access_list_id is None:
checks.append(_check(
"repopulse_access_list_unavailable", "blocked",
"A positive root-owned RepoPulse NPM access-list ID is not configured.",
))
else:
verify_access_list = getattr(self.npm, "validate_managed_access_list", None)
access_list = verify_access_list(trust.repopulse_proxy_access_list_id) if callable(verify_access_list) else {}
verified = (
access_list.get("success") is True
and access_list.get("status") == "verified"
and access_list.get("accessListId") == trust.repopulse_proxy_access_list_id
)
checks.append(_check(
"repopulse_access_list_unavailable",
"passed" if verified else "blocked",
"The root-owned RepoPulse NPM access list has active authorization entries and requires all checks."
if verified else "The root-owned RepoPulse NPM access list could not be verified.",
))
by_application, by_repository = self._existing(session, plan.application_id, plan.repository)
if by_application:
status = "warning" if by_application.plan_sha256 == _canonical(plan)[1] else "blocked"
code = "enrollment_replay" if status == "warning" else "target_already_exists"
checks.append(_check(code, status, "This application identity is already enrolled."))
else:
checks.append(_check("application_identity", "passed", "Application identity is not yet enrolled."))
if by_repository and (not by_application or by_repository.id != by_application.id):
checks.append(_check("target_already_exists", "blocked", "Repository is already bound to a different managed application."))
containers = self.portainer.list_containers()
if containers.get("status") != "ok":
checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection."))
else:
names = {str(item.get("name") or "").casefold() for item in containers.get("containers", [])}
if plan.target.container_name.casefold() in names:
checks.append(_check("unmanaged_resource_collision", "blocked", "Container identity already exists and cannot be claimed by enrollment."))
else:
checks.append(_check("container_identity", "passed", "Container identity is free."))
if plan.exposure:
if trust and self._allowed_hostname(plan.exposure.hostname, trust):
checks.append(_check("hostname_suffix", "passed", "Hostname is inside an allowed domain suffix."))
else:
checks.append(_check("hostname_not_allowed", "blocked", "Hostname is outside the configured allowed suffixes."))
hosts = self.npm.proxy_hosts()
if not hosts.get("success"):
checks.append(_check("npm_credentials_missing" if not self.npm.is_configured() else "npm_executor_unavailable", "blocked", "NPM inventory is unavailable for takeover protection."))
else:
existing = [item for item in hosts.get("items", []) if plan.exposure.hostname in {str(value).lower() for value in item.get("domainNames", [])}]
checks.append(_check("hostname_collision", "blocked" if existing else "passed", "Hostname is already owned and cannot be taken over." if existing else "Hostname is free in NPM."))
if plan.authentication.mode == "native-oidc":
groups_ready = bool(trust and trust.administrator_group and trust.support_agent_group and trust.read_only_group and trust.oidc_issuer)
if not groups_ready or not self.authentik.is_configured():
checks.append(_check("oidc_profile_unavailable", "blocked", "Configured OIDC profile or Authentik role groups are unavailable."))
else:
assert trust is not None and plan.authentication.roles is not None
expected_roles = {
"administrator": trust.administrator_group,
"support_agent": trust.support_agent_group,
"read_only": trust.read_only_group,
}
requested_roles = plan.authentication.roles.model_dump(mode="json")
checks.append(_check(
"oidc_role_model",
"passed" if requested_roles == expected_roles else "blocked",
"Requested application roles match the root-owned Authentik role model."
if requested_roles == expected_roles
else "Requested application roles do not match the root-owned Authentik role model.",
))
auth_status = self.authentik.test_connection()
if auth_status.get("status") != "connected":
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable."))
else:
template = self.authentik.deployment_oidc_template_status(
role_claim=trust.oidc_role_claim
)
if template.get("status") != "ready":
checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete."))
applications = self.authentik.applications()
groups = self.authentik.groups()
if not applications.get("success") or not groups.get("success"):
checks.append(_check("oidc_provider", "blocked", "Authentik applications cannot be inspected for takeover protection."))
elif any(str(item.get("slug") or "") == plan.application_id for item in applications.get("items", [])):
checks.append(_check("unmanaged_resource_collision", "blocked", "An existing Authentik application cannot be claimed by a new enrollment."))
else:
checks.append(_check("oidc_provider", "passed", "Authentik is available and the application identity is free."))
required_groups = {
trust.administrator_group if trust else "",
trust.support_agent_group if trust else "",
trust.read_only_group if trust else "",
}
found_groups = {str(item.get("name") or "") for item in groups.get("items", [])}
checks.append(_check(
"oidc_role_groups",
"passed" if required_groups.issubset(found_groups) else "blocked",
"Configured Authentik role groups are available." if required_groups.issubset(found_groups) else "One or more configured Authentik role groups are missing.",
))
storage = secret_storage_status()
storage_ready = _enrollment_secret_store_ready(storage)
checks.append(_check("secret_store", "passed" if storage_ready else "blocked", "Production-grade write-only secret storage is ready." if storage_ready else "Enrollment requires APPOPS_SECRET_KEY from a protected environment variable or file; the development fallback is not permitted."))
if trust:
checks.append(_check("controller_bootstrap_secret", "passed" if has_secret(session, trust.bootstrap_secret_ref) else "blocked", "Controller bootstrap secret is available by reference." if has_secret(session, trust.bootstrap_secret_ref) else "Controller bootstrap secret reference is not configured."))
if has_secret(session, trust.bootstrap_secret_ref):
controller = self.broker.enrollment_preflight(
self._controller_payload(plan, enrollment_id="enr-00000000000000000000"),
secret=get_secret(session, trust.bootstrap_secret_ref, touch=False) or "",
)
code = str(controller.get("reason") or "deployment_executor_unavailable")
checks.append(_check("controller_target", "passed" if controller.get("status") in {"preflight_ok", "ready"} else "blocked", "Controller accepted the bounded enrollment preflight." if controller.get("status") in {"preflight_ok", "ready"} else f"Controller enrollment preflight is unavailable ({code})."))
else:
checks.append(_check("controller_target", "blocked", "Controller bootstrap credential is unavailable."))
blockers = [item["code"] for item in checks if item["status"] == "blocked"]
warnings = [item["code"] for item in checks if item["status"] == "warning"]
planned_resources = {
"applicationId": plan.application_id,
"controllerTarget": plan.target.target_identity,
"appdataPath": plan.appdata_path,
"dataPath": plan.data_path,
"network": plan.network.docker_network,
"hostname": plan.exposure.hostname if plan.exposure else None,
"oidc": plan.authentication.mode == "native-oidc",
}
return {
"status": "ready" if not blockers else "blocked",
"blockers": blockers,
"warnings": warnings,
"checks": checks,
"plannedResources": planned_resources,
"plannedMutations": [] if blockers else ["controller_target_register", "secret_generate", *( ["reverse_proxy_ensure"] if plan.exposure else []), *( ["oidc_application_ensure"] if plan.authentication.mode == "native-oidc" else []), *( ["runtime_environment_seal"] if profile and profile["runtimeEnvironmentKeys"] else []), "exact_policy_install"],
"plannedSecretRefs": [
_secret_ref(plan.application_id, "TARGET_HMAC"),
*( [_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET")] if plan.authentication.mode == "native-oidc" else []),
*( ["REPOPULSE_GITEA_TOKEN", _secret_ref("repopulse", "MAINTENANCE_TOKEN")] if plan.profile == "repopulse-authenticated" else []),
],
"plannedPolicies": [f"enrollment-{plan.application_id}-deployment-apply", f"enrollment-{plan.application_id}-deployment-rollback"],
}
def _create_enrollment(self, session: Session, plan: EnrollmentPlanV1, plan_sha256: str, trust: EnrollmentTrustConfigV1) -> Enrollment:
enrollment = Enrollment(
id=f"enr-{uuid.uuid4().hex[:20]}",
application_id=plan.application_id,
repository=plan.repository,
revision=plan.revision,
profile=plan.profile,
controller_identity=trust.controller_identity,
plan_sha256=plan_sha256,
status="planned",
target_secret_ref=_secret_ref(plan.application_id, "TARGET_HMAC"),
oidc_secret_ref=_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET") if plan.authentication.mode == "native-oidc" else None,
plan_json=plan.model_dump(mode="json"),
provenance_json={"profileVersion": PROFILE_VERSION, "createdBy": "enrollment-plane/v1", "sourceRevision": plan.revision},
)
session.add(enrollment)
session.commit()
session.refresh(enrollment)
return enrollment
def _create_job(self, session: Session, enrollment: Enrollment, idempotency_key: str) -> EnrollmentJob:
digest = _idempotency_digest("enroll", idempotency_key)
existing = session.exec(select(EnrollmentJob).where(EnrollmentJob.idempotency_sha256 == digest)).first()
if existing:
if existing.enrollment_id != enrollment.id:
raise EnrollmentError("idempotency key was already used for a different enrollment")
return existing
job = EnrollmentJob(
id=f"enroll-{uuid.uuid4().hex[:16]}",
enrollment_id=enrollment.id,
application_id=enrollment.application_id,
idempotency_sha256=digest,
status="queued",
stage="planned",
)
session.add(job)
session.commit()
session.refresh(job)
return job
@staticmethod
def serialize_enrollment(enrollment: Enrollment) -> dict[str, Any]:
plan = dict(enrollment.plan_json or {})
exposure = plan.get("exposure") if isinstance(plan.get("exposure"), dict) else {}
authentication = plan.get("authentication") if isinstance(plan.get("authentication"), dict) else {}
return {
"id": enrollment.id,
"applicationId": enrollment.application_id,
"repository": enrollment.repository,
"revision": enrollment.revision,
"profile": enrollment.profile,
"controllerIdentity": enrollment.controller_identity,
"status": enrollment.status,
"publicUrl": f"https://{exposure.get('hostname')}" if exposure.get("hostname") else None,
"authentication": authentication.get("mode", "none"),
"resources": enrollment.resources_json or {},
"targetSecretRef": enrollment.target_secret_ref,
"oidcSecretRef": enrollment.oidc_secret_ref,
"maintenanceSecretRef": (
_secret_ref("repopulse", "MAINTENANCE_TOKEN")
if enrollment.profile == "repopulse-authenticated" else None
),
"lastVerifiedAt": enrollment.last_verified_at.isoformat() if enrollment.last_verified_at else None,
"createdAt": enrollment.created_at.isoformat(),
"updatedAt": enrollment.updated_at.isoformat(),
"completedAt": enrollment.completed_at.isoformat() if enrollment.completed_at else None,
}
@staticmethod
def serialize_job(job: EnrollmentJob, *, replay: bool = False) -> dict[str, Any]:
return {
"id": job.id,
"enrollmentId": job.enrollment_id,
"applicationId": job.application_id,
"operation": job.operation,
"status": job.status,
"stage": job.stage,
"preflight": job.preflight_json or {},
"evidence": job.evidence_json or {},
"result": job.result_summary,
"createdAt": job.created_at.isoformat(),
"updatedAt": job.updated_at.isoformat(),
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
"idempotentReplay": replay,
}
@staticmethod
def _checkpoint(
session: Session,
enrollment: Enrollment,
job: EnrollmentJob,
*,
stage: str,
evidence: dict[str, Any],
) -> None:
"""Durably record intent and receipts around every external boundary.
This is deliberately conservative: after an interrupted external call,
a same-plan request is surfaced as recovery-required instead of being
replayed against NPM, Authentik or the controller without proof of its
prior effect. Values in ``evidence`` are identifiers/status only.
"""
now = utc_now()
enrollment.status = "provisioning"
enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence}
enrollment.updated_at = now
job.status = "running"
job.stage = stage
job.evidence_json = {**dict(job.evidence_json or {}), **evidence}
job.updated_at = now
session.add(enrollment)
session.add(job)
session.commit()
def _install_policies(self, session: Session, enrollment: Enrollment, plan: EnrollmentPlanV1) -> list[str]:
provenance = {
"enrollmentId": enrollment.id,
"profileVersion": PROFILE_VERSION,
"createdBy": "enrollment-plane/v1",
"policyTemplateVersion": "exact-target/v1",
"target": plan.application_id,
"sourceRevision": plan.revision,
"createdAt": utc_now().isoformat(),
}
identifiers: list[str] = []
for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")):
policy_id = f"enrollment-{plan.application_id}-{suffix}"
existing = session.get(Policy, policy_id)
if existing is not None:
# Enrollment never adopts or repurposes a pre-existing policy,
# even where its visible action/target happen to match. A
# replay is handled before this method; this branch is a
# collision, not a safe recovery path.
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
existing = Policy(
id=policy_id,
action=action,
target=plan.application_id,
voice="blocked",
manual="allowed",
risk="high",
reason="Enrollment-generated exact-target deployment policy.",
)
existing.provenance_json = provenance
session.add(existing)
identifiers.append(policy_id)
session.commit()
return identifiers
def _compensate(
self,
session: Session,
enrollment: Enrollment,
evidence: dict[str, Any],
) -> dict[str, Any]:
"""Reverse only resources this execution proved it created.
A failed compensation stays visible as ``compensation_required``. It
never attempts to adopt, overwrite or delete an earlier resource.
"""
plan = EnrollmentPlanV1.model_validate(enrollment.plan_json)
outcomes: dict[str, str] = {}
oidc = evidence.get("oidc") if isinstance(evidence.get("oidc"), dict) else {}
if oidc.get("status") == "created":
remove = getattr(self.authentik, "remove_deployment_oidc_application", None)
result = remove(
application_id=oidc.get("applicationId"),
provider_id=oidc.get("providerId"),
application_slug=plan.application_id,
launch_url=plan.public_url or "",
) if callable(remove) else {"success": False, "status": "unsupported"}
outcomes["oidc"] = str(result.get("status") or "failed")
elif oidc.get("status") == "partial_failure":
outcomes["oidc"] = "ownership_unverified"
proxy = evidence.get("proxy") if isinstance(evidence.get("proxy"), dict) else {}
if proxy.get("status") == "created":
remove = getattr(self.npm, "remove_managed_proxy_host", None)
result = remove(host_id=proxy.get("hostId"), ownership_id=enrollment.id) if callable(remove) else {"success": False, "status": "unsupported"}
outcomes["proxy"] = str(result.get("status") or "failed")
elif proxy.get("status") == "ownership_unverified":
outcomes["proxy"] = "ownership_unverified"
controller = evidence.get("controller") if isinstance(evidence.get("controller"), dict) else {}
if controller.get("status") in {"enrolled", "already_enrolled"}:
trust = self.get_trust_config(session)
bootstrap = get_secret(session, trust.bootstrap_secret_ref, touch=False) if trust else None
if bootstrap:
result = self.broker.unenroll_target(
{"enrollment_id": enrollment.id, "application_id": enrollment.application_id, "repository": enrollment.repository},
secret=bootstrap,
)
outcomes["controller"] = str(result.get("status") or "failed")
else:
outcomes["controller"] = "bootstrap_secret_unavailable"
elif controller.get("status") == "partial_failure":
outcomes["controller"] = "ownership_unverified"
completed = all(value in {"removed", "rolled_back"} for value in outcomes.values())
if completed and outcomes:
delete_secret(session, enrollment.target_secret_ref)
if enrollment.oidc_secret_ref:
delete_secret(session, enrollment.oidc_secret_ref)
if plan.profile == "repopulse-authenticated":
delete_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN"))
return {"completed": completed, "outcomes": outcomes}
def _fail(
self,
session: Session,
enrollment: Enrollment,
job: EnrollmentJob,
*,
stage: str,
message: str,
evidence: dict[str, Any],
) -> dict[str, Any]:
compensation = (
self._compensate(session, enrollment, evidence)
if evidence.get("controller")
else {"completed": True, "outcomes": {}}
)
compensation_required = not bool(compensation.get("completed"))
enrollment.status = "compensation_required" if compensation_required else "failed"
enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence, "compensation": compensation}
enrollment.updated_at = utc_now()
enrollment.completed_at = utc_now()
job.status = "compensation_required" if compensation_required else "failed"
job.stage = stage
job.evidence_json = {**dict(job.evidence_json or {}), **evidence, "compensation": compensation}
job.result_summary = message
job.updated_at = utc_now()
job.completed_at = utc_now()
session.add(enrollment)
session.add(job)
session.commit()
log_event(
session,
source="enrollment-plane",
identity="operator",
action_id="target.enroll",
target=enrollment.application_id,
target_type="enrollment",
risk="high",
policy_decision="blocked",
reason=message,
result="failed",
execution_plan={"enrollmentId": enrollment.id, "planSha256": enrollment.plan_sha256},
after_state={"stage": stage, "status": enrollment.status},
)
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
def enroll(self, session: Session, plan: EnrollmentPlanV1, *, idempotency_key: str) -> dict[str, Any]:
_, plan_digest = _canonical(plan)
existing, repository_existing = self._existing(session, plan.application_id, plan.repository)
if existing and existing.plan_sha256 == plan_digest:
prior = session.exec(
select(EnrollmentJob).where(EnrollmentJob.enrollment_id == existing.id).order_by(EnrollmentJob.created_at.desc())
).first()
if prior is None or prior.status in _TERMINAL:
return {
"enrollment": self.serialize_enrollment(existing),
"job": self.serialize_job(prior, replay=True) if prior else None,
"idempotentReplay": True,
}
# A process may have stopped after an external request was sent but
# before its response was durably checkpointed. Do not infer the
# effect and do not send the request again: retain the exact intent
# and require an explicit reconciliation flow.
existing.status = "recovery_required"
existing.updated_at = utc_now()
prior.status = "recovery_required"
prior.result_summary = "Enrollment stopped during an external stage; reconcile recorded intent and receipts before retrying."
prior.updated_at = utc_now()
session.add(existing)
session.add(prior)
session.commit()
return {
"enrollment": self.serialize_enrollment(existing),
"job": self.serialize_job(prior),
"recoveryRequired": True,
}
if existing or repository_existing:
raise EnrollmentError("application identity or repository is already enrolled")
trust = self.get_trust_config(session)
if trust is None:
raise EnrollmentError("enrollment trust boundaries are not configured")
enrollment = self._create_enrollment(session, plan, plan_digest, trust)
job = self._create_job(session, enrollment, idempotency_key)
if job.status != "queued":
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job, replay=True)}
preflight = self.preflight(session, plan)
job.preflight_json = preflight
job.updated_at = utc_now()
session.add(job)
session.commit()
if preflight["status"] != "ready":
enrollment.status = "preflight_failed"
enrollment.completed_at = utc_now()
enrollment.updated_at = utc_now()
job.status = "preflight_failed"
job.stage = "preflight"
job.result_summary = "Enrollment preflight was blocked."
job.completed_at = utc_now()
session.add(enrollment)
session.add(job)
session.commit()
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
repopulse_gitea_token = (
get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False)
if plan.profile == "repopulse-authenticated" else None
)
if plan.profile == "repopulse-authenticated" and not repopulse_gitea_token:
return self._fail(
session, enrollment, job, stage="preflight",
message="The existing encrypted RepoPulse Gitea token became unavailable after preflight.",
evidence={},
)
target_secret = _credential()
set_secret(
session,
enrollment.target_secret_ref,
target_secret,
used_by=[f"enrollment:{enrollment.id}", "deployment-controller"],
metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(target_secret), "rotationState": "active"},
)
oidc_secret: str | None = None
if enrollment.oidc_secret_ref:
oidc_secret = _credential()
set_secret(
session,
enrollment.oidc_secret_ref,
oidc_secret,
used_by=[f"enrollment:{enrollment.id}", "authentik", "deployment-controller"],
metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(oidc_secret), "rotationState": "active"},
)
repopulse_maintenance_token: str | None = None
if plan.profile == "repopulse-authenticated":
repopulse_maintenance_token = _credential()
set_secret(
session,
_secret_ref("repopulse", "MAINTENANCE_TOKEN"),
repopulse_maintenance_token,
used_by=[f"enrollment:{enrollment.id}", "repopulse-maintenance", "deployment-controller"],
metadata={
"enrollmentId": enrollment.id,
"credentialFingerprint": _fingerprint(repopulse_maintenance_token),
"rotationState": "active",
},
)
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
if not bootstrap_secret:
return self._fail(session, enrollment, job, stage="creating_secrets", message="Controller bootstrap secret disappeared after preflight.", evidence={})
evidence: dict[str, Any] = {
"intent": {
"controller": {
"enrollmentId": enrollment.id,
"applicationId": plan.application_id,
"repository": plan.repository,
}
}
}
self._checkpoint(
session, enrollment, job, stage="registering_target", evidence=evidence
)
controller_result = self.broker.enroll_target(
self._controller_payload(plan, enrollment_id=enrollment.id, target_credential=target_secret),
secret=bootstrap_secret,
idempotency_key=idempotency_key,
)
evidence["controller"] = {
key: controller_result[key] for key in _CONTROLLER_EVIDENCE if key in controller_result
}
self._checkpoint(
session, enrollment, job, stage="registering_target", evidence=evidence
)
if controller_result.get("status") not in {"enrolled", "already_enrolled"}:
return self._fail(session, enrollment, job, stage="registering_target", message="Controller rejected the bounded target enrollment.", evidence=evidence)
if plan.exposure:
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"proxy": {"hostname": plan.exposure.hostname, "ownershipId": enrollment.id},
}
self._checkpoint(
session, enrollment, job, stage="provisioning_proxy", evidence=evidence
)
proxy_arguments = {
"hostname": plan.exposure.hostname,
"forward_host": plan.application_id,
"forward_port": plan.network.internal_port,
"certificate_mode": "managed",
"force_ssl": True,
"http2": True,
"upload_limit_mb": 1024 if plan.exposure.upload_profile == "large-upload" else 128,
"read_timeout_seconds": 900 if plan.exposure.timeout_profile == "long-running" else 120,
"ownership_id": enrollment.id,
}
if plan.profile == "repopulse-authenticated":
access_list_id = trust.repopulse_proxy_access_list_id if trust else None
if not access_list_id or access_list_id <= 0:
return self._fail(
session, enrollment, job,
stage="provisioning_proxy",
message="RepoPulse requires a verified, positive NPM access-list ID.",
evidence=evidence,
)
proxy_arguments["access_list_id"] = access_list_id
proxy_result = self.npm.ensure_managed_proxy_host(
**proxy_arguments,
)
evidence["proxy"] = {key: proxy_result[key] for key in _PROXY_EVIDENCE if key in proxy_result}
self._checkpoint(
session, enrollment, job, stage="provisioning_proxy", evidence=evidence
)
if not proxy_result.get("success") or proxy_result.get("status") != "created":
return self._fail(session, enrollment, job, stage="provisioning_proxy", message="Managed reverse-proxy provisioning was not verified.", evidence=evidence)
profile = profile_contract(plan.profile)
runtime_values: dict[str, str] = {}
if plan.public_url and profile and "APPOPS_PUBLIC_URL" in profile["runtimeEnvironmentKeys"]:
runtime_values["APPOPS_PUBLIC_URL"] = plan.public_url
if plan.authentication.mode == "native-oidc":
assert oidc_secret is not None and enrollment.oidc_secret_ref is not None
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"oidc": {"applicationSlug": plan.application_id, "launchUrl": plan.public_url},
}
self._checkpoint(
session, enrollment, job, stage="provisioning_identity", evidence=evidence
)
oidc_result = self.authentik.ensure_deployment_oidc_application(
application_slug=plan.application_id,
launch_url=plan.public_url or "",
redirect_uri=f"{plan.public_url}{plan.authentication.redirect_path}",
client_secret=oidc_secret,
administrator_group=trust.administrator_group or "",
support_agent_group=trust.support_agent_group or "",
read_only_group=trust.read_only_group or "",
)
evidence["oidc"] = {key: oidc_result[key] for key in _OIDC_EVIDENCE if key in oidc_result}
self._checkpoint(
session, enrollment, job, stage="provisioning_identity", evidence=evidence
)
if not oidc_result.get("success") or oidc_result.get("status") != "created":
return self._fail(session, enrollment, job, stage="provisioning_identity", message="Bounded OIDC application provisioning was not verified.", evidence=evidence)
runtime_values.update({
"OIDC_CLIENT_ID": plan.application_id,
"OIDC_CLIENT_SECRET": oidc_secret,
"OIDC_ISSUER": trust.oidc_issuer or "",
"OIDC_ROLE_CLAIM": trust.oidc_role_claim,
})
if plan.profile == "repopulse-authenticated":
assert repopulse_gitea_token is not None and repopulse_maintenance_token is not None
runtime_values.update({
"REPOPULSE_GITEA_URL": "https://gitea.example.com",
"REPOPULSE_GITEA_TOKEN": repopulse_gitea_token,
"REPOPULSE_MAINTENANCE_TOKEN": repopulse_maintenance_token,
})
if profile and profile["runtimeEnvironmentKeys"]:
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"runtime": {"applicationId": plan.application_id, "keys": list(runtime_values)},
}
self._checkpoint(
session, enrollment, job, stage="sealing_runtime", evidence=evidence
)
runtime_result = self.broker.configure_enrollment_runtime(
{
"enrollment_id": enrollment.id,
"application_id": plan.application_id,
"repository": plan.repository,
"values": runtime_values,
},
secret=target_secret,
)
evidence["runtime"] = {key: runtime_result[key] for key in ("status", "enrollmentId", "applicationId") if key in runtime_result}
self._checkpoint(
session, enrollment, job, stage="sealing_runtime", evidence=evidence
)
if runtime_result.get("status") not in {"configured", "already_configured"}:
return self._fail(session, enrollment, job, stage="sealing_runtime", message="Controller could not seal profile-owned runtime values.", evidence=evidence)
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"policies": [
f"enrollment-{plan.application_id}-deployment-apply",
f"enrollment-{plan.application_id}-deployment-rollback",
],
}
self._checkpoint(
session, enrollment, job, stage="installing_policy", evidence=evidence
)
try:
policy_ids = self._install_policies(session, enrollment, plan)
except EnrollmentError as exc:
return self._fail(session, enrollment, job, stage="installing_policy", message=str(exc), evidence=evidence)
evidence["policies"] = policy_ids
enrollment.status = "ready"
enrollment.resources_json = {
"appdataPath": plan.appdata_path,
"dataPath": plan.data_path,
"network": plan.network.docker_network,
"hostname": plan.exposure.hostname if plan.exposure else None,
**evidence,
}
enrollment.last_verified_at = utc_now()
enrollment.updated_at = utc_now()
enrollment.completed_at = utc_now()
job.status = "ready"
job.stage = "ready"
job.evidence_json = evidence
job.result_summary = "Bounded target, profile resources and exact policies are ready for deployment."
job.updated_at = utc_now()
job.completed_at = utc_now()
session.add(enrollment)
session.add(job)
session.commit()
log_event(
session,
source="enrollment-plane",
identity="operator",
action_id="target.enroll",
target=plan.application_id,
target_type="enrollment",
risk="high",
policy_decision="allowed",
reason="Bounded application identity was enrolled from an autonomous profile.",
result="success",
execution_plan={"enrollmentId": enrollment.id, "planSha256": plan_digest, "profile": plan.profile},
after_state={"status": "ready", "policyIds": policy_ids},
)
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
def list_enrollments(self, session: Session, *, limit: int = 100) -> list[dict[str, Any]]:
rows = session.exec(select(Enrollment).order_by(Enrollment.created_at.desc()).limit(max(1, min(limit, 250)))).all()
return [self.serialize_enrollment(row) for row in rows]
def get_enrollment(self, session: Session, enrollment_id: str) -> dict[str, Any]:
row = session.get(Enrollment, enrollment_id)
if row is None:
raise EnrollmentError("enrollment was not found")
jobs = session.exec(
select(EnrollmentJob).where(EnrollmentJob.enrollment_id == enrollment_id).order_by(EnrollmentJob.created_at.desc()).limit(20)
).all()
return {"enrollment": self.serialize_enrollment(row), "jobs": [self.serialize_job(job) for job in jobs]}
Loaded 100 of 528 files, more files were not shown because too many files have changed in this diff. Show more