Zum Inhalt springen

Architecture overview

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

Pia Server is an ASP.NET Core 10 service. HTTP endpoints are MVC controllers, grouped by area under Controllers/. The Blazor Server admin UI and one SignalR hub are mounted in the same process.

Area Controllers Purpose
Auth Controllers/Auth/ OAuth login, local login, refresh, logout, /auth/me, MFA, WebAuthn, sessions
Sync Controllers/Sync/ Cursor-based pull, conflict-aware push, status, audit, quota, plugin assets
AI proxy Controllers/AiProxy/ Forward client requests to upstream AI providers, with guardrails and the tool loop
E2EE Controllers/E2EE/ Register / approve / revoke devices, exchange wrapped UMK, recovery
Knowledge Controllers/Knowledge/ Knowledge-base document ingest and management
Operators Controllers/Operators/ Pia Mesh assignments — enqueue, poll, cancel
Admin Controllers/Admin/ Admin-only APIs behind AdminPolicy, including the assignment roll-up
Licensing Controllers/Licensing/ Licence status

Two things sit outside the controller model, both deliberately:

  • The admin UI is Blazor Server components mounted at /admin.
  • /hubs/pod-uplink is a SignalR hub — the single exception to the controllers-only rule, because a persistent bidirectional connection is what the Mesh uplink is. It is also the tool plane’s only inbound route. See Pod uplink and connector credentials.

Health check at GET /health. It deliberately does not depend on Temporal or on any pod: an unreachable Mesh dependency must never read as an unhealthy server.

UseHsts → UseHttpsRedirection → UseAuthentication → UseAuthorization → UseRateLimiter → Endpoints

Program.cs itself registers only cross-cutting infrastructure (the DB context factory, Data Protection, authentication, MVC/Razor Components, rate limiting). Everything else is composed from per-area extension methods — AddAuthServices, AddAiProxyServices, AddE2EEServices, AddSyncServices, AddAdminSurfaceServices, AddBackgroundServices, AddConnectorServices / AddRemoteConnectorServices (must run after AddKnowledgeServices so the knowledge-base connector keeps first-registered-wins tool-name precedence over any Mesh pod), and AddKnowledgeServices when a vector-DB connection string is configured. Call order in Program.cs is registration order, and a golden-file test (Snapshots/services.golden.txt) fails on an unreviewed reordering.

A representative slice of what those methods register:

Service Lifetime Notes
PiaDbContext Scoped EF Core, provider chosen by config
JwtService Singleton HS256, issuer pia-server, audience pia-client
UserService Scoped User CRUD, find-or-create on OAuth callback
EncryptionService Singleton AES-256-GCM with per-user keys via HKDF
AiProxyService Scoped Routes per-mode AI requests to upstream providers
ConflictResolver Scoped Last-write-wins sync conflict resolution
QuotaService Scoped Enforces per-user object caps before push
SyncService Scoped Sync orchestration
OnboardingSessionService Singleton In-memory E2EE pairing sessions (10-min expiry)
RefreshTokenCleanupService Hosted Background prune of expired refresh tokens
IConnectorRegistry Scoped Resolves the tool set for a chat request across all connectors
IPodPresenceRegistry Singleton In-memory pod presence; deliberately does not survive a restart
IMcpSessionManager Singleton Pooled MCP sessions to remote pods, with connect backoff and a liveness probe
OperatorWorker Hosted Temporal worker for assignments; idles when Operators:Enabled is off
AssignmentRetentionService Hosted Five-minute sweep for expired assignments and stuck Queued rows

OnboardingSessionService is in-memory by design — pairing sessions are short-lived and don’t need to survive a restart. If you run the server in a load-balanced deployment, route all /api/e2ee/* calls for a given device pairing to the same node, or replace the implementation with a Redis-backed one.

A typical authenticated sync request looks like this:

  1. Caddy terminates TLS and forwards to pia-server.
  2. The rate limiter applies the sync policy (per-user budget).
  3. UseAuthentication validates the JWT and populates HttpContext.User.
  4. SyncController.Push binds the SyncPushRequest body and makes one call, ISyncService.PushAsync — controllers stay thin and never touch EF Core or business logic directly.
  5. Inside PushAsync, QuotaService.CheckQuotaAsync rejects with 409 quota_exceeded if any cap would be violated.
  6. ConflictResolver reconciles per-record UpdatedAt timestamps.
  7. EF Core writes the changes inside a single transaction.
  8. The controller maps the returned Outcome<SyncPushResponse> to a 200 via ToActionResult, with server-assigned IDs and any conflict metadata.

E2EE traffic adds a parallel side-channel: the server stores wrapped UMK material via E2EEDevicesController/E2EERecoveryController, but never sees plaintext keys.

Configuration is layered (appsettings.jsonappsettings.{Environment}.json → environment variables → command-line args). The key sections are:

Section Purpose
Database Provider (sqlite / postgresql) and connection string
Jwt Issuer, audience, signing key, lifetimes
OAuth Per-provider client IDs and secrets
Ai Default provider + per-mode overrides
Encryption Master key (must come from env in production)
Operators, Temporal The Mesh operator runtime — off by default
License Licence file path

See Configuration for the full reference.

A few non-obvious hardening choices are worth knowing about so you don’t accidentally undo them in a refactor:

  • Email enumeration: /auth/login/local is timing-padded (300 ms floor) and runs a dummy hash on the unknown-email branch. The error body is identical for “unknown email” and “wrong password”. Don’t introduce branches that return earlier than the floor.
  • /auth/forgot-password always returns 200, regardless of whether the email is registered. Reset tokens are minted only for real users; the response is identical either way.
  • /auth/register intentionally returns 409 email_exists for duplicates (UX trade-off accepted), with rate limiting in front.
  • OAuth callback collisions: when an OAuth provider brings an email that already exists under a different provider, the server returns 409 email_exists — the same response as /auth/register. This leaks existence but cannot be probed anonymously.