Server internals¶
This page walks through the Bor server code: what happens at start-up, how the packages are divided, the conventions every REST handler follows, how authorization is evaluated, how the policy hub fans changes out to agents, and where metrics and audit events go. For the bigger picture see Architecture.
Entry point and start-up sequence¶
server/cmd/server/main.go is deliberately small. It parses the
--reset-mfa <username> flag (handled by app.ResetMFA) and otherwise
calls:
app.Run in server/pkg/app/app.go does all the wiring, in this order:
- Edition.
opts.Edition(defaultedition.Community{}) decides optional features.nodeGroupScopesis a function evaluated per request, so an edition can switch features at run time. - Configuration.
config.Load()reads the YAML file named byBOR_CONFIG(default/etc/bor/server.yaml, a missing file is ignored) and lets environment variables override each value (server/internal/config/config.go). - Internal CA. Either from a PKCS#11 HSM (
pki.EnsureCAWithHSM), fromBOR_CA_CERT_FILE/BOR_CA_KEY_FILE, or auto-generated into the autogen directory (pki.EnsureCA). The CA is created first because it signs the server certificate. - Server TLS certificate. If no certificate files are configured,
pki.EnsureServerCertcreates one signed by the internal CA for the configured hostnames. - Database.
database.Newopens the pool (25 connections maximum) and pings PostgreSQL;db.RunMigrations()applies pending migrations. Any failure is fatal. See Database and migrations. - Repositories. One repository per table group
(
database.NewPolicyRepository(db)and so on). Two seeders start in the background: the built-in GSettings schemas (server/assets/dconf_builtin_schemas.json) and polkit actions (server/assets/polkit_builtin_actions.json). - Identity services. LDAP (when enabled), MFA (TOTP), WebAuthn (when
BOR_WEBAUTHN_RPIDis set) andAuthService. - Domain services. Policy, node, node group, user group, policy binding, enrollment, audit and settings services.
- Audit sinks. The database sink is always registered; the syslog sink
when syslog forwarding is enabled. If a retention period is set
(
BOR_AUDIT_RETENTION_DAYS), a goroutine purges old audit rows once at start-up and then every 24 hours. - Authorizer.
authz.New(...). - Default administrator.
EnsureDefaultAdmincreates theadminuser when the users table is empty, usingBOR_ADMIN_PASSWORDor a generated password written to/var/lib/bor/initial_admin_password(mode 0600). - Policy hub.
grpcserver.NewPersistentPolicyHubloads the revision frompolicy_hub_state. - REST handlers and hooks. Handlers are created and the policy and
binding handlers get
OnPolicyChange/OnBindingChangecallbacks that publish resync signals to the hub. - Feature services. Agent package repository (
/agent/), Flatpak catalog manager (started immediately), recovery-key escrow (when a KEK file is configured), Tang servers and the disk-encryption service, whose janitor runs two minutes after start and then hourly. - HTTP routes. Public, authenticated and permission-gated routes are
registered on one
http.ServeMux; extension modules mount theirs viaexthost.Initunder/api/v1/ext/<module>/; the embedded frontend is served on/. - Certificates for the web port. Static, or ACME with an HTTP-01 challenge listener and TLS-ALPN-01.
- Kerberos.
KerberosServicewhen Kerberos enrollment is enabled. - gRPC servers. An enrollment server and a policy server, each with client-certificate and revocation interceptors.
- Listeners. The web port, the policy port and the metrics listener start in goroutines.
- Shutdown. On
SIGINTorSIGTERM: stop the Flatpak manager and the disk-encryption janitor, gracefully stop both gRPC servers, then shut down the HTTP servers with a 10-second timeout.
Any error before the listeners start ends the process with log.Fatalf.
Package layout¶
| Package | Responsibility |
|---|---|
server/cmd/server |
Community entry point |
server/cmd/protoc-gen-bor-ui |
protoc plugin for UI metadata, see Protocol |
server/pkg/app |
Start-up and wiring (Run, ResetMFA), plus dconf and polkit seeders |
server/pkg/edition |
Edition interface and feature switches; edition.Community enables none |
server/pkg/ext |
Extension API for optional modules (login methods, routes, storage) |
server/pkg/grpc/* |
Generated protobuf and gRPC code |
server/internal/config |
Configuration from YAML and environment |
server/internal/api |
REST handlers and HTTP middleware |
server/internal/services |
Business logic, one service per domain |
server/internal/database |
PostgreSQL repositories and migrations |
server/internal/models |
Data models and request / response DTOs |
server/internal/grpc |
PolicyServer, EnrollmentServer, interceptors, PolicyHub |
server/internal/authz |
Grant computation and scopes |
server/internal/pki |
Internal CA, CSR signing, ACME, PKCS#11 |
server/internal/audit |
Audit sink interface, database and syslog sinks, CEF and OCSF formatters |
server/internal/metrics |
Prometheus collector and metrics server |
server/internal/escrow |
Envelope encryption of LUKS recovery keys under a KEK |
server/internal/export |
Policy and binding export / import |
server/internal/exthost |
Host side of pkg/ext: mounts modules, login method registry |
server/internal/flatpakcatalog |
Flatpak remote and AppStream catalog fetching |
server/internal/tang |
Tang server client |
server/web |
web.StaticFiles, the embedded frontend build |
The layering is strict: api and grpc call services, services call
database repositories, and nothing below api knows about HTTP.
REST conventions¶
All REST endpoints live under /api/v1/ on the web port.
Middleware chain¶
Every HTTP (non-gRPC) request passes through, outermost first:
SecurityHeadersMiddleware: Content-Security-Policy,X-Frame-Options: DENY,X-Content-Type-Options: nosniff, HSTS,Referrer-Policy,Permissions-Policy.CSRFMiddleware: double-submit check onPOST,PUT,PATCHandDELETEunder/api/. TheX-CSRF-Tokenheader must equal thebor_csrfcookie. Public login endpoints and prefixes registered withapi.ExemptFromCSRFare exempt.- Per route:
AuthMiddleware, then a permission gate, then (for state-changing routes)AuditMiddleware, then the handler.
A typical protected route:
policyPerms := gate.RequireScopedMethod([]api.MethodPermission{
{Method: http.MethodGet, Resource: "policy", Action: "view"},
{Method: http.MethodPost, Resource: "policy", Action: "create"},
{Method: http.MethodPut, Resource: "policy", Action: "edit"},
{Method: http.MethodDelete, Resource: "policy", Action: "delete"},
})
mux.Handle("/api/v1/policies/all/", authMiddleware(policyPerms(auditMw(http.HandlerFunc(policyHandler.ServeHTTP)))))
Routing is deny-by-default: a route without a gate is reachable only if it
is registered as public (version, public config, login steps, logout,
refresh, agent package downloads) or is a self-service /api/v1/users/me/...
or /api/v1/auth/me route that needs only authentication. Login endpoints
are rate limited to 10 requests per minute per client IP.
Sessions¶
AuthMiddlewaretakes the JWT from thebor_sessionhttpOnly cookie, or fromAuthorization: Bearerfor API clients, validates it and stores the claims in the request context (api.GetUserFromContext).- JWTs are HS256 signed with
JWT_SECRET. Access tokens default to 1 hour (BOR_JWT_LIFETIME), refresh tokens (bor_refreshcookie) to 24 hours (BOR_REFRESH_LIFETIME). - On login the server also sets
bor_csrf, a non-httpOnly cookie (Secure,SameSite=Strict) that the frontend copies into theX-CSRF-Tokenheader.
Errors¶
Errors are JSON objects with one error key and an appropriate status:
Use http.Error(w,, status) for constant messages or
writeJSONError (in permission_gate.go) for formatted ones. Do not leak
internal errors (database messages, file paths) to clients; log them with
log.Printf and return a generic message. Successful responses are JSON
with Content-Type: application/json.
RBAC¶
Authorization is implemented in server/internal/authz/authz.go and
enforced by PermissionGate in server/internal/api/permission_gate.go.
- A permission is
resource:action(authz.PermKey). Roles and their permissions come from theroles,permissionsandrole_permissionstables; the built-in roles are seeded by migrations. ComputeGrantscollects the user's effective role bindings (direct bindings plus bindings of user groups the user is a member of) and producesGrants: for each permission, aScopethat is global, a set of node groups, or empty.- Node-group-scoped bindings grant only scopable resources (
node,node_group,policy,binding,compliance), and only when the edition enablesedition.NodeGroupScopedRBAC. In the community edition they grant nothing; only global bindings count. Unknown scope types fail closed. gate.Require/gate.RequireMethodadmit only global grants. This is the default for every route.gate.RequireScoped/gate.RequireScopedMethodalso admit scoped grants, and may only wrap handlers that check scope per object (they callrequestScope(r, resource, action).Allows(groupID)).- The gate stores the grants in the request context so handlers do not hit
the database again. Denied state-changing requests are audited as
access_deniedevents.
Server-side guards protect against privilege escalation (for example,
Grants.CanDelegate checks that a caller only hands out permissions they
hold themselves).
PolicyHub¶
server/internal/grpc/policy_hub.go is the in-process publish / subscribe
hub between REST handlers and agent streams.
- Revision counter. A monotonic
int64, incremented on every publish.NewPersistentPolicyHubseeds it from the single-rowpolicy_hub_statetable, and every publish writes the new value back withGREATEST(old, new)before fanning out. Without this, a restart would reuse revision numbers and a reconnecting agent could be served a delta that silently skips events. - Event log.
defaultEventLogSize = 1000. When the log grows past the limit, the oldest half is dropped. The log itself is in memory only, so after a restart every agent that is behind gets a full snapshot. - Delta sync.
EventsSince(rev)returns the events afterrev, ornilifrevis older than the oldest retained event (compacted). The stream handler falls back to a snapshot in that case, and also when the delta contains a resync signal. - Broadcast.
Subscribe(ctx, clientID)returns a buffered channel per stream. Publishing never blocks: if a subscriber's channel is full, the event is dropped for that subscriber and logged. - Resync signals.
PublishResync(groupIDs...)publishes aSNAPSHOTevent without a policy. Each stream checks whether its node's groups overlap the affected groups (empty means all) and, if so, sends a full snapshot built from the database. This is what the policy and binding REST handlers call. - Targeted commands.
SendMetadataRefreshRequest(clientID)andSendDiskEncryptionTask(clientID)send aMETADATA_REQUESTorDISK_ENCRYPTION_TASKto one connected agent without bumping the revision.
The hub is per process. Running several server instances behind a load balancer would need a shared bus; today each instance only notifies the agents connected to it.
Metrics¶
server/internal/metrics exposes Prometheus metrics on
metrics.listen_addr (BOR_METRICS_ADDR, default 127.0.0.1:9090) at
/metrics, in OpenMetrics format, with optional bearer token
(BOR_METRICS_TOKEN) and optional TLS (BOR_METRICS_TLS_CERT_FILE,
BOR_METRICS_TLS_KEY_FILE).
BorCollector (collector.go) queries the repositories on each scrape and
exports gauges such as bor_nodes_total{status},
bor_node_last_seen_seconds, bor_node_certificate_expiry_seconds,
bor_policies_total, bor_policy_bindings_total, bor_users_total,
bor_compliance_results_total, bor_audit_events_total and the Flatpak and
LUKS families. Counters that are incremented in code
(bor_agent_package_downloads_total, bor_flatpak_catalog_refresh_total,
bor_luks_escrow_operations_total, bor_luks_recovery_key_reveals_total)
are registered on the same registry, together with the Go runtime and
process collectors.
To add a metric, prefer a prometheus.Desc in BorCollector for values
that can be read from the database, and a dedicated counter type (see
downloads.go) for events. Keep label cardinality bounded.
Audit logging pipeline¶
The canonical audit record is bor.audit.v1.AuditEvent
(proto/audit/audit.proto): time, actor, action, resource, outcome, source
IP and a typed payload (HTTP change, tamper event, ...).
REST AuditMiddleware ----\
PermissionGate denials ---+--> AuditService.Emit --> DatabaseSink --> audit_logs
gRPC handlers (tamper) ---+ \--> SyslogSink --> RFC 5424 syslog
services, extensions ----/ (CEF or OCSF body)
AuditMiddleware(server/internal/api/audit_middleware.go) records successful (2xx)POST,PUT,PATCHandDELETErequests. It reads the JSON body and replaces values whose keys look sensitive (passwords, secrets, tokens and similar) with[REDACTED]before storing it.- Source IPs are truncated (/24 for IPv4, /48 for IPv6) when
BOR_AUDIT_ANONYMIZE_IPSis set. AuditService.Emithands the event to every registered sink. Sinks must not block and must not return errors to the caller.DatabaseSinkflattens the event intomodels.AuditLogand writesaudit_logs.SyslogSink(server/internal/audit/syslog.go) queues events in a buffered channel and sends them over UDP, TCP or TLS with RFC 5424 framing. The body is CEF (FormatCEF) or OCSF JSON (FormatOCSF). If the queue is full, events are dropped with a warning, so a slow SIEM never stalls requests.- The audit log can be exported as CSV or JSON from the REST API
(
AuditService.ExportCSV,ExportJSON) and purged by the retention job.
When you add a state-changing route, wrap it in auditMw. When you add an
event that does not come from an HTTP request (for example a gRPC call),
build an AuditEvent and call auditSvc.Emit. Never put secrets, private
keys or TOTP seeds in an audit payload.