Architecture¶
This page describes how Bor is put together for people who work on its code: the two binaries, the two server ports and why they are split, the trust model between server and agents, the data model behind policies and RBAC, and the main runtime flows (enrollment, policy streaming and certificate renewal). Details of each side live in Server internals, Agent internals, Protocol and code generation and Database and migrations.
Components¶
Bor has two deliverables, both written in Go and kept in one repository:
| Component | Source | Binary | Runs as |
|---|---|---|---|
| Server | server/ (entry point server/cmd/server, wiring in server/pkg/app) |
bor-server |
unprivileged user bor |
| Agent | agent/ (entry point agent/cmd/agent) |
bor-agent |
root (systemd unit bor-agent.service) |
| Web UI | server/web/frontend (PatternFly 6, React, TypeScript) |
embedded into the server binary | browser |
| Protocol | proto/ |
generated Go in server/pkg/grpc/*, TypeScript in server/web/frontend/src/generated/proto |
- |
The agent module depends on the server module for the generated protobuf
code (github.com/VuteTech/Bor/server/pkg/grpc/...) through a replace
directive in agent/go.mod (github.com/VuteTech/Bor/server => ../server),
so both modules must always build from the same checkout.
PostgreSQL is the only external dependency of the server. All persistent state lives there: policies, bindings, nodes, users, RBAC, audit log, compliance results, enrollment tokens and the policy hub revision counter.
flowchart LR
browser(["Browser<br/>(PatternFly UI)"])
subgraph server["bor-server (server/pkg/app)"]
web["<b>Web port</b><br/>enrollment_port, default 8443<br/>VerifyClientCertIfGiven"]
router{"Content-Type<br/>application/grpc*<br/>and HTTP/2?"}
rest["REST mux<br/>SecurityHeaders, CSRF,<br/>Auth, PermissionGate, Audit"]
enroll["EnrollmentService<br/>(gRPC)"]
pol["<b>Policy port</b><br/>policy_port, default 8444<br/>RequireAndVerifyClientCert, TLS 1.3"]
polsvc["PolicyService<br/>(gRPC)"]
hub["PolicyHub<br/>revision + event log"]
svc["internal/services"]
repo["internal/database<br/>repositories"]
ca["internal/pki<br/>CA"]
audit["Audit sinks<br/>DB, syslog CEF/OCSF"]
metrics["/metrics<br/>127.0.0.1:9090"]
end
db[("PostgreSQL")]
agent["bor-agent<br/>(root daemon)"]
browser -- "HTTPS + cookies" --> web
agent -- "Enroll, KerberosEnroll" --> web
web --> router
router -- "yes" --> enroll
router -- "no" --> rest
rest --> svc
enroll --> ca
enroll --> svc
agent <== "SubscribePolicyUpdates,<br/>ReportCompliance, RenewCertificate, ..." ==> pol
pol --> polsvc
polsvc --> hub
polsvc --> svc
rest -- "policy and binding changes" --> hub
svc --> repo
svc --> audit
repo --> db
audit --> db
The dual-port design¶
The server opens two TLS listeners (plus an optional metrics listener). The split keeps browsers and unenrolled agents away from the port that serves policy, and lets each port use the TLS settings that fit its clients.
Web port (server.enrollment_port, default 8443)¶
One http.Server serves the web UI, the REST API, the agent package
repository under /agent/ and the enrollment gRPC service. The handler in
server/pkg/app/app.go routes each request by protocol and content type:
uiGrpcRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
enrollGrpcSrv.ServeHTTP(w, r)
} else {
// Security headers + CSRF applied to HTTP only, not gRPC.
api.SecurityHeadersMiddleware(
api.CSRFMiddleware(mux),
).ServeHTTP(w, r)
}
})
TLS settings on this port:
ClientAuth: tls.VerifyClientCertIfGiven. Browsers and enrolling agents have no client certificate, so the handshake must succeed without one; if a certificate is presented it must chain to the internal CA.- TLS 1.2 minimum, ECDHE with AES-GCM suites only for TLS 1.2, curve preference X25519, P-256, P-384.
- The certificate comes either from a static file / the auto-generated
server certificate, or from ACME (
pki.NewACMEManager) when ACME is enabled.
On the gRPC side, RequireClientCertInterceptor (in
server/internal/grpc/interceptor.go) still requires a verified client
certificate for every enrollment RPC except the two bootstrap calls
/bor.enrollment.v1.EnrollmentService/Enroll and
/bor.enrollment.v1.EnrollmentService/KerberosEnroll.
Policy port (server.policy_port, default 8444)¶
A second http.Server serves only the bor.policy.v1.PolicyService gRPC
server. It is agent-only:
ClientAuth: tls.RequireAndVerifyClientCertagainst the internal CA pool.- TLS 1.3 minimum, ALPN
h2only. - Always presents the internal-CA-signed server certificate, even when ACME
is active on the web port, so agents can verify it with the
ca.crtthey received at enrollment. - Unary and stream interceptors check the client certificate serial against
the
revoked_certificatestable on every call.
Both gRPC servers share grpcHardeningOptions(): at most 256 concurrent
streams, 8 MiB maximum message size, keepalive enforcement (minimum ping
interval 10 s) and a one-hour server keepalive.
Metrics listener¶
metrics.NewServer serves Prometheus /metrics on metrics.listen_addr
(default 127.0.0.1:9090), plain HTTP unless a TLS certificate and key are
configured, optionally behind a bearer token. See
Server internals.
Trust model¶
| Actor | Authenticates with | Identity used by the server |
|---|---|---|
| Browser user | Password (local or LDAP), optional TOTP or WebAuthn; then a JWT in the bor_session httpOnly cookie |
user ID in the JWT claims |
| REST API client | Authorization: Bearer <JWT> |
user ID in the JWT claims |
| Enrolling agent | One-time enrollment token, or a Kerberos SPNEGO token from the machine keytab | node group bound to the token (or the configured Kerberos default group) |
| Enrolled agent | mTLS client certificate signed by the internal CA | certificate Common Name = node name |
Key properties:
- The internal CA is the root of agent trust. It is created on first
start (
pki.EnsureCA) or loaded from files or a PKCS#11 token. Agent certificates are ECDSA P-256, valid for 90 days. - The certificate CN is authoritative.
SignCSRoverwrites whatever CN the CSR carries with the server-assigned node name. Every policy RPC callsrequireClientIdentity, which rejects requests whoseclient_idfield does not match the CN. - Enrollment tokens are single use and expire after 5 minutes
(
enrollmentTokenTTLinserver/internal/services/enrollment.go). Only their SHA-256 hash is stored. - Revocation is enforced by the server, not by CRLs: deleting a node or
revoking its certificate adds the serial to
revoked_certificates, and the interceptors refuse that serial on both ports. - The agent trusts the server at enrollment time through the system
trust store (or not at all if
server.insecure_skip_verifyis set, which is meant for test setups only). After enrollment it pins theca.crtreturned by the server and never skips verification.
Data model¶
The schema is created by the migrations in
server/internal/database/migrations. The tables most code touches:
| Table | Purpose |
|---|---|
policies |
Policy artifacts: name, type (for example Firefox, Dconf, Firewalld), content (JSON), version, state, deprecation fields |
node_groups |
Logical groups of nodes; the unit that policies are bound to |
nodes |
Enrolled agents: name (unique, equals certificate CN), status, metadata from heartbeats, certificate serial and expiry |
node_group_members |
Many-to-many membership of nodes in node groups |
policy_bindings |
Policy to node group, with state (enabled / disabled) and priority |
users, user_groups, user_group_members |
Local and directory users, and user groups |
roles, permissions, role_permissions |
RBAC roles and their resource:action permissions |
user_role_bindings, user_group_role_bindings |
Role assignments to users and to user groups, global or (where the edition allows) node-group scoped |
compliance_results |
Latest compliance status per node and policy, with per-item JSON |
audit_logs |
Audit trail written by the database audit sink |
enrollment_tokens |
Hashes of pending enrollment tokens |
revoked_certificates |
Revoked agent certificate serials |
policy_hub_state |
Single row holding the policy hub revision high-water mark |
Policy lifecycle¶
A policy has an artifact state (server/internal/models/models.go):
draft, released, archived. Transitions are validated in
validateStateTransition (server/internal/services/policy.go):
draft->released(content is validated for the policy type first)released->archivedreleasedorarchived->draft(refused while enabled bindings exist)
A policy can additionally be marked deprecated, with a message and an optional replacement policy.
Enforcement is controlled separately by the binding. A new binding always
starts disabled. An agent receives a policy only when the policy is
released and at least one enabled binding links it to a node group
the node is a member of (PolicyService.ListPoliciesForNodeGroups). When
several bindings apply, the policy is delivered with the highest binding
priority (Policy.priority in the protocol), which the agent uses to order
merges.
RBAC¶
Permissions are resource:action pairs (for example policy:view,
binding:create). Roles group permissions; role bindings give a role to a
user or to a user group. A binding has a scope: global, or one node group.
In the community edition node-group-scoped bindings grant nothing
(edition.NodeGroupScopedRBAC is off); see Server internals.
Main flows¶
Enrollment¶
sequenceDiagram
autonumber
actor Admin
participant UI as Web UI / REST
participant ES as EnrollmentService<br/>(web port)
participant DB as PostgreSQL
participant Agent as bor-agent
Admin->>UI: POST /api/v1/node-groups/{id}/tokens
UI->>DB: store SHA-256(token), node_group_id, expires_at (now + 5 min)
UI-->>Admin: plaintext token (shown once)
Admin->>Agent: bor-agent --token-file FILE
Agent->>Agent: generate ECDSA P-256 key + CSR
Agent->>ES: Enroll(enrollment_token, csr_pem, node_name)
ES->>DB: DELETE ... RETURNING (consume token, check expiry)
ES->>ES: SignCSR (CN forced to node_name, 90-day cert)
ES->>DB: create node + group membership, store cert serial
ES-->>Agent: node_id, signed_cert_pem, ca_cert_pem, assigned_node_group
Agent->>Agent: write agent.key, agent.crt, ca.crt to data_dir
Agent->>Agent: print next steps and exit
- An administrator generates a token for a node group
(
POST /api/v1/node-groups/{id}/tokens). The server stores the hash and returns the token once. - On the node,
bor-agent --token-file FILE(orBOR_ENROLLMENT_TOKEN) generates an ECDSA P-256 key and a CSR, connects to the web port and callsEnroll. - The server consumes the token, signs the CSR with the internal CA (CN set to the node name), creates the node record and its group membership, and stores the certificate serial and expiry.
- The agent writes
agent.key,agent.crtandca.crttoenrollment.data_dir(default/var/lib/bor/agent), prints the next steps and exits. The systemd service is started separately.
Domain-joined hosts can use KerberosEnroll instead: the agent sends a
SPNEGO token built from /etc/krb5.keytab, and the server places the node
in the configured default node group. If Kerberos enrollment fails, the
agent falls back to token enrollment.
Policy streaming¶
sequenceDiagram
participant Agent as bor-agent
participant PS as PolicyServer<br/>(policy port, mTLS)
participant Hub as PolicyHub
participant DB as PostgreSQL
actor Admin
Agent->>PS: SubscribePolicyUpdates(last_known_revision)
PS->>PS: identity = verified certificate CN
alt revision 0 or ahead of server
PS->>DB: released policies with enabled bindings for node groups
PS-->>Agent: SNAPSHOT x N (last one snapshot_complete)
else behind and delta available, no resync events
Note over PS,Hub: not reached today - only resync events are published
PS->>Hub: EventsSince(revision)
PS-->>Agent: CREATED / UPDATED / DELETED events
else behind, delta compacted or contains a resync
PS-->>Agent: full SNAPSHOT
end
PS->>DB: node status = online
Note over PS,Hub: watch mode, Subscribe(clientID)
Admin->>Hub: policy or binding change -> PublishResync(groupIDs)
Hub->>DB: SaveRevision (high-water mark)
Hub-->>PS: resync signal
PS-->>Agent: full SNAPSHOT if node groups overlap
Agent->>PS: ReportCompliance per policy
Note over Agent,PS: stream breaks -> node offline
Agent->>Agent: wait backoff (1s, 2s, 4s ... max 60s)
Agent->>PS: SubscribePolicyUpdates(last revision seen)
The agent keeps one server-streaming RPC, SubscribePolicyUpdates, open to
the policy port:
- The agent sends
last_known_revision(0 after a restart, since the agent keeps it in memory only). - The server decides the initial sync in
PolicyServer.SubscribePolicyUpdates(server/internal/grpc/server.go):- revision 0, or higher than the server's: full snapshot;
- lower than the server's:
PolicyHub.EventsSince; if the events were compacted away, or any of them is a resync signal, a full snapshot is sent instead; otherwise the events are replayed; - equal: nothing to send.
- A snapshot is one
SNAPSHOTmessage per policy, the last one carryingsnapshot_complete = true. An empty snapshot is a single message with no policy andsnapshot_complete = true. - The node is marked
online, and the stream enters watch mode on a hub subscription. It is markedofflinewhen the stream ends. - When an administrator changes a policy (update, state change,
deprecation) or a binding (state or priority change, deletion of an
enabled binding), the REST handler calls
PolicyHub.PublishResyncwith the affected node group IDs. Every stream whose node belongs to one of those groups sends a fresh full snapshot. The agent compares the new snapshot with its cache and re-applies what changed. - After applying, the agent reports compliance per policy with
ReportCompliance. - If the stream breaks, the agent reconnects with exponential backoff (1 s doubling, capped at 60 s) and sends the last revision it saw.
Snapshots are the common case
The protocol defines CREATED, UPDATED and DELETED delta events,
and the agent handles them, but the server's REST handlers currently
publish only resync signals. In practice every change reaches agents as
a scoped full snapshot.
Group membership changes
The streaming handler reads the node's group memberships when the stream opens. Adding a node to, or removing it from, a node group does not publish a resync, so the new policy set reaches the agent on its next reconnect.
The server also pushes two targeted, non-policy commands over the same
stream: METADATA_REQUEST (the agent answers with a Heartbeat) and
DISK_ENCRYPTION_TASK (the agent fetches its pending disk-encryption
tasks). These do not change the agent's last known revision.
Certificate renewal¶
sequenceDiagram
participant Agent as bor-agent
participant PS as PolicyServer<br/>(policy port, mTLS)
participant DB as PostgreSQL
Note over Agent: at daemon start-up only
Agent->>Agent: CertExpiringSoon(agent.crt, 30 days)?
alt expires within 30 days
Agent->>Agent: new ECDSA P-256 key, CSR with same CN
Agent->>PS: RenewCertificate(csr_pem) using current cert
PS->>PS: interceptor - verified chain, serial not revoked
PS->>DB: look up node by certificate CN
PS->>PS: SignCSR (CN = node name, 90 days)
PS->>DB: update node cert serial and expiry
PS-->>Agent: signed_cert_pem
Agent->>Agent: write agent.key, then agent.crt
end
Agent->>PS: connect policy stream with current credentials
At start-up, after the enrollment check, the agent calls
policyclient.CertExpiringSoon with a 30-day threshold. If the certificate
is close to expiry it generates a new key and CSR with the same CN and calls
RenewCertificate on the policy port, authenticated with the current
certificate. The server looks up the node by the verified CN, signs a new
90-day certificate and updates the node's certificate serial and expiry.
The agent then writes the new key and certificate and connects as usual.
The check runs once per agent start, so a daemon that is never restarted for longer than the certificate lifetime does not renew. Any restart of the service (for example a reboot) runs the check again.
Compliance and tamper reports¶
Besides the stream, the agent makes unary calls on the policy port:
ReportComplianceafter each sync, one call per policy, with a four-state status and optional per-item results. Stored incompliance_results.Heartbeaton every connect and onMETADATA_REQUEST, carrying OS, desktop environments, agent version and machine ID.ReportTamperEventwhen the file watcher restores a modified managed file, with the processes that had the file open.ReportSchemaCatalogueandReportPolkitCatalogueonce, with the GSettings schemas and polkit actions installed on the node, which feed the dconf and polkit editors in the UI.- The disk-encryption RPCs (
ReportDiskEncryptionState,GetDiskEncryptionTasks,EscrowRecoveryKey,ConfirmRecoveryKey,BeginRecoveryKeyRotation).