Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f8c6027
Add Elixir Sparkplug B library scaffold
KavanPrice Oct 19, 2026
a9a9ea3
Add an Elixir code style section to CLAUDE.md
KavanPrice Oct 19, 2026
462f469
Test Elixir Sparkplug decoding against Tahu JS fixtures
KavanPrice Oct 19, 2026
ffc8b5c
Decode Sparkplug payloads to native Elixir values
KavanPrice Oct 19, 2026
3feb4e1
Parse and format Sparkplug topic names
KavanPrice Oct 19, 2026
bdca561
Vendor kerlberos 2.0.3 for the Elixir service client
KavanPrice Oct 19, 2026
ce589a6
Patch kerlberos to log in to the ACS KDC from a keytab
KavanPrice Oct 19, 2026
f00e3e7
Scaffold the Elixir service client
KavanPrice Oct 19, 2026
9b741f7
Replace lager with OTP's logger in the kerlberos fork
KavanPrice Oct 19, 2026
9d63d2d
Keep secrets out of kerlberos crash reports
KavanPrice Oct 20, 2026
83e84b3
Check the reply's time in kerlberos mutual authentication
KavanPrice Oct 20, 2026
1b0e12b
Add Kerberos credentials to the Elixir service client
KavanPrice Oct 20, 2026
bdae980
Add an MQTT connection to the Elixir service client
KavanPrice Oct 20, 2026
19e03e7
Add HTTP requests to the Elixir service client
KavanPrice Oct 20, 2026
b432b45
Read KRB5_CONFIG in the kerlberos fork
KavanPrice Oct 20, 2026
899858b
Start the whole Elixir service client from one child spec
KavanPrice Oct 20, 2026
281140a
Track Sparkplug births and rebirths in the Elixir library
KavanPrice Oct 20, 2026
f38c669
Run the Elixir libraries' checks in CI
KavanPrice Oct 20, 2026
01da30f
Show how to depend on the Elixir client from outside the repo
KavanPrice Oct 20, 2026
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
62 changes: 62 additions & 0 deletions .github/workflows/elixir.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Elixir libraries
# The Elixir libraries in lib/ex-* aren't built by the Makefiles or
# deployed yet, so this is the only place their checks run. Tests tagged
# :acs_local need the local test cluster and are excluded by default.
on:
pull_request:
branches:
- dev
- main
paths:
- 'lib/ex-**'
- 'lib/erl-**'
# FPlus.Service is checked against the JS client's UUIDs.
- 'lib/js-service-client/lib/uuids.js'
- '.github/workflows/elixir.yaml'

jobs:
check:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
lib:
- ex-sparkplug
- ex-service-client
defaults:
run:
working-directory: lib/${{ matrix.lib }}
env:
MIX_ENV: test
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Erlang and Elixir
uses: erlef/setup-beam@v1
with:
version-file: lib/${{ matrix.lib }}/.tool-versions
version-type: strict

- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
lib/${{ matrix.lib }}/deps
lib/${{ matrix.lib }}/_build
key: ${{ runner.os }}-mix-${{ matrix.lib }}-${{ hashFiles(format('lib/{0}/mix.lock', matrix.lib), format('lib/{0}/.tool-versions', matrix.lib)) }}

- name: Fetch dependencies
run: mix deps.get

- name: Check formatting
run: mix format --check-formatted

- name: Compile
run: mix compile --warnings-as-errors

- name: Lint
run: mix credo

- name: Test
run: mix test
78 changes: 78 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ The AMRC Connectivity Stack (ACS) is a Kubernetes-deployed implementation of the
- `js-pg-client`, `js-rx-client`, `js-rx-util` - Database and reactive utilities
- `py-edge-driver` - Python edge driver base
- `java-service-client` - Java client library
- `ex-service-client` - Factory+ service client for Elixir services
- `ex-sparkplug` - Sparkplug B library for Elixir services
- `erl-kerlberos` - Fork of the kerlberos Erlang Kerberos library (see its `FORK.md`)

- `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.)
- `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.)
Expand Down Expand Up @@ -330,6 +333,81 @@ Read `acs-configdb/lib/api-v1.js`, `acs-configdb/lib/auth.js`,
`acs-configdb/lib/notify.js`, and `acs-configdb/bin/api.js` before writing a
new service. Copy the shape; do not improvise.

## Code Style (Elixir)

Elixir code lives in Mix projects under `lib/ex-*`. There is no canonical
Elixir service yet, so these rules come from the Elixir defaults plus the
ACS conventions above. `lib/ex-sparkplug` is the reference as it grows.

### Language and projects
- Elixir 1.20 on OTP 29, pinned in each project's `.tool-versions`.
- Each library is its own Mix project. The directory is `lib/ex-<name>`
(kebab-case, like `js-*` and `py-*`), the app is `:snake_case` and the
top-level module is `PascalCase`: `lib/ex-sparkplug` is app
`:sparkplug_b`, module `SparkplugB`.
- Projects depend on each other through path dependencies
(`{:sparkplug_b, path: "../lib/ex-sparkplug"}`), not Hex.
- Standard Mix layout: code in `lib/`, tests in `test/`, one module per
file, with the file path following the module name (`SparkplugB.Topic`
is `lib/sparkplug_b/topic.ex`). Elixir file names are `snake_case.ex`,
unlike the JS `kebab-case.js`.
- Generated code (e.g. from protox) is committed and never edited by
hand. The project README says how to regenerate it.
- Forked Erlang libraries are vendored under `lib/erl-<name>` and used as
`{:<name>, path: "../erl-<name>", manager: :rebar3}`. Each has a
`FORK.md` recording where it came from and every patch we carry.

### Formatting and linting
- `mix format` with the `.formatter.exs` that `mix new` creates. No
custom formatter options.
- `mix credo` with its default checks. No `.credo.exs`.
- Code must pass `mix format --check-formatted`, `mix credo` and
`mix compile --warnings-as-errors`, which includes the compiler's type
warnings. `.github/workflows/elixir.yaml` runs these and `mix test` on
pull requests for each `lib/ex-*` project; add new ones to its matrix.

### Naming
- Elixir's own conventions, which already match ACS: `snake_case` for
functions, variables and atoms; `PascalCase` for modules.
- Constants are module attributes (`@ns_threshold`), not
`SCREAMING_SNAKE_CASE`.
- Functions returning a boolean end in `?` (`valid?`). Functions that
raise instead of returning an error end in `!` (`decode!`).

### Errors
- Functions that can fail on bad input return `{:ok, value}` or
`{:error, reason}`. Data from the network is input, so decoding never
raises; add a `!` variant only where callers want one.
- Don't rescue broadly to keep a process alive. Let it crash and let its
supervisor restart it.

### Processes
- Use processes for runtime state (connections, caches, buffers), not to
organise code. Plain functions run in the caller's process.
- Libraries take options; they don't read application config
(`Application.get_env`). A library that needs processes exposes a child
spec for the application's supervision tree rather than starting its
own.
- Libraries never stop the VM. They report problems through return
values, logs or `:telemetry`, and the application decides what to do.

### Docs, logs and tests
- `@moduledoc` on every module (`@moduledoc false` for internal ones),
and `@doc` and `@spec` on public functions. Use doctests where an
example helps.
- Log with `Logger`, never `IO.puts`.
- ExUnit tests mirror the `lib/` path (`test/sparkplug_b/topic_test.exs`)
and use `async: true` unless they touch global state.

### Headers
- Each source file starts with a short comment block, then a blank line:
```
# ACS Sparkplug B
# <one-line file purpose>
# Copyright <year> University of Sheffield
```
Generated files keep the generator's header instead.

## Contributing

- PRs target `dev` (the default branch), including fixes for a release.
Expand Down
2 changes: 2 additions & 0 deletions lib/erl-kerlberos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Build output when Mix compiles this as a dependency
/_build/
163 changes: 163 additions & 0 deletions lib/erl-kerlberos/FORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# kerlberos (ACS fork)

An in-tree copy of [kerlberos](https://github.com/arekinath/kerlberos), a
pure-Erlang Kerberos 5 and GSS-API library. The Elixir service client
uses it to log in to the ACS KDC and authenticate to services.

## Where it came from

- Version 2.0.3, from the Hex package `kerlberos-2.0.3.tar` (outer
checksum `5278d7d01b647cdb66d66288787449300edc0e38de1952b34e6de187146b91d2`,
as published on hex.pm).
- Upstream tag `v2.0.3`, commit `b6a27d1cc6`. The hand-written source in
the Hex package matches the tag. The package also contains the
modules the build generates (`src/KRB5.erl`, `src/SPNEGO.erl`,
`src/krb5conf.erl`, the `.asn1db` files and `include/SPNEGO.hrl`),
`rebar.lock`, and `src/krbtests.erl`, a manual test module that isn't
in git. Vendoring the package rather than the tag means the build
needs no rebar3 plugins.
- Licence: BSD-2-Clause, stated in each source file's header. Upstream
has no separate licence file.

## Why a fork

Upstream 2.0.3 can't log in as an ACS service:

- The ACS KDC doesn't require pre-authentication, so it answers
kerlberos's first request with a ticket. kerlberos only expects a
"pre-authentication required" error there and crashes.
- ACS services log in from keytabs. kerlberos only logs in with a
password.

The patches below fix both. They are small and could go upstream.

## Using it

```elixir
{:kerlberos, path: "../erl-kerlberos", manager: :rebar3}
```

Mix builds it with rebar3 (install rebar3 for Mix once with
`mix local.rebar`). Don't run `rebar3 compile` in this directory:
upstream's `rebar.config` regenerates the ASN.1 and parser modules with
plugins, from sources that aren't vendored here.

## Patches

Each change is marked `ACS patch` in the source. Apart from these,
this file and `.gitignore`, the copy is unchanged from the Hex package.

1. **Pre-authentication not required** (`src/krb_auth_fsm.erl`,
`probe/3`). kerlberos first sends a request without
pre-authentication to learn the etype and salt, and expects
`KDC_ERR_PREAUTH_REQUIRED`. A KDC that doesn't require
pre-authentication for the principal answers with a ticket instead,
which crashed `probe/3`. The new clause takes the etype and salt from
that reply and carries on to the normal pre-authenticated request.
The unauthenticated ticket is discarded, so the ticket kerlberos
returns still has the `pre_auth` flag.
2. **Log in from a keytab** (`src/krb_auth_fsm.erl`,
`src/krb_realm.erl`). Pass the principal's keytab entries as an
option; the password argument is then ignored:

```erlang
{ok, KeyTab} = krb_mit_keytab:file("client.keytab"),
Entries = [E || E = #{principal := ["sv1warehouse"]} <- KeyTab],
{ok, TGT} = krb_realm:authenticate(Realm, ["sv1warehouse"], <<>>,
#{keytab => Entries}).
```

The keytab's key for the etype the KDC chose is used instead of
deriving one from a password. If the keytab has no key for that
etype, the result is `{error, {no_keytab_key, EType}}` rather than a
crash, because a crash report would include the keytab.
3. **OTP's logger instead of lager** (10 files under `src/`,
`src/kerlberos.app.src`, `rebar.config`, `rebar.lock`). kerlberos
only used lager for debug messages. Starting kerlberos started lager,
which wrote `log/console.log`, `log/error.log` and `log/crash.log`
into the working directory of whatever application started it. By
default it also captured OTP's crash reports into `crash.log`, which
could put passwords or keys on disk. Each `lager:debug` call is now
`?LOG_DEBUG` (44 calls), so messages go to the application's own
logger and only appear at debug level. `lager` and `goldrush` are no
longer dependencies. One acceptor-side message logged the keytab's
keys when no key matched a ticket; it now logs only the ticket.

4. **Secrets kept out of crash reports** (`src/krb_redact.erl`, which is
new, and `format_status/1` in `src/krb_auth_fsm.erl`,
`src/krb_tkt_grant_fsm.erl` and `src/krb_realm.erl`). These processes
hold passwords, keytabs, keys and tickets in their state, and
`krb_realm` receives them in its `authenticate` and `obtain_ticket`
calls. OTP prints a process's state, last message and exit reason in
its crash report, and a `function_clause` exit reason carries the
crashed function's arguments, which can be the whole state.
`format_status/1` now blanks out the secrets, and reduces an exit
reason to the kind of error and where it happened (module, function,
arity and line). The same applies to `sys:get_status/1`. The service
client's `test/kerlberos_fork_test.exs` checks each process, and
crashes a live `krb_realm` to check its report.

5. **Mutual authentication checks the reply's time** (`src/gss_krb5.erl`).
The initiator only checked that the acceptor's reply (AP-REP)
decrypted with the ticket's session key. It didn't check that the
reply echoes the time (`ctime`, `cusec`) of the authenticator it sent,
as RFC 4120 section 3.2.5 asks and MIT Kerberos does. Without that,
any reply made with the same ticket passed, so someone who recorded
the broker's reply to one connection could replay it to pass as the
broker on a later one that reused the ticket, and tickets are cached
for hours. `initiate/1` now keeps the authenticator's time in its
state, and the reply handling, moved into `initiator_ap_rep/2`, returns
`{error, mutual_failed}` when the reply doesn't echo it. The service
client's `test/fplus/credentials_acs_local_test.exs` checks both cases
against the `acs-local` broker.

6. **`KRB5_CONFIG`** (`src/krb_realm_conf.erl`, `config_paths/0`).
kerlberos only read fixed paths such as `/etc/krb5.conf`. As MIT
Kerberos does, it now reads the colon-separated files in
`KRB5_CONFIG` instead when that is set, skipping any that are
missing. ACS pods keep their `krb5.conf` under `/config` and point
`KRB5_CONFIG` at it.
7. **An empty DNS answer for KDCs** (`src/krb_realm_conf.erl`,
`lookup_kdcs/3`). Unless `krb5.conf` sets `dns_lookup_kdc = false`,
which ACS's doesn't, kerlberos also looks up `_kerberos._udp.<realm>`
in DNS. An answer with no SRV records (NODATA) crashed it in
`lists:min/1`. It now adds no KDCs from DNS in that case. The service
client's `test/kerlberos_fork_config_test.exs` checks both patches,
including with a `krb5.conf` as ACS's Helm template renders it.

Patches 1 and 2 were checked against the `acs-local` KDC and broker on
2026-10-19: password and keytab logins (the keytab one as
`sv1warehouse`), a wrong password (`{error, bad_secret}`), a keytab
without the KDC's etype, and an MQTT 5 GSSAPI connection with mutual
authentication. After patch 3, login and the MQTT connection work as
before, debug messages arrive through Elixir's `Logger`, nothing is
written to `log/`, and the compiler warnings are the same 23 upstream
ones as before.

## Known issues

Not patched:

- With no channel bindings, the GSS initiator sends the MD5 hash of an
empty binding where MIT sends 16 zero bytes. The ACS broker's Java
acceptor ignores bindings, so this works.
- When a call to `krb_realm` fails, the exit the caller receives
includes the request, so a password or ticket. Patch 4 can't change
that: callers must catch these exits and not log them as they are.
- `krb_mit_keytab:file/1` matches `{err, _}` where it means
`{error, _}`, so it crashes on any read error. The service client
reads keytabs itself and calls `krb_mit_keytab:parse/1`.
- kerlberos ignores two settings in ACS's `krb5.conf`.
`udp_preference_limit = 1` tells MIT clients to use TCP, but kerlberos
tries UDP first, which only costs time where UDP isn't reachable.
`disable_encrypted_timestamp = true` tells MIT clients to use SPAKE
pre-authentication, but kerlberos only has encrypted timestamps, which
the KDC still accepts.
- On OTP 29 kerlberos compiles with 23 warnings, mostly the deprecated
`catch` expression. A later OTP may turn these into errors.

## Updating

Vendor the new Hex package unchanged in one commit, then reapply the
patches listed above in another, so each patch stays visible in the
history.
Loading
Loading