Skip to content

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:

  1. Opaque tokens (authentication.tokens) — an exact-match list. Each entry has a name (referenced by authorization rules) and a token (the literal secret value):

    authentication:
      tokens:
        - name: ops
          token: "a-long-random-opaque-string"
    
  2. 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 iss claim:

    authentication:
      oidc:
        - issuer: https://login.example.com/realms/buildings
          audience: bacnet-proxy
    

    The proxy fetches <issuer>/.well-known/openid-configuration, checks that the document names exactly this issuer, and loads the signing keys from its jwks_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 kid
    iss exactly the configured issuer
    aud contains the configured audience
    exp present, and not in the past
    nbf, iat if present, not in the future

    Times are checked with one minute of leeway for clock skew. audience is 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). issuer must be an https URL (plain http is only allowed for localhost, for testing). Several issuers can be listed.

  3. JWT signed with a shared secret — a JWT signed with HMAC (HS256/384/512) is verified with authentication.jwtSecret instead, and never against an issuer's keys:

    authentication:
      jwtSecret: "a long random shared secret"
    

    If jwtSecret is unset, HMAC-signed JWTs are never considered valid. Unlike issuer tokens, their iss and aud aren't checked; use jwt authorization 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 conditions are checked against the request; whether the rule applies is controlled by match (see below).
  • If a rule applies, it overwrites the provisional result (permission and whether the request is allowed) — regardless of whether its action is allow/deny or allow-now/deny-now.
  • An -now action (allow-now or deny-now) stops evaluation immediately; a plain allow/deny lets 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 as Authorization: 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.