Skip to content

Latest commit

 

History

History
111 lines (85 loc) · 11.9 KB

ARCHITECTURE.md

File metadata and controls

111 lines (85 loc) · 11.9 KB

Architecture

Why things are like this: DECISIONS.md (D-xx). Message definitions: proto/. Rules and plugin contract: CONTRACTS.md. Build order: ROADMAP.md.

1. Topology

Browser ──HTTPS (proxy) / HTTP localhost──► web container (React SPA + own Fastify BFF)   nets: public, backend
                                              │ gRPC / TCP / mTLS — the ONLY thing web talks to
 ┌─────────────────────────────── server container ───────────────────────────────────┐  nets: backend, dbnet, egress
 │ Controller (PID 1) ── starts, assigns rights, restarts ──┐                          │
 │                                                          ▼                          │
 │   Watchdog ──health via Gateway──►  IPC Gateway ◄──── every message passes here ────│
 │                                       ▲  ▲  ▲  ▲                                    │
 │            Executioner ───────────────┘  │  │  └── File-System ── media volume      │
 │               │ spawns + checks          │  └───── Plugin-Mgmt (PMP) ── plugins vol │
 │               ▼ private channel          │                                          │
 │        Plugin A / Plugin B (own UID)     └── Gateway ──TCP, SCRAM──► db (PostgreSQL)│  net: dbnet
 └──────────────────────────────────────────────────────────────┬──────────────────────┘
                                                                ▼ outbound only (egress)
                                                      Provider APIs (e.g. Meta)
Container Contents Networks Published
web frontend + own BFF public, backend yes, 127.0.0.1:8080 by default
server Controller + Watchdog, Gateway, Executioner, PMP, File-System, plugin sub-processes backend, dbnet, egress no
db PostgreSQL 18 dbnet only no
proxy (profile tls) Caddy TLS public 80/443

backend and dbnet are internal (no internet). egress is attached to server only. Web cannot reach the DB; the DB cannot be reached from anything but the server container. No Docker socket anywhere. Hardening everywhere: read-only rootfs, cap_drop: ALL, no-new-privileges, pids/memory limits.

2. Server container processes

Process UID Capabilities Secrets DB Role
Controller (PID 1) root SETUID, SETGID, CHOWN, KILL, DAC_READ_SEARCH reads all, re-homes no spawn, assign rights, restart crashed, signals, ordered shutdown
Watchdog own none none no health checks via Gateway, status, restart requests
IPC Gateway own none DB password, master key, TLS server key + CA yes (only client) door, router, authn/authz, ownership, audit, DB ops, credential crypto
Executioner own ambient SETUID, SETGID only none no loads plugins as sub-processes, verifies their rights, rights-checks + approvals, job scheduler
Plugin-Mgmt (PMP) own none none no writes/stores plugin packages, pins hashes, registry ops
File-System own none none no stores files on the media volume
Plugin n 20000+ each none none (leases per call) no provider integration
/run/postflowx/            (tmpfs, created by the Controller)
  gateway/<process>.sock   one socket dir per process: only that process UID + Gateway UID (= identity)
  gateway/health.sock      Gateway health, status only (probed directly by the Watchdog)
  controller-watchdog      private socketpair (inherited at spawn): restart/stop/status + heartbeat
  secrets/<process>/       re-homed secrets, 0400, owner = that process UID
Executioner ↔ plugin       private inherited socketpair per plugin (never visible to others)

3. Communication and identity

Rule: every message between processes passes through the Gateway. (Process-lifecycle control and the two private channels below carry no application data.)

From → To Transport Caller identified by
Browser → BFF HTTP(S) session cookie (validated at the Gateway)
BFF → Gateway gRPC / TCP / mTLS client cert CN=postflowx-web
Process → Gateway → process gRPC / UDS per-process socket dir (+ UID); Gateway assigns sender, checks route ACL
Plugin ↔ Executioner private channel the channel itself (one per plugin); Executioner attaches actor from the call context
Controller ↔ Watchdog private socketpair inherited at spawn; only restart/stop/status + heartbeat
Controller → children fork/exec, waitpid, signals parent/child
Gateway → PostgreSQL TCP on dbnet SCRAM-SHA-256, password only the Gateway can read
Plugin → provider HTTPS via egress credential leased through Executioner → Gateway

Forbidden: browser → anything but the BFF; web → DB or any process except the Gateway; plugin → Gateway, DB, Docker socket, volumes, other plugins; any process-to-process socket other than via the Gateway; raw SQL from web or any process; a client- or plugin-supplied user/plugin ID treated as identity; anything except web published.

4. Responsibilities

  • Web: serve SPA; cookie transport, CSRF/Origin; validation; error mapping; typed calls to the Gateway only; generic routes GET /api/connect/{plugin}/callback and GET /media/public/{token}; renders plugin descriptors as forms (escaped); shows the approval queue to the admin (SSE). No DB, no domain logic, no authority.
  • Controller: see D-07. Static process list, config table of rights per process. Listens to nothing but the Watchdog channel. No network.
  • Watchdog: health request to every process through the Gateway every 5 s; probes the Gateway directly; states STARTING, HEALTHY, DEGRADED, UNRESPONSIVE, CRASH_LOOP, STOPPED, UNKNOWN; unhealthy → asks the Controller to restart; publishes status for the admin page. Never starts/kills itself.
  • Gateway: terminate mTLS; per-process identity; route table (deny by default); limits and deadlines; correlation IDs; authentication (sessions), role checks, ownership scoping, audit; typed DB operations (users, sessions, plugins registry, accounts, credentials, posts, jobs, plugin data, approvals, audit); credential encryption; signed media tokens.
  • Executioner: reads the registry; spawns and supervises plugin sub-processes (verify pinned hash → distinct UID → verify rights in /proc); plugin ↔ Executioner channel; per-request rights check (trusted/untrusted, grants, approvals); call contexts (call_id); job scheduler and attempts; manifest tasks; reports plugin states.
  • PMP: only writer of the plugins volume; package validation (strict main.json), path safety, SHA-256 pinning; registry rows via the Gateway; install/enable/disable/trust/uninstall operations.
  • File-System: only process touching the media volume (0700); put/get/stat/delete/list with chunked streaming; ID-based paths, atomic writes, magic-byte + size validation, owner scoping and quotas.
  • PostgreSQL: state only; on dbnet; application role DML only.
  • Plugin: implements PluginService, uses the Host API over its private channel to the Executioner (CONTRACTS.md).

5. Key flows

Request: Browser → BFF (CSRF/validate) → Gateway (mTLS, session → actor, route ACL) → typed DB operation or routed call to a process → result → BFF → browser.

Plugin request with approval (local): plugin → (private channel) Executioner checks the plugin's trust level and grants. trusted + granted → forward to the Gateway. untrusted → Executioner holds the request and creates an approval entry through the Gateway (local DB). The BFF follows pending entries through the Gateway and pushes them to the admin's browser; the admin answers deny / allow once / allow always — or uses postflowx-admin approvals … on the CLI. The decision is stored and audited; the Executioner releases or rejects (PERMISSION_DENIED); no answer within 60 s = deny. Nothing leaves the stack.

Connect account (OAuth): user picks plugin → Gateway op accounts.begin_connect creates a single-use state bound to user/session (TTL 10 min) → Executioner creates a call context and calls plugin BeginConnect → provider URL → browser redirect → provider redirects to /api/connect/{plugin}/callback → BFF forwards raw params + session → Gateway validates and consumes state → Executioner calls HandleCallback → plugin exchanges the code → UpsertAccount + StoreCredential (via Executioner → Gateway; encrypted there).

Publish: composer (caption, media, account, provider options from the descriptor, schedule + IANA tz) → Gateway validates (schema) → Executioner calls plugin ValidatePost → job SCHEDULED (UTC) → Executioner's scheduler claims (SKIP LOCKED, lease) → attempt CLAIMED → call context → mark DISPATCHED before calling plugin Publish (deadline) → plugin leases credential, gets media (via File-System ops or signed URL), calls the provider, CheckpointAttempt before irreversible steps → outcome class → state update with backoff for retryable. Timeout/crash after dispatch → OUTCOME_UNKNOWN → Reconcile with checkpoint; only NOT_PUBLISHED auto-retries. After an Executioner or Gateway restart: expired lease while CLAIMED → safe retry; while DISPATCHED → OUTCOME_UNKNOWN.

Plugin lifecycle: DISCOVERED → VALIDATING → INSTALLED → ENABLED → STARTING → READY. CLI installs via PMP; admin sets trust level, grants and enables; Executioner verifies hash, spawns, verifies rights, health check + Describe with compatible API → READY. Failures: INVALID, INCOMPATIBLE, START_FAILED (incl. rights deviation), UNHEALTHY, CRASH_LOOP, DISABLED, UNINSTALL_PENDING.

6. Data

Schema core (Gateway only): users, sessions, user_roles, plugins, plugin_versions, plugin_grants, plugin_approvals, social_accounts, credentials (ciphertext, key_id, expires_at, state), media, posts, publishing_jobs, job_attempts (state, lease, dispatched_at, checkpoint, external ids), plugin_data, connect_states, public_media_tokens (hashed), audit_events, schema_migrations. Admins have no implicit access to other users' posts, media or credentials.

Volume / path Written by Read by
pgdata db db only
media → /var/lib/postflowx/media File-System only (0700) File-System only
plugins → /var/lib/postflowx/plugins PMP only Executioner (exec/read), PMP
/run/postflowx, /tmp (tmpfs) Controller / per process sockets, re-homed secrets, per-plugin temp (0700)

The plugins volume is mounted read-write in the server container (PMP writes at runtime); only the PMP UID has write permission.

7. Startup, health, shutdown

Compose waits for db health → server: Controller starts Gateway → File-System → PMP → Executioner → Watchdog. web starts independently and shows a degraded screen if the server is down. Docker healthcheck of server = Controller's view of the five platform processes (plugin failures only degrade). Crash → Controller restarts (backoff); hung → Watchdog asks the Controller; Gateway/Controller crash loop → container unhealthy → Docker restarts. Shutdown: Watchdog stops first (no restart storms) → Executioner drains jobs and plugins (≤ 30 s) → PMP → File-System → Gateway (stop_grace_period 45 s).