Configuration¶
The proxy is configured through a single YAML file, passed with -config (default config.yaml in the current directory; the packaged systemd unit passes /etc/bacnet-ip-rest-proxy/config.yaml).
A commented example ships as config.example.yaml in the repository root, and is installed as the initial /etc/bacnet-ip-rest-proxy/config.yaml by the .deb package.
The file is checked strictly at startup: an unknown or misspelt key is an error, not silently ignored. Every problem found is reported at once, each with its line number and the setting it concerns, and the proxy refuses to start. To check a file without starting the proxy, run bacnet-ip-rest-proxy -configcheck -config <file>; it exits non-zero if there are errors:
config errors:
/etc/bacnet-ip-rest-proxy/config.yaml:10: unknown field "peermission" (expected one of: action, conditions, match, name, permission)
/etc/bacnet-ip-rest-proxy/config.yaml:18: authorization.rules[0].match: invalid match "every" (expected one of: all, any, none, not-all)
Top-level structure¶
listen:
address: "0.0.0.0:8443"
tls:
enabled: true
certFile: /etc/bacnet-ip-rest-proxy/cert.pem
keyFile: /etc/bacnet-ip-rest-proxy/key.pem
bacnet:
localPort: 0
timeout: 3s
retries: 3
cacheRefresh: 60s
authentication:
tokens:
- name: ops
token: "opaque-bearer-token"
oidc:
- issuer: https://login.example.com/realms/buildings
audience: bacnet-proxy
jwtSecret: "shared-secret-for-hmac-sha256"
authorization:
rules: []
devices:
default: 192.168.3.2
integra: integra-controller.example.com
listen¶
The HTTP(S) listener.
| Key | Default | Description |
|---|---|---|
listen.address |
:8443 |
host:port the server listens on |
listen.tls.enabled |
false |
enable HTTPS |
listen.tls.certFile |
— | PEM certificate file (required if tls.enabled) |
listen.tls.keyFile |
— | PEM private key file (required if tls.enabled) |
listen.trustedProxies |
[] |
IPs/CIDRs allowed to supply the client's real IP via X-Forwarded-For/X-Real-IP |
Running behind a reverse proxy¶
When the proxy sits behind a reverse proxy (nginx, Caddy, a load balancer, ...), the TCP connection the proxy sees always comes from that reverse proxy, not the real client — which breaks any authorization rule using an ip condition, since it would only ever see the reverse proxy's address.
listen.trustedProxies fixes this by naming the reverse proxy's own address(es); the connecting peer's X-Forwarded-For (preferred) or X-Real-IP header is only trusted when that peer's address is in this list:
listen:
trustedProxies:
- 127.0.0.1/32 # reverse proxy running on the same host
- 10.0.5.0/24 # or a subnet of reverse proxy/load-balancer hosts
Warning
Never set this to 0.0.0.0/0 or otherwise trust untrusted networks — any client whose peer address is listed here can set X-Forwarded-For to anything, including a value crafted to satisfy an ip authorization condition it shouldn't. Only list the exact addresses of reverse proxies you control.
Only a single hop is supported: when the peer is trusted, the leftmost X-Forwarded-For entry is taken as the real client (the convention when a proxy appends to, rather than replaces, that header). If trustedProxies is empty (the default), these headers are always ignored and the real TCP peer address is used — the safe default for a proxy reachable directly.
bacnet¶
Settings for the BACnet/IP client the proxy uses to talk to devices.
| Key | Default | Description |
|---|---|---|
bacnet.localPort |
0 |
local UDP port to bind for outgoing BACnet/IP traffic; 0 picks an ephemeral port |
bacnet.timeout |
3s |
per-attempt reply timeout (Go duration syntax, e.g. 3s, 500ms) |
bacnet.retries |
3 |
retries per request before giving up |
bacnet.cacheRefresh |
60s |
how often each device's variables are re-enumerated in the background (see Variables) |
The proxy only ever initiates unicast requests to configured devices — it never broadcasts — so binding a non-standard local port is fine; it does not need to match the BACnet/IP standard port 47808 the way a passive/discoverable device would.
devices¶
A map of device aliases to hostname/IP (optionally host:port; default port 47808):
devices:
default: 192.168.3.2
integra: integra-controller.example.com
A caller can always address a device by a literal hostname/IP directly in the URL, whether or not it appears in this map — the map only exists to let clients use short, stable names instead of raw addresses. The proxy resolves each device's actual BACnet instance number lazily via a unicast Who-Is/I-Am exchange, and caches it for the life of the process.
authentication¶
Bearer credentials, presented as Authorization: Bearer <value>, are checked in this order:
-
Opaque tokens (
authentication.tokens) — an exact-match list. Each entry has aname(referenced by authorization rules) and atoken(the literal secret value):authentication: tokens: - name: ops token: "a-long-random-opaque-string" -
JWT access token from an OpenID Connect issuer — if the bearer doesn't match an opaque token and is a JWT signed with an asymmetric algorithm, it's verified against the configured issuer named in its
issclaim:authentication: oidc: - issuer: https://login.example.com/realms/buildings audience: bacnet-proxyThe proxy fetches
<issuer>/.well-known/openid-configuration, checks that the document names exactly this issuer, and loads the signing keys from itsjwks_uri. Nothing about the keys needs to be configured, and key rotation is picked up automatically:- Keys are fetched at startup, and again whenever they're more than an hour old (in the background; tokens keep being verified with the current keys meanwhile).
- A token signed with a key ID the proxy doesn't know yet, which is how a rotation shows up, triggers an immediate re-fetch. These re-fetches happen at most once a minute, so tokens with made-up key IDs can't make the proxy flood the issuer.
- If a fetch fails, the previous keys stay in use and a warning is logged.
A token is accepted only if all of these hold:
Check Requirement Signature RS256/384/512, PS256/384/512, ES256/384/512 or EdDSA, with a key from the issuer's JWKS matching the token's kidissexactly the configured issueraudcontains the configured audienceexppresent, and not in the past nbf,iatif present, not in the future Times are checked with one minute of leeway for clock skew.
audienceis required: without it, an access token the same issuer made for any other application would be accepted here. Configure your identity provider to put this value into the access tokens it issues for the proxy's clients (e.g. an audience mapper in Keycloak, or the application ID URI in Entra ID).issuermust be anhttpsURL (plainhttpis only allowed forlocalhost, for testing). Several issuers can be listed. -
JWT signed with a shared secret — a JWT signed with HMAC (HS256/384/512) is verified with
authentication.jwtSecretinstead, and never against an issuer's keys:authentication: jwtSecret: "a long random shared secret"If
jwtSecretis unset, HMAC-signed JWTs are never considered valid. Unlike issuer tokens, theirissandaudaren't checked; usejwtauthorization conditions for that.
A request that matches none of these still reaches the authorization engine, just with no token name or JWT claims to match against — meaning only rules with no such conditions (or IP-only rules) can grant it access.
authorization¶
An ordered list of rules deciding what a request is allowed to do. This is the security-critical part of the configuration — read it carefully.
Evaluation algorithm¶
- Rules are evaluated in the order given.
- For each rule, its
conditionsare checked against the request; whether the rule applies is controlled bymatch(see below). - If a rule applies, it overwrites the provisional result (
permissionand whether the request is allowed) — regardless of whether its action isallow/denyorallow-now/deny-now. - An
-nowaction (allow-nowordeny-now) stops evaluation immediately; a plainallow/denylets evaluation continue, so a later rule can still override it. - If no rule applies at all, the implicit default is deny.
In other words: the last matching rule wins, unless an earlier rule used allow-now/deny-now to short-circuit first.
Rule fields¶
authorization:
rules:
- name: admins get full access
conditions:
- type: jwt
field: group
value: admins
match: all
permission: readwrite
action: allow
| Field | Description |
|---|---|
name |
free-text label, shown in logs/diagnostics |
conditions |
list of conditions to test (see below) |
match |
how the conditions combine into "this rule applies" (see below) |
permission |
readonly or readwrite — the access level granted if this rule wins. Only meaningful for allow/allow-now rules |
action |
allow, deny, allow-now, or deny-now |
Conditions¶
Each condition is a type/value pair (plus field for jwt). Wildcards apply to values: * matches anything, and glob patterns like foo* or *.example.com are supported.
| Type | Matches against | Notes |
|---|---|---|
device |
the targeted device (the alias or literal host/IP from the URL) | |
ip |
the connecting client's IP | besides wildcards, also accepts CIDR notation (10.0.0.0/8) for both IPv4 and IPv6 |
jwt |
a claim in the verified JWT payload | requires field (the claim name); if the claim is a JSON array (e.g. a groups claim), the condition matches if any element matches |
operation |
the BACnet operation the request maps to | one of who-is, read-property, read-property-multiple, write-property, or the synthetic list-devices (for GET /devices, which makes no BACnet call) |
token |
the name of a matched entry in authentication.tokens |
never matches if the bearer wasn't a recognized opaque token (there's no such thing as "no token" matching a wildcard) |
variable |
a BACnet object's object-name, as reported by the device |
rules are evaluated separately for each variable (see Variables); a rule without a variable condition applies to all variables alike. Requests not about variables (GET /devices) never match |
A jwt or token condition never matches a request that didn't carry the corresponding credential — even against a * wildcard.
Variables¶
A device's BACnet objects are its variables. The proxy enumerates them the first time a device is used — its object-list, plus each object's object-name, description and units — and re-enumerates every bacnet.cacheRefresh (default 60s) in the background. Devices with more objects than fit in a single BACnet reply (the proxy doesn't support segmented replies) are read piecewise, element by element and in smaller batches, so the first enumeration of a large device can take a few seconds. A failed refresh keeps the previous list; devices nobody has asked about for ten refresh intervals are dropped from the cache. Live values such as present-value are never cached.
The rules are evaluated per variable, against its cached name:
| Request | Effect |
|---|---|
PUT .../{property} |
denied unless the caller has readwrite access to that variable |
GET /devices/{id}/objects |
only variables the caller can read are listed; 401/403 if there are none |
GET .../{type}/{instance}[/{property}], GET /devices/{id} |
401/403 if the caller can't read that variable (the device object, for GET /devices/{id}) |
GET .../device/{instance}/object-list |
only variables the caller can read are included; ?index= applies to that filtered list |
A caller can read a variable if the rules allow it read-property on it, and can write it if they also allow write-property with readwrite permission. Every returned variable carries an access field, readonly or readwrite, reflecting that; objects keep their real BACnet type.
Fail-closed on unknown names
When any rule contains a variable condition, an object that isn't in the cached list — the device can't be reached, or the object was created since the last refresh — is refused rather than evaluated without a name, so a deny rule keyed on a name can't be bypassed. A caller with a recognized token or JWT then gets the underlying BACnet error (502/504) so device problems aren't mistaken for permission problems; anyone else gets 401/403. The failure is logged as a warning. Likewise, a renamed object is authorized under its old name for up to one refresh interval.
Matching modes¶
match controls how the condition list combines into a yes/no for "this rule applies":
| Mode | Rule applies when… |
|---|---|
all |
every condition matches |
any |
at least one condition matches |
none |
no condition matches |
not-all |
at least one condition does not match |
Pitfall: match: none with a condition that always matches
A condition with value * always matches — so pairing it with match: none produces a rule that can never apply, since "at least one condition matched" defeats none outright. If you want an issuer guard like:
- name: only accept our IdP's tokens
conditions:
- type: jwt
field: iss
value: id.example.com
match: none
action: deny-now
(deny immediately unless the issuer matches), don't add an extra {type: operation, value: "*"} condition to that same rule — it will silently make the guard inert.
A worked example¶
authorization:
rules:
- name: deny anyone not issued by our IdP
conditions:
- type: jwt
field: iss
value: id.example.com
match: none
action: deny-now
- name: admins get full access
conditions:
- type: jwt
field: group
value: admins
match: all
permission: readwrite
action: allow
- name: everyone else from the office network gets read-only
conditions:
- type: ip
value: 10.10.0.0/16
match: all
permission: readonly
action: allow
Reading this top to bottom: any JWT not issued by id.example.com is denied outright. Admins (matched by JWT group claim) get full read/write access. Anyone else connecting from the 10.10.0.0/16 network gets read-only access. Everyone else falls through to the implicit deny.
Debugging authentication and authorization¶
Clients only see 401 (no bearer token found) or 403 (token not recognized, or no rule grants access). To see why, start the proxy with -debugauth:
bacnet-ip-rest-proxy -config /etc/bacnet-ip-rest-proxy/config.yaml -debugauth
For every request it then logs, tied together by a request ID:
- authentication: how the bearer was recognized (
opaque token "ops",JWT verified (issuer …, RS256)) or exactly why not (e.g.token has invalid audience,token is expired,no signing key with ID …, or an issuer that isn't configured), the verified JWT claims, and a hint if the token wasn't sent asAuthorization: Bearer <token>; - rule: each rule evaluated, whether it applies, and each condition with the value it was compared with, e.g.
jwt.groups="admins": no match (actual ["users"]); - decision: the operation, whether it's allowed, and the rule that decided it.
level=INFO msg="auth debug: authentication" request=…-000001 method=GET path=/api/v1/devices/unit/objects/analog-input/1/present-value client=10.0.0.7 result="JWT verified (issuer \"https://login.example.com/realms/buildings\", RS256)" claims="{\"groups\":[\"users\"],…}"
level=INFO msg="auth debug: rule" request=…-000001 index=0 rule=admins match=all applies=false action=allow permission=readonly conditions="jwt.groups=\"admins\": no match (actual [\"users\"])"
level=INFO msg="auth debug: decision" request=…-000001 operation=read-property device=unit allowed=false "decided by"=<implicit-default>
Raw tokens are never logged, but claims are, and they may contain personal data such as names or e-mail addresses; turn -debugauth off again once done. With the systemd unit, add the flag to ExecStart via systemctl edit bacnet-ip-rest-proxy and watch journalctl -u bacnet-ip-rest-proxy -f.
Permission levels and operations¶
| Permission | Grants |
|---|---|
readonly |
who-is, read-property, read-property-multiple, list-devices |
readwrite |
all of the above, plus write-property |
Each REST endpoint maps to exactly one of these operations; see the API Reference for the endpoint list.