Stack

Last updated

The full inventory of moving parts.

Process model

ProcessLanguageOwner UIDListens onPurpose
jabali-panel.serviceGo (Gin)jabaliunix /run/jabali-panel.sockThe HTTP panel itself. Behind nginx.
jabali-agent.serviceGorootunix /run/jabali-agent.sock (group jabali-sockets)The only thing that performs privileged host ops.
jabali-webmail.serviceNode (Next.js standalone)jabaliunix /run/jabali-webmail.sockWebmail SPA + SSO drop-in + autoconfig bridge.
kratos.serviceGo (Ory)kratosunix /run/kratos-public.sock, /run/kratos-admin.sockIdentity (login, 2FA, recovery). Sockets only.
nginx.serviceCwww-data:80, :443 (+ admin pool IPs)Reverse-proxy panel + serves every vhost.
php<ver>-fpm.serviceCroot master, per-user pool workersunix /run/php/jabali-<user>/fpm.sockOne systemd unit per PHP version; one pool socket per panel user.
mariadb.serviceCmysqlunix /run/mysqld/mysqld.sock (skip-networking on)Both panel DB and tenant DBs.
postgresql.serviceCpostgreslocal socketTenant DBs only.
pdns.serviceC++pdnspublic IPs :53Authoritative DNS for hosted zones, MariaDB backend.
pdns-recursor.serviceC++pdns-recursor127.0.0.1:53Local recursive resolver.
stalwart-mail.serviceRuststalwart:25, :465, :587, :993, :995, admin HTTP 127.0.0.1:8080SMTP MTA + submission + IMAP + JMAP + mailbox store.
redis.serviceCredisunix /run/redis/redis.sockNotifications dispatcher stream, panel cache.
crowdsec.service + bouncersGocrowdsecunix socketsIP-trust source + AppSec WAF.
tetragon.serviceGo + eBPFrootunix socketKernel-level tripwires for malware detection.
aide.timershellroot,Daily host-integrity scan.

Data model

  • Panel DB (MariaDB): single DB, ~150 tables. Every domain, user, mailbox, DNS record, audit row, backup job, etc. is here.
  • Stalwart store: mailbox blobs + metadata; opaque to the panel.
  • Kratos store: identity DB; opaque to the panel except for the user FK.
  • PowerDNS DB: zones + records. The agent syncs from the panel DB (DB-as-truth).

Reconciler

The single in-process loop inside jabali-panel. Wakes on:

  • A 60 s timer.
  • Any panel-side write that schedules itself (Reconciler.Schedule(<domain-id>)).

What it does (per tick):

  1. Diffs DB intent vs. host state for: domains (vhosts, SSL, DNSSEC, listen IPs), per-user resource limits, mail accounts, DNS zone files, cron timers, backup destinations + schedules, PHP pool files, SSH key files, CrowdSec allowlists, IP pool, panel-cert state.
  2. For each diff, calls the agent over UDS to converge.
  3. Records the result in the audit log if it was a real change.

Idempotency rule: every converger compares before/after and skips side-effects on no-change. Tracked by the “per-tick idempotent loops” audit checklist.

Agent contract

jabali-agent accepts a small set of typed JSON RPCs over UDS. Each handler is a single Go file under panel-agent/internal/commands/. Examples:

domain.create       { user_id, domain, has_php, php_pool_id, cache_enabled, listen_ip, … }
ssl.issue           { domain }
ssl.panel.issue     {}
mail.mailbox.create { domain, local_part, password_hash, quota_mib }
db.config.apply     { engine, keyspace }
nginx.reload        {}
nginx.cache.purge   { domain }
pdns.dnssec.enable  { domain }
backup.schedule.run { schedule_id }

Wire-contract drift is caught by golden tests (mirror security_crowdsec_geoblock_golden_test.go): JSON tags on domainCreateParams must match the panel side. The “verify wire contract against handler” rule is mandatory whenever a new field is added.

Why this shape

  • DB-as-truth prevents host-edit drift; restart-safe.
  • Reconciler-converged means anything you change in the DB shows up on host within ≤60 s; restart of the agent doesn’t lose state.
  • Single agent over UDS keeps privilege boundary one process; no setuid binaries, no sudoers.d expansion.
  • Sockets only for everything that can be a socket (Kratos public/admin, MariaDB, Stalwart admin) removes TCP attack surface.

ADRs covering the major decisions live under docs/adr/.