Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions agent/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,19 +466,10 @@ To follow the agent logs:
agentAddr := cfg.Server.PolicyAddr()

// ─── Certificate renewal check ────────────────────────────────────
// Renew the agent certificate if it expires within 30 days.
const renewThreshold = 30 * 24 * time.Hour
expiring, expiryErr := policyclient.CertExpiringSoon(paths.CertFile, renewThreshold)
if expiryErr != nil {
log.Printf("Warning: could not check cert expiry: %v", expiryErr)
} else if expiring {
log.Println("Certificate expires within 30 days, renewing...")
if renewErr := policyclient.RenewCertificate(agentAddr, paths.CACert, paths.CertFile, paths.KeyFile); renewErr != nil {
log.Printf("Warning: certificate renewal failed: %v - will retry next cycle", renewErr)
}
}

// Renew the agent certificate if it expires within 30 days; a running
// agent checks again periodically (watchCertExpiry below).
agentCreds = &agentCredentials{serverAddr: agentAddr, paths: paths}
agentCreds.renewIfExpiring()

// ─── Connect with mTLS credentials ────────────────────────────────
client, err := policyclient.New(
Expand Down Expand Up @@ -507,6 +498,8 @@ To follow the agent logs:
cancel()
}()

go agentCreds.watchCertExpiry(ctx)

// Start the file watcher to restore managed files if tampered externally.
var watcherErr error
fileWatcher, watcherErr = filewatcher.New(func(path string) {
Expand Down Expand Up @@ -614,6 +607,10 @@ To follow the agent logs:
log.Println("Bor Agent stopped")
}

// stableStreamDuration is how long a policy stream must stay up for its end
// to reset the reconnect backoff.
const stableStreamDuration = time.Minute

// runStreamingLoop connects to the server's SubscribePolicyUpdates
// stream and applies policies as they arrive. On stream failure it
// reconnects with exponential backoff. The last known revision is
Expand Down Expand Up @@ -766,6 +763,7 @@ func runStreamingLoop(ctx context.Context, client *policyclient.Client, cfg *con
startUpdatesInventoryTicker(ctx, client, cfg)

var postInitialSync bool
streamStart := time.Now()
err := client.SubscribePolicyUpdates(ctx, lastRevision,
func(updateType string, pi *policyclient.PolicyInfo, revision int64, snapshotComplete bool) {
// Don't let targeted server-to-agent commands overwrite the
Expand All @@ -781,6 +779,11 @@ func runStreamingLoop(ctx context.Context, client *policyclient.Client, cfg *con
return // parent context cancelled - shutting down
}

// A stream that stayed up for a while ended normally (the server
// closes connections daily); reconnect quickly.
if time.Since(streamStart) >= stableStreamDuration {
backoff = time.Second
}
log.Printf("Policy stream disconnected: %v - reconnecting in %v", err, backoff)

select {
Expand Down
53 changes: 53 additions & 0 deletions agent/cmd/agent/trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package main

import (
"context"
"log"
"sync"
"time"
Expand All @@ -17,6 +18,14 @@ import (
// renew in a loop.
const renewRetryInterval = time.Hour

// certRenewThreshold is how long before expiry the agent renews its
// certificate, and certCheckInterval how often a running agent checks.
// The server issues certificates for 90 days.
const (
certRenewThreshold = 30 * 24 * time.Hour
certCheckInterval = 12 * time.Hour
)

// agentCredentials is where the enrolled agent keeps its CA certificates and
// its own certificate, and how it reacts when the server replaces its CA.
type agentCredentials struct {
Expand Down Expand Up @@ -74,3 +83,47 @@ func (c *agentCredentials) apply(res *policyclient.HeartbeatResult) (trustChange
log.Println("Certificate renewed")
return trustChanged
}

// renewIfExpiring renews the agent certificate when it expires within
// certRenewThreshold. The renewed certificate is used from the next
// connection on; the server closes connections daily.
func (c *agentCredentials) renewIfExpiring() {
if c == nil {
return
}
expiring, err := policyclient.CertExpiringSoon(c.paths.CertFile, certRenewThreshold)
if err != nil {
log.Printf("Warning: could not check certificate expiry: %v", err)
return
}
if !expiring {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.lastRenewal.IsZero() && time.Since(c.lastRenewal) < renewRetryInterval {
return
}
c.lastRenewal = time.Now()
log.Println("Certificate expires within 30 days, renewing...")
if err := policyclient.RenewCertificate(c.serverAddr, c.paths.CACert, c.paths.CertFile, c.paths.KeyFile); err != nil {
log.Printf("Warning: certificate renewal failed: %v - will retry in %s", err, certCheckInterval)
return
}
log.Println("Certificate renewed")
}

// watchCertExpiry checks the agent certificate every certCheckInterval
// until ctx ends, so an agent that runs for months renews in time.
func (c *agentCredentials) watchCertExpiry(ctx context.Context) {
ticker := time.NewTicker(certCheckInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.renewIfExpiring()
}
}
}
46 changes: 34 additions & 12 deletions doc/configuration/kerberos.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,44 @@ directory accounts is a separate feature, see [LDAP](ldap.md).
4. The Bor server decrypts and validates the ticket with its own service
keytab. It does not contact the KDC and does not need to be joined to
the domain.
5. The server signs the certificate, creates the node in the configured
default node group and returns the certificate and CA certificate.
5. The server checks that the principal is a machine account of the
configured realm and that the requested node name belongs to it, signs
the certificate, creates the node in the configured default node group
and returns the certificate and CA certificate.

Because the server only needs the keytab, the Bor server itself can stay
outside the domain. The agent hosts must reach the KDC on port 88 (TCP and
UDP).

!!! warning "Who can enroll"
The server accepts any client whose service ticket for the Bor principal
it can decrypt with its keytab. It does not restrict enrollment to
particular hosts, groups or realms. Every account that can obtain a
ticket for the Bor service principal from your KDC can therefore enroll
a node, and all of them land in the same default node group. Keep the
default node group free of sensitive policy assignments, or restrict
ticket issuance for the service principal in the directory. Review new
nodes in **Nodes** and decommission unexpected ones.
### Who can enroll

The server accepts a ticket only when:

- it can decrypt it with its keytab,
- the client principal is in `kerberos.realm` (when set; tickets from
trusted foreign realms are refused), and
- the client principal is a machine account: `host/<fqdn>` (FreeIPA, MIT
Kerberos) or `<NAME>$` (Active Directory, Samba AD). User and service
principals cannot enroll a node.

The node name is bound to that principal, ignoring case:

| Principal | Accepted node names | Without a requested name |
|-----------|---------------------|--------------------------|
| `host/ws042.example.com@EXAMPLE.COM` | `ws042.example.com` or `ws042` | `ws042.example.com` |
| `WS042$@EXAMPLE.COM` | `ws042`, or any FQDN whose first label is `ws042` | `ws042` |

Any other name (from `agent.client_id`, or the host name when that is
unset) is refused with `node name ... does not match principal ...`. One
machine's keytab therefore cannot enroll under another machine's name.

!!! warning "Default node group"
Every machine account that can obtain a ticket for the Bor service
principal can still enroll itself, and all of them land in the same
default node group. Keep that group free of sensitive policy
assignments, or restrict ticket issuance for the service principal in
the directory. Review new nodes in **Nodes** and decommission
unexpected ones.

## Prerequisites

Expand Down Expand Up @@ -153,7 +175,7 @@ kerberos:
| YAML key | Environment variable | Description |
|----------|----------------------|-------------|
| `kerberos.enabled` | `BOR_KERBEROS_ENABLED` | Turn on the `KerberosEnroll` call. While it is off, agents get `Kerberos enrollment is not enabled on this server`. |
| `kerberos.realm` | `BOR_KERBEROS_REALM` | Realm, for reference in the startup log. Tickets are validated with the keytab. |
| `kerberos.realm` | `BOR_KERBEROS_REALM` | Realm whose machine accounts may enroll. Tickets for principals of any other realm are refused. Empty accepts any realm the keytab can validate. |
| `kerberos.keytab_file` | `BOR_KERBEROS_KEYTAB` | Service keytab. Read at startup; if it cannot be loaded, the server stops with `Failed to initialize Kerberos service`. |
| `kerberos.service_principal` | `BOR_KERBEROS_PRINCIPAL` | Service principal, shown in the startup log. Agents must use the same value. |
| `kerberos.default_node_group_id` | `BOR_KERBEROS_DEFAULT_NODE_GROUP` | Group ID from step 2. Without it, every Kerberos enrollment is refused. |
Expand Down
4 changes: 3 additions & 1 deletion doc/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ Web UI sessions and bootstrap credentials.
| `security.jwt_lifetime` | `BOR_JWT_LIFETIME` | `1h` | Lifetime of the access token. |
| `security.refresh_lifetime` | `BOR_REFRESH_LIFETIME` | `24h` | Lifetime of the refresh token, kept in an HTTP-only cookie. A user must sign in again after this time. |
| `security.admin_token` | `BOR_ADMIN_TOKEN` | `""` | Static token that authorizes the `CreateEnrollmentToken` gRPC call (header `x-admin-token`) on the enrollment port, for scripted token creation. Empty disables that call. Enrollment tokens created in the web UI do not need it. |
| `security.enroll_rate_limit` | `BOR_ENROLL_RATE_LIMIT` | `60` | Most `Enroll`, `KerberosEnroll` and `CreateEnrollmentToken` calls accepted per client IP address per minute on the enrollment port. Further calls fail with `ResourceExhausted` until the minute is over. Raise it when many machines enroll at once from behind one NAT address; `0` turns the limit off. |
| `security.admin_password` | `BOR_ADMIN_PASSWORD` | `""` | Password for the initial `admin` account. Used only when the user table is empty, that is on the very first start. The package writes a random value here at install time. Once you have signed in and changed the password, remove the value from the file. |

```yaml
Expand Down Expand Up @@ -322,7 +323,8 @@ log](../user-guide/audit-log.md).
| YAML key | Environment variable | Default | Description |
|----------|----------------------|---------|-------------|
| `audit.retention_days` | `BOR_AUDIT_RETENTION_DAYS` | `365` | Audit records older than this are purged. `0` keeps them forever. |
| (none) | `BOR_AUDIT_ANONYMIZE_IPS` | `false` | Store client IP addresses truncated to /24 (IPv4) or /48 (IPv6). Environment variable only: an `anonymize_ips` key in `server.yaml` is ignored. |
| (none) | `BOR_AUDIT_ANONYMIZE_IPS` | `false` | Store client IP addresses truncated to /24 (IPv4) or /48 (IPv6), in every audit record including sign-in and sign-out. Environment variable only: an `anonymize_ips` key in `server.yaml` is ignored. |
| `audit.trusted_proxies` | `BOR_TRUSTED_PROXIES` | empty | Reverse proxies (IP addresses or CIDR ranges; comma-separated in the environment variable) whose `X-Forwarded-For` and `X-Real-IP` headers are believed when the audit log records a client IP. Requests from any other address are recorded with their TCP peer address, and the headers are ignored. Rate limiting always uses the peer address. |
| `audit.syslog.enabled` | `BOR_AUDIT_SYSLOG_ENABLED` | `false` | Also send every audit event to a syslog receiver. |
| `audit.syslog.network` | `BOR_AUDIT_SYSLOG_NETWORK` | `udp` | `udp`, `tcp` or `tcp+tls`. |
| `audit.syslog.addr` | `BOR_AUDIT_SYSLOG_ADDR` | `localhost:514` | `host:port` of the receiver. |
Expand Down
46 changes: 32 additions & 14 deletions doc/configuration/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,27 +496,45 @@ The agent stores the key, certificate and CA certificate in

### Renewal

When the agent starts and its certificate expires within **30 days**, it
generates a new key pair, requests a new certificate over the existing
mutual TLS connection on port 8444, and replaces the files on disk. No
token and no administrator action are needed. If renewal fails, the agent
logs `certificate renewal failed` and tries again at its next start.

Because the check runs at agent start, an endpoint whose agent runs
without interruption for more than 60 days only renews at its next restart
(a reboot or a package upgrade restarts it). If a certificate expires, the
agent can no longer connect and must be re-enrolled with a new token.
The agent checks its certificate when it starts and every 12 hours while
it runs. When the certificate expires within **30 days**, the agent
generates a new key pair, requests a new certificate over mutual TLS on
port 8444, and replaces the files on disk. No token and no administrator
action are needed. If renewal fails, the agent logs `certificate renewal
failed` and tries again at the next check.

The agent uses the new certificate from its next connection on. The server
closes every agent connection after at most 24 hours (open streams get a
minute to finish), so a renewed certificate is in use within a day, and an
expired certificate cannot keep an old connection open.

Until the agent connects with the new certificate, the previous one stays
valid, so an agent that lost the server's answer can renew again. The
first connection with the new certificate revokes the previous one
(reason `superseded by renewal`). A certificate that was already replaced
cannot be renewed.

If a certificate expires anyway (for example the machine was switched off
for months), the agent can no longer connect and must be re-enrolled with a
new token.

### Revocation

Bor keeps its own revocation list in the database instead of CRLs or OCSP.
Every call on the policy port, and every authenticated call on the
enrollment port, checks it, so a revocation takes effect on the agent's
next connection. A certificate is revoked when:
enrollment port, checks it. An open policy stream checks it again every
minute and closes when its certificate is revoked or has expired. A
certificate is revoked when:

- you click **Revoke Certificate** on a node in **Nodes**, or
- you **Decommission** (delete) a node: its certificate is revoked before
the record is removed.
- you **Decommission** (delete) a node: its certificates are revoked before
the record is removed, or
- the agent connects with a renewed certificate: the one it replaced is
revoked.

Revoking or decommissioning a node revokes both its current certificate
and, shortly after a renewal, the previous one. Renewal never clears a
revocation.

A revoked agent must be re-enrolled with a new token. See
[Nodes](../user-guide/nodes.md).
Expand Down
18 changes: 12 additions & 6 deletions doc/development/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,18 @@ At start-up, after the enrollment check, the agent calls
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.
90-day certificate and records it as the node's current certificate; the
presented one becomes `previous_cert_serial`. The agent then writes the
new key and certificate.

The check runs at agent start and every 12 hours. The new certificate is
used from the next connection on; the policy listener sets
`MaxConnectionAge` to 24 hours, so that happens within a day. When the
node opens a policy stream with its current certificate, the previous one
is revoked. A renewal authenticated with the previous certificate (the
agent lost the answer) revokes the unused current one instead; any other
certificate is refused. An open stream re-checks its certificate against
the revocation list and its expiry every minute.

### Compliance and tamper reports

Expand Down
3 changes: 2 additions & 1 deletion doc/getting-started/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ See [Users, groups and roles](../user-guide/users-and-roles.md) and the

- Agents authenticate with a client certificate issued by the server's
internal CA during enrollment. Certificates are valid for 90 days and the
agent renews its certificate at startup when it expires within 30 days.
agent renews its certificate when it expires within 30 days (checked at
startup and every 12 hours).
- Enrollment tokens are single-use and expire after 5 minutes.
- Deleting a node revokes its certificate; a revoked agent can no longer
connect to the policy port.
Expand Down
3 changes: 2 additions & 1 deletion doc/installation/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,8 @@ sudo systemctl enable --now bor-agent
sudo systemctl status bor-agent
```

At start the agent renews its certificate if it expires within 30 days,
At start (and every 12 hours) the agent renews its certificate if it
expires within 30 days,
connects to the policy port and applies every policy it receives.

## Verify the node
Expand Down
6 changes: 5 additions & 1 deletion doc/operations/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ from `server.env` first if your database settings are there.

The user can then sign in with their password. If MFA is required for all
users, they are asked to enroll again (when self-enrollment is allowed). The
reset is not recorded in the Bor audit log; note it in your change log.
reset is recorded in the Bor audit log as `auth.mfa.reset`; if that record
cannot be written, the command fails with `write audit record (MFA not
reset)` and nothing changes.

If **every** user's TOTP codes stopped working at the same time, the secret
that encrypts TOTP data changed: `BOR_MFA_SECRET`, or `jwt_secret` when
Expand Down Expand Up @@ -176,6 +178,8 @@ sudo bor-agent --token-file /etc/bor/enroll-token
| `Enrollment failed: enrollment RPC failed: rpc error: code = Unauthenticated desc = enrollment failed: enrollment token expired` | The token is older than 5 minutes | Generate a new one (**Node Groups** -> **Generate token**) and use it right away |
| `... desc = enrollment failed: invalid enrollment token` | Token mistyped, already used (tokens are single-use, even by a failed attempt), or from another server | Generate a new token |
| `... code = Internal desc = enrolled but failed to create node record: a node named "<name>" already exists; delete it before re-enrolling` | A node with this name (the host name, or `agent.client_id`) is already registered; the token is now used up | Decommission the old node on **Nodes**, or set a unique `agent.client_id`; then use a new token |
| `... code = ResourceExhausted desc = too many requests, retry in 1m0s` | More enrollment calls from this IP address in the last minute than `security.enroll_rate_limit` allows (default 60), common when many machines enroll from behind one NAT address | Wait a minute and retry, or raise `security.enroll_rate_limit` on the server |
| `... code = PermissionDenied desc = node name "<name>" does not match principal "<principal>"` (Kerberos) | The node name (`agent.client_id` or the host name) does not belong to the machine principal | Leave `agent.client_id` unset, or set it to the principal's host name; see [Kerberos enrollment](../configuration/kerberos.md#who-can-enroll) |
| `... x509: certificate signed by unknown authority` | The server uses the internal CA and the endpoint does not trust it | Install the CA into the system trust store (the **Deploy agent** wizard shows the commands), or set `server.insecure_skip_verify: true` in `/etc/bor/config.yaml` for enrollment only |
| `... x509: certificate is valid for <names>, not <address>` | `server.address` in the agent configuration is not in the server certificate | Use a listed name, or add the address to `server.hostnames` on the server and restart it |
| `... x509: certificate has expired or is not yet valid` | The endpoint's clock is wrong (or the server certificate really expired) | Fix time synchronization (`timedatectl`), then retry |
Expand Down
Loading
Loading