Appearance
Reference
Everything the package installs, exposes and answers. Tables, not prose.
muxen-configd
muxen-configd [OPTION]| Flag | Argument | Default | Effect |
|---|---|---|---|
-h, --help | — | — | print the option list on stdout and exit 0 |
-v, --verbose | — | off | add libwebsockets NOTICE logging, and print one line per request and per commissioning decision |
-V, --version | — | — | print the version (git describe --tags --always --dirty) and exit 0 |
-p, --port | port number | 12000 | TCP port of the HTTP server |
-c, --config | directory | /etc/muxen | configuration folder — must exist and be writable |
-s, --swagger | directory | /usr/share/muxen-config/swagger | folder served at / — must exist |
Notes on parsing:
- The option string is
+hvVp:c:s:, so option parsing stops at the first non-option argument. The daemon takes no positional argument; the built-in help text mentions acommand, and there is none. --portis parsed with base0, so0x2EE0is accepted, and stored in a 16-bit field — a value above 65535 wraps rather than being refused.- An unrecognised option prints the help text and exits
0.
Startup
In order: seed the PRNG, parse options, check the swagger folder is a directory, check the configuration folder is a directory and writable, print the configuration summary, create the glib main loop, install SIGINT/SIGTERM handlers, start libwebsockets bound to 127.0.0.1, start the MQTT info client (see MQTT).
The summary printed on every successful start:
config: verbose = 0
config: wsPort = 12000
config: configFolder = /etc/muxen
config: swaggerFolder = /usr/share/muxen-config/swaggerExit codes
| Code | When |
|---|---|
0 | --help, --version, or a clean shutdown on SIGINT / SIGTERM |
1 | swagger folder missing; configuration folder missing or not writable; libwebsockets context creation failed (lws init failed, typically a port already bound); a route regex failed to compile |
muxen-config-backup
muxen-config-backupNo options. Copies /etc/muxen/deploy.json into /etc/muxen/backup/ when it differs from the previous copy.
| Constant | Value |
|---|---|
| Source | /etc/muxen/deploy.json |
| Destination directory | /etc/muxen/backup (created if absent) |
| Copy name | deploy.json-%Y-%m-%d_%H-%M-%S (local time) |
| Change detection | cmp against /etc/muxen/backup/last_backup |
| Pointer to the newest copy | last_backup, a symlink |
| Copies retained | 30, newest first by modification time |
Does nothing if the source file does not exist. Prints Backup created: <path> when it copies, nothing when it does not.
systemd units
| Unit | Type | Role |
|---|---|---|
muxen-config.service | long-running | the daemon. PartOf=muxen.target, WantedBy=muxen.target, After=muxen-config-perms.service, Restart=always, RestartSec=30 |
muxen-config-perms.service | oneshot | fixes ownership and modes under the configuration folder. PartOf=muxen.target, WantedBy=muxen.target |
muxen-config-backup.timer | timer | OnCalendar=daily, Persistent=true, WantedBy=timers.target |
muxen-config-backup.service | oneshot | runs /usr/bin/muxen-config-backup |
muxen-config.service runs as muxen:muxen with UMask=0022, ConfigurationDirectory=muxen (mode 0755) and ExecStart=/usr/bin/muxen-configd -c ${MUXEN_CONFIG_FOLDER}.
Its hardening, in one place: ProtectSystem=strict, ProtectHome, ProtectProc=invisible, ProtectClock, ProtectHostname, ProtectKernelLogs, ProtectKernelModules, ProtectKernelTunables, ProtectControlGroups, PrivateDevices, PrivateTmp, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, RestrictNetworkInterfaces=lo, RemoveIPC, LockPersonality, NoNewPrivileges, SystemCallArchitectures=native, SystemCallErrorNumber=EPERM, a SystemCallFilter denying @clock, @cpu-emulation, @debug, @module, @mount, @obsolete, @privileged, @raw-io, @reboot, @resources, @swap, and a CapabilityBoundingSet dropping every capability the daemon does not need.
RestrictNetworkInterfaces=lo is worth noting on its own: the service cannot use any interface but loopback, whatever --port is set to.
muxen-config-perms.service runs, ignoring individual failures:
chown -R muxen:muxen ${MUXEN_CONFIG_FOLDER}
chmod 755 ${MUXEN_CONFIG_FOLDER}
chmod 755 ${MUXEN_CONFIG_FOLDER}/configuration
chmod 644 ${MUXEN_CONFIG_FOLDER}/deploy.json
chmod 644 ${MUXEN_CONFIG_FOLDER}/configuration/*Environment
| Variable | Default | Set by | Used for |
|---|---|---|---|
MUXEN_CONFIG_FOLDER | /etc/muxen | muxen-config.service, muxen-config-perms.service | the -c argument, and the path the perms unit fixes |
An override goes in a drop-in (systemctl edit muxen-config).
Files and directories
| Path | Owner | Content |
|---|---|---|
/usr/bin/muxen-configd | package | the daemon |
/usr/bin/muxen-config-backup | package | the backup script |
/etc/muxen/ | package (empty) | the configuration folder |
/etc/muxen/configuration/<id>.json | daemon | one project per file |
/etc/muxen/deploy.json | daemon | the deployed configuration — what the rest of the boat reads |
/etc/muxen/deploy.json.tmp | daemon | transient, during a deploy only |
/etc/muxen/apps/<id> | daemon | opaque per-application blobs, any content type |
/etc/muxen/backup/deploy.json-<timestamp> | backup script | dated copies of the deployed configuration |
/etc/muxen/backup/last_backup | backup script | symlink to the newest copy |
/usr/share/muxen-config/templates/<id>.json | package (empty) | read-only templates |
/usr/share/muxen-config/swagger/ | package | the bundled Swagger UI, served at / |
/etc/nginx/snippets/muxen-api-config.conf | package | exposes the API at /api/config/ |
/etc/nginx/snippets/muxen-deploy.conf | package | exposes the deployed configuration at /api/deploy.json |
/usr/share/bash-completion/completions/muxen-configd | package | bash completion |
There is no socket, no PID file and no lock file.
Network
| Property | Value |
|---|---|
| Bind address | 127.0.0.1 only, hard-coded |
| Port | 12000, --port to change |
| Protocol | HTTP/1.1 |
| Root mount | the swagger folder, index.html by default, cache_max_age 300 |
| Request body limit | none in the daemon; nginx sets client_max_body_size 50M on /api/config/ |
nginx mapping:
| Public path | Daemon path |
|---|---|
/api/config/<path> | /<path> |
/api/deploy.json | /deploy, falling back to the file /etc/muxen/deploy.json on connect error, 1 s connect timeout, 502, 503 or 504 |
Both snippets set proxy_set_header Connection "close".
MQTT
The daemon's only MQTT use is announcing itself; it subscribes to nothing.
| Parameter | Value |
|---|---|
| Broker | 127.0.0.1:1883, hard-coded |
| Client ID | muxen-config |
| Keepalive | 30 s |
| Subscriptions | none |
| Broker down at startup | not an error: the API is served anyway and the connection is retried every 10 s; after the first connection libcanmqtt reconnects on its own |
Published:
| Topic | QoS | Retain | When |
|---|---|---|---|
app/config/info | 1 | yes | on each connection with online: true; online: false as the last will and on a clean exit |
The payload is the daemon identity every MUXEN daemon publishes on app/<daemon>/info:
json
{
"name": "muxen-config",
"version": "v10.15.0",
"hostname": "brain-3",
"features": ["deploy", "backup", "transaction", "duplicate", "rename", "settings", "device", "device-legend", "sensor", "synapse", "energy", "muxing", "button-mapping", "media", "view", "commissioning", "apps"],
"online": true,
"metadata": { "rxdate": "2026-09-22T08:01:58.406Z", "rxTimestamp": 1790064118, "expireAfterSec": 3124137600 }
}version is the git describe of the build and is for display only. expireAfterSec is about 99 years: the info never goes stale, online carries liveness. GET /info answers the same identity without online and metadata.
features has one entry per REST resource family, named after its URL segment; a family added later gets its own entry. Clients test for the one they need and ignore the ones they do not know:
| Feature | Routes |
|---|---|
deploy | GET /deploy |
backup | GET /project/<id>/backup, PUT /project/<id>/restore |
transaction | POST /project/<id>/transaction |
duplicate | PUT /project/<id>/duplicate/<newId> |
rename | PUT /project/<id>/rename/<newId> |
settings | /project/<id>/settings… |
device | /project/<id>/device…, parameters included |
device-legend | /project/<id>/device/<n>/legend… |
sensor | /project/<id>/sensor… |
synapse | /project/<id>/synapse… |
energy | /project/<id>/energy… |
muxing | /project/<id>/muxing… |
button-mapping | /project/<id>/button-mapping… |
media | /project/<id>/media… |
view | /project/<id>/view…, partial updates included |
commissioning | /project/<id>/commissioning… |
apps | /apps… |
The server block that includes them is not in this package: it is shipped by the boat's UI interface package (muxen-interface-*), one site file per boat, which is also where the listening port is decided and where the map $http_upgrade $connection_upgrade block used by the WebSocket snippets of other MUXEN packages is defined. The two includes are unconditional there — no trailing * — so on a boat whose interface site includes them, nginx will not start unless this package is installed.
HTTP conventions
| Aspect | Behaviour |
|---|---|
| Response content type | application/json unless a handler sets another |
| Response body | pretty-printed JSON |
Content-Length | never sent — responses are terminated by closing the connection |
Connection | always close |
| Request body | read when Content-Length is greater than zero; parsed as JSON except for media and app uploads, which are stored raw |
| Unknown path or method | 404 |
Error bodies produced by the daemon:
json
{"error":"save project","errno":30,"code":"EROFS","message":"Read-only file system"}errno, code and message are present only when the failure came from a syscall. code is the symbolic name for the common cases (EACCES, EBUSY, EDQUOT, EEXIST, EFBIG, EINVAL, EIO, EISDIR, ELOOP, ENAMETOOLONG, ENOENT, ENOMEM, ENOSPC, ENOTDIR, EPERM, EROFS) and absent otherwise.
Status codes
| Code | Meaning in this API |
|---|---|
200 | read succeeded, or a delete/update took effect |
201 | a create or replace took effect |
304 | If-None-Match matched the current ETag (/deploy, media) |
400 | malformed short name, a numeric path segment malformed or out of range (see Path segment rules), missing or unparseable body, wrong JSON type |
401 | the project has a password setting and the Authorization header did not match |
404 | no such project, section entry, media, app or route |
409 | a view rename collided with an existing view, or a duplicate or a rename targeted a project that already exists |
500 | the project could not be saved, memory could not be allocated, or an internal invariant failed |
503 | /health only — the routing table is not compiled yet |
Authentication
| Item | Value |
|---|---|
| Where the secret lives | the project's settings[] entry named password, field hash |
| Hash | SHA-256 of the password, lowercase hex |
| Header | Authorization: Bearer <hash> |
| Comparison | the header is split on the first space and the second field is compared to hash; the scheme word is not checked |
| Scope | every endpoint that opens the project, plus PUT /project/<id>, PUT /project/<id>/restore and DELETE /project/<id>, which are gated on the password of the file already on disk |
| No password setting | all requests allowed |
Endpoints
Blank means the method is not routed and answers 404.
Service
| Path | GET | PUT | POST | DELETE |
|---|---|---|---|---|
/health | 200 ready / 503 starting | |||
/info | 200 {name, version, hostname, features}, see MQTT | |||
/deploy | 200 / 304 / 404 | |||
/ and below | the bundled Swagger UI |
Projects
| Path | GET | PUT | POST | DELETE |
|---|---|---|---|---|
/project | 200 list of projects and templates | |||
/project/<id> | 200 stripped project | 201 create or replace (blank project; body may set name) | 200 / 404 | |
/project/<id>/backup | 200 whole project | |||
/project/<id>/restore | 201 write the body as the project | |||
/project/<id>/deploy | 201 copy to deploy.json | |||
/project/<id>/duplicate/<newId> | 201 copy with a new uid and duplicateFrom, optionally applying commissioning; 409 if <newId> exists | |||
/project/<id>/rename/<newId> | 201 move an /etc project, keeping its uid; 409 if <newId> exists | |||
/project/<id>/transaction | 200 run a step list |
Sections
| Path | GET | PUT | DELETE |
|---|---|---|---|
/project/<id>/settings | 200 array | ||
/project/<id>/settings/<name> | 201 upsert | 200 | |
/project/<id>/device | 200 array | ||
/project/<id>/device/<deviceId> | 200 one device | 201 create or reset | 200 |
/project/<id>/device/<deviceId>/parameters | 200 array | 201 upsert a list | 200 remove a list of names |
/project/<id>/device/<deviceId>/parameter/<name> | 201 upsert one | 200 | |
/project/<id>/sensor | 200 array | ||
/project/<id>/sensor/<name> | 200 | 201 upsert | 200 |
/project/<id>/synapse | 200 object | ||
/project/<id>/synapse/<instanceId> | 200 | 201 upsert | 200 |
/project/<id>/energy | 200 array | ||
/project/<id>/energy/<name> | 200 | 201 upsert | 200 |
/project/<id>/muxing | 200 array | ||
/project/<id>/muxing/<name> | 200 | 201 upsert, applying config to devices; a replace keeps the commissioning rules naming it | 200, reversing config and cascading into commissioning |
/project/<id>/button-mapping | 200 array | ||
/project/<id>/button-mapping/<name> | 200 | 201 upsert | 200 |
/project/<id>/media | 200 array, metadata only | ||
/project/<id>/media/<name> | 200 the decoded bytes | 201 store the raw body | 200 |
/project/<id>/view | 200 array, bodies stripped | ||
/project/<id>/view/<name> | 200 whole view | 201 upsert | 200 |
/project/<id>/view/<name>/tags | 200 replace tags | ||
/project/<id>/view/<name>/enabled | 200 replace enabled | ||
/project/<id>/view/<name>/texts | 200 patch text elements | ||
/project/<id>/view/<name>/settings | 200 patch allowed view settings | ||
/project/<id>/view/<name>/name | 200 rename, 409 on collision | ||
/project/<id>/commissioning | 200 array | ||
/project/<id>/commissioning/<groupId> | 200 | 201 upsert | 200 |
/project/<id>/commissioning/<groupId>/rules | 201 append one rule | ||
/project/<id>/commissioning/<groupId>/rules/<index> | 201 remove rule at index |
Applications
| Path | GET | PUT | DELETE |
|---|---|---|---|
/apps | 200 array of file names | ||
/apps/<id> | 200 raw bytes, application/octet-stream | 201 store the raw body | 200 / 404 |
/apps is a plain keyed blob store under /etc/muxen/apps/. It is not part of any project, is not deployed, and is not covered by the daily backup.
Path segment rules
| Segment | Pattern | Notes |
|---|---|---|
project <id> | [a-zA-Z0-9-]+ | also re-validated by the handler; 64 characters or more is rejected |
<deviceId> | decimal, 0–4095 | function × 64 + instance, function and instance 0–63 each |
legend <zone> | decimal, 16–35, then an optional .<REGION> | the BPU 8S e-paper slots; the bulk legends body takes the same range |
setting and parameter <name> | [a-zA-Z0-9.-]+ | dot allowed |
every other <name> / <id> | [a-zA-Z0-9-]+ | |
<index> | decimal, 0 to the rule count − 1 | position in the group's rules array |
The numeric segments are parsed strictly: decimal digits only, no sign, no leading or trailing characters, no overflow, and in range. Anything else — 4096, -1, 57abc, +57, a 20-digit number — is answered 400 before the project is touched, so the file stays byte-identical. They used to go through atoi(): PUT …/device/12345 was stored as 12345 and read back, masked, as device 57.
Caching
| Endpoint | Validator | cache-control |
|---|---|---|
GET /deploy | ETag: SHA-256 of the bytes of deploy.json, quoted | max-age=0, no-cache |
GET /project/<id>/media/<name> | ETag from the media's stored metadata.etag, sent only when present | max-age=60, no-cache |
Both honour If-None-Match and answer 304 with an empty body.
Transactions
POST /project/<id>/transaction takes a JSON array of steps:
| Key | Type | Required | Meaning |
|---|---|---|---|
url | string | yes | the path the step would have been sent to |
method | string | yes | GET, PUT, POST or DELETE, case-insensitive |
data | any JSON | no | the step's request body |
Behaviour:
- Steps run in order against one in-memory copy of the project. The file is written once, after the last step.
- A step missing
urlormethodis skipped without an error. - A step whose path matches no route gets
404. - The first step whose code is outside
200–299stops the run; the request answers500and nothing is saved. - Each step contributes three response headers, numbered from 1:
X-Step-URL-<n>,X-Step-Method-<n>,X-Step-Code-<n>. - On success the request answers
200.
One sharp edge: the project id inside each step's url is ignored. The project is opened once from the transaction's own URL, and every step operates on it whatever id its path names.
The bundled Swagger UI
The daemon serves /usr/share/muxen-config/swagger/ at its root, so http://127.0.0.1:12000/ — or /api/config/ through nginx — is a browsable API console.
Its swagger.json describes the project, settings, device, device parameter, sensor, media, muxing and button-mapping routes, and /info. It does not describe /health, /deploy, /apps, the transaction, view, energy, synapse or commissioning routes; this chapter is the complete list.
TypeScript packages
MUXEN also publishes three npm packages to the @muxen scope on the MUXEN GitLab registry. They share the daemon's version.
| Package | What it is |
|---|---|
@muxen/project | typed project model plus two Pinia stores: useConfigAPIStore (default endpoint /api/config) and useConfigDeployStore (default endpoint /api/deploy.json) |
@muxen/units | the unit database and conversions |
@muxen/muxing-parameters | turns a muxing form into the list of device parameters the switching rule needs |
useConfigAPIStore implements the authentication described above: setProjectPassword() hashes with SHA-256 and stores Bearer <hash>, keeping it in localStorage under raken-last-open-authorization so a reload can restore it with restoreAuthFromStorage(); clearAuth() removes it.
useConfigDeployStore reads the stripped deployment and fetches media and view bodies individually as they are needed.
@muxen/project also exports the daemon identity types: ConfigInfo (the GET /info body), DaemonInfo (the app/config/info payload), isDaemonInfo() and CONFIG_INFO_TOPIC. The package talks to configd over HTTP only and does not subscribe to the topic itself.
