Architecture
This page explains how the pieces fit and why. It is the reference for anyone changing cross-component behaviour: authentication, tenancy, agent connectivity, deployments.
Components
| Component | Code | Runtime | Responsibility |
|---|---|---|---|
| Entrosity Hub | entrosity-hub.backend, entrosity-hub.frontend | Go service platform-server with its own platform database, and a browser SPA | Sign-in, users, organizations and roles for every Entrosity product (Entrosity Hub) |
| Portal | entrosity-axis.frontend/ | Browser SPA: React, TanStack Router and Query, Tailwind, shadcn/ui, served at /axis on the Hub's host | UI for global admins and tenant users |
| Backend | entrosity-axis.backend/ | One Go binary (rmm-server), stateless, N replicas behind Caddy | REST API, token verification and RBAC, WebSocket hub for agents and connectors, SSE for the portal, background workers (river) |
| PostgreSQL 18 | entrosity-axis.backend/db/ | Container or managed | System of record and job queue (river tables) |
| Object storage | – | MinIO / S3 | Package files, agent and connector MSIs, large script output |
| Agent | entrosity-axis-agent/ | Go Windows service RMMAgent, runs as SYSTEM | Inventory, heartbeats, executes jobs, self-update |
| Site connector | entrosity-axis-connector/ | Go Windows service RMMConnector, one per site | LDAP sync of AD computers, LDAP test, agent push (WinRM/SMB), wake-on-LAN |
| Shared | entrosity-shared-go/ | Go module | Wire types (entrosity-shared-go/proto), version, and agentkit (transport, executor, secret store, logging, service wrapper, self-update) shared by agent and connector; server building blocks without database access shared by the backend and Entrosity Hub (apperr, reqctx, secretbox, authkit, mailkit) |
Network model
- Agents and connectors only make outbound HTTPS connections to
RMM_PUBLIC_URL. They open a WebSocket and keep it alive; the backend pushes jobs down that socket. - The portal talks REST to the backend and subscribes to one SSE stream per tenant for live updates.
- The backend never connects to customer networks. All LDAP and WinRM/SMB traffic starts at the site connector inside the customer network.
Backend internals
Request pipeline (portal API)
chi router
└─ RequestID → RealIP (trusted proxies only) → ClientInfo → Logger → Recoverer → CORS → Timeout
└─ /api: NoStore → MaxBody (1 MB) → RateLimit (per IP)
└─ generated router (openapi.yaml) → per route:
└─ Authorize (the route's rule in portal/access.go):
public │ RequireAuth (Hub product token → user/tenant/session state from DB, cached 30 s → Principal)
│ → GlobalAdmin? → TenantScope ({tenantID}; 404 on mismatch) → rbac.Can(perm)
└─ OpenAPI request validation (422 with field errors)
└─ strict handler → service → sqlc (tx) → audit.Record in the same tx
Every route must have an access rule; a route without one is refused at
runtime and fails TestEveryRouteHasAccessRule.
Package layout
internal/http/*: transport only (decode, validate, call a service, encode). No SQL.internal/<domain>: services with the business rules (platformauth,platformsync,tenant,device,inventory,adsync,deploy,scripts,alerts,releases,metrics,retention,mail, …).internal/db: pgx pool, sqlc-generated queries,WithTx.internal/jobs: river workers.internal/realtime: the WebSocket hub and SSE broadcast.internal/app: wires everything together (also used by integration tests).
Background jobs (river)
| Job | When | Does |
|---|---|---|
deployment.start | On create / schedule | Materialises targets. |
deployment.tick | On agent hello with pending targets and after target results (coalesced per second) | One scheduler pass for one deployment. |
deployment.tick_all | Every 30 s | Scheduler pass for all running deployments. |
adsync.schedule | Every minute | Starts due AD syncs. |
adsync.reconcile | After a run | Matches AD computers with devices. |
alerts.evaluate | Every minute | Evaluates all rules, sends notifications and digests. |
metrics.partitions | Daily and at start | Creates monthly device_metrics partitions two months ahead. |
releases.rollout | Every 5 minutes | Makes update offers. |
retention.cleanup | Daily | Deletes old data. |
realtime.sweep | Every 30 s | Marks silent devices offline, times out expired jobs. |
winget.refresh | Checked every 6 h | Refreshes the winget index about daily. |
email.send | On demand | Sends one queued e-mail. |
Periodic jobs run on the river leader only.
Realtime
realtime.Hub holds the WebSocket of each agent and connector connected
to this replica. Send(ctx, agentID, msg) returns ErrNotConnected when
the device is offline. Because the hub is replica-local, a job for an
agent held by another replica is dispatched through PostgreSQL
LISTEN/NOTIFY on channel agent_dispatch. Tenant events (device status,
deployment progress, script output, alerts) are broadcast to the tenant's
SSE subscribers.
Authentication
- Users sign in on Entrosity Hub; Axis has no passwords, sessions or
sign-in endpoints. The Hub hands Axis's SPA (same host, under
/axis) five-minute EdDSA product tokens (audiencermm,sub= user,sid= Hub session) from its session cookie (Hub API). - Axis verifies them with the Hub's public keys (JWKS from the internal
listener
RMM_PLATFORM_INTERNAL_URL, cached), and does not take roles from the token:RequireAuthloads the user, their tenants, roles and the session's state from Axis's copy of the Hub's data (cached 30 s per replica). - Each replica pulls the access snapshot (users, organizations with Axis,
roles, sessions ended in the last 15 minutes) every
RMM_PLATFORM_SYNC_INTERVALwith an ETag, and at once (at most every 5 s) for a token of a user it does not know yet. A snapshot is applied in one transaction under an advisory lock: users and tenants upserted (tenant settings stay Axis's), memberships replaced (alert preferences kept), missing users marked deleted and missing organizations suspended (agents and remote sessions of those tenants are cut off); nothing is hard-deleted. Sign-out, disabling a user, demotion and suspension reach every replica within the sync interval plus the 30 s cache. - The SPA renews product tokens before they expire and serialises that across tabs of Axis and the Hub with the Web Locks API; a sign-out in one tab reaches the others over a broadcast channel.
- Deleting an enrollment token needs a step-up token: the Hub issues
it for the user's password (two minutes, bound to user and session);
Axis accepts each once across replicas (
platform_step_ups_used). - The event stream authenticates with a 60-second stream token that
Axis signs itself (
POST /auth/sse-token, HS256 withRMM_JWT_SECRET, audiencermm-events, bound to one tenant and the session) passed as?sse_token=, so product tokens never appear in URLs. - Agents and connectors authenticate with a per-installation 32-byte key
issued at enrollment (
Authorization: Bearer <key>), stored as a SHA-256 hash and cached 60 s.
Device lifecycle and matching
See Concepts → Devices for the
states. When an agent enrolls or an AD computer is synced, the server
matches an existing device by machine SID = objectSid, then FQDN =
dNSHostName, then hostname = name and the same domain; otherwise it
creates a new device. Merging handles the rest.
Other subsystems
- Jobs and deployments: Jobs and deployments.
- Tenancy and row-level security: Multi-tenancy.
- Scripts: versioned per library entry; a run resolves targets like a
deployment, validates the parameters against the script's schema and
creates one
script_run+run_scriptjob per device. Output streams asjob.progresschunks, is appended at its byte offset (duplicates and replays ignored) and republished as SSEscript_run.output. - Metrics: heartbeats feed an in-process batch ingester (flush every
second or 1,000 samples, one
unnestinsert). Reads usedate_binbuckets (at most 300 points per range). - Alerts:
alerts.evaluateruns each rule type as one set-based query over all tenants the rule applies to, then opens, refreshes and resolves alerts. Notifications go out once per pass, immediately or as a 15-minute digest. - AD sync: the backend owns the schedule; the connector is stateless
between runs. Computers stream in chunks; a run is only committed (and
vanished computers marked) when the final
complete=truechunk arrives, so a crashed connector never marks computers as gone. - Self-update: releases are signed with Ed25519 at publish and verified by the device (Agent releases).
Scaling notes
- Replicas are stateless; no sticky sessions (Scaling).
- About 45 KB of backend memory per connection.
- Heartbeats are batched into one update per second; inventory sections are fingerprinted so unchanged ones are skipped; ingestion concurrency is capped at a quarter of the pool.
- Metrics are served on the internal listener
RMM_METRICS_ADDRonly.
Technology
| Area | Choices |
|---|---|
| Backend | Go 1.26, chi, pgx, sqlc, oapi-codegen (strict server), river, golang-migrate, koanf, slog |
| Database | PostgreSQL 18 with citext, pg_trgm, row-level security |
| Frontend | Vite, React, TypeScript (strict), TanStack Router (file-based) and Query, react-hook-form + zod, Tailwind, shadcn/ui (shared in entrosity-ui) |
| Agent / connector | Go (GOOS=windows), WiX v4 MSIs, DPAPI, Windows service, scheduled-task updater |
| Edge | Caddy (custom build in deploy/caddy) |