raptor
raptor is a hawkBit-compatible over-the-air (OTA) update server, written in Rust. It speaks hawkBit’s DDI v1 device API — so SWUpdate, the RAUC hawkbit-updater, and other hawkBit clients work unchanged — and the core hawkBit Management API workflow, all from a single static binary and one config file.
Where a stock hawkBit deployment is Java + a relational database + RabbitMQ, raptor is one process backed by SQLite or Postgres. No broker, no JVM, no servlet container.
Highlights
- Drop-in DDI v1 — devices poll, download, and report feedback exactly as they do against hawkBit.
- Core Management API — targets, software modules, artifacts, distribution
sets, assignments, actions, rollouts, and target filters, with hawkBit-shaped
JSON, paging, sorting, and FIQL
q=filtering. - Rollouts — staged, threshold-driven group deployments.
- Target filters + auto-assignment — saved FIQL queries that assign a distribution set to matching devices automatically.
- Confirmation flow — optional device/operator confirmation before a deployment starts.
- One binary — an embedded web console ships inside the executable; SQLite by default, Postgres when you need it.
- Packaged — a Debian package with a hardened systemd unit.
Where to go next
- New here? Start with What is raptor? and the Quick Start.
- Ready to run it? See Installation and Your First Deployment.
- Coming from hawkBit? Read hawkBit Compatibility to see what is and isn’t implemented.
Note: raptor is young and its surface is a subset of hawkBit’s. The compatibility matrix is the source of truth for what exists today.
What is raptor?
raptor is a Rust reimplementation of the Eclipse hawkBit server, targeting drop-in API compatibility with the parts of hawkBit that device fleets use every day.
The problem it solves
hawkBit is the de-facto open-source backend for embedded/IoT software updates.
Its device-facing DDI protocol is spoken by mature clients — SWUpdate’s
suricatta module, the RAUC hawkbit-updater, and Collabora’s hawkbit Rust crate.
But a production hawkBit deployment brings a heavy operational footprint: a JVM,
a servlet container, a relational database, and (for the DMF device path)
RabbitMQ.
raptor keeps the API and drops the weight:
| hawkBit | raptor | |
|---|---|---|
| Runtime | JVM / Spring Boot | single static binary |
| Database | MySQL / Postgres | SQLite or Postgres |
| Message broker | RabbitMQ (DMF) | none |
| Device API | DDI v1 + DMF | DDI v1 |
| Deployment | multi-service | one process, one config file |
Because raptor implements hawkBit’s DDI v1 contract field-for-field, existing devices do not know the difference — you point them at raptor’s URL and they poll, download, and report just as before.
What raptor is
- A device update server: it tells devices what to install, serves the artifacts, and records their feedback.
- A fleet management API: create targets, upload firmware, compose distribution sets, assign updates, and run staged rollouts over REST.
- A single-fleet, single-tenant server. It answers to exactly one tenant
name on hawkBit’s DDI tenant URL segment (default
DEFAULT) and rejects any other; there is no tenant isolation. Run one raptor per fleet.
What raptor is not (yet)
raptor implements a growing subset of hawkBit. It does not currently provide the DMF (AMQP) device path, multi-tenancy, tags, target types, metadata endpoints, maintenance windows, or the full set of action types. See hawkBit Compatibility for the authoritative list.
Design principles
- Compatibility is the contract. Clients branch on HTTP status codes and JSON field names; raptor matches hawkBit’s exactly, backed by golden-fixture and end-to-end tests against a real hawkBit client.
- One binary, one config file. Everything — including the web console — ships in the executable. Configuration is a single TOML file with environment overrides.
- Boring persistence. One SeaORM entity/migration set targets both SQLite and Postgres; migrations run automatically at startup.
Quick Start
This gets a raptor server running and deploys an update to a (virtual) device in a few commands. For production installs, see Installation.
1. Build and configure
$ cargo build --release
$ printf 'yourpassword\n' | ./target/release/raptor hash-password
$argon2id$v=19$m=19456,t=2,p=1$...
Put that hash into a raptor.toml:
bind = "0.0.0.0:8088"
database_url = "sqlite://raptor.db?mode=rwc" # or postgres://user:pass@host/db
artifact_dir = "./artifacts"
[ddi]
anonymous = true # dev only — no device auth
[mgmt]
username = "admin"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
Warning:
anonymous = truedisables all device authentication. Use it for local experiments only. See Authentication for production setups.
2. Run the server
$ ./target/release/raptor serve --config raptor.toml
raptor listening bind=0.0.0.0:8088
The database is created and migrated automatically on first start.
3. Deploy an update
Using the Management API (HTTP Basic with the admin credentials):
# 1. a software module
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/softwaremodules \
-H 'Content-Type: application/json' \
-d '[{"name":"rootfs","version":"1.0","type":"os"}]'
# 2. an artifact on module 1
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/softwaremodules/1/artifacts \
-F 'file=@rootfs.img'
# 3. a distribution set bundling module 1
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/distributionsets \
-H 'Content-Type: application/json' \
-d '[{"name":"release","version":"1.0","type":"os","modules":[{"id":1}]}]'
# 4. assign DS 1 to a device (auto-registered on first poll)
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/targets/my-device/assignedDS \
-H 'Content-Type: application/json' -d '{"id":1,"type":"forced"}'
4. Poll as the device
$ curl localhost:8088/DEFAULT/controller/v1/my-device
{"config":{"polling":{"sleep":"00:05:00"}},
"_links":{"deploymentBase":{"href":".../deploymentBase/1"},
"configData":{"href":".../configData"}}}
The deploymentBase link tells the device an update is waiting. From here a real
hawkBit client downloads the artifacts and posts feedback. Walk through the full
cycle in Your First Deployment.
hawkBit Compatibility
raptor implements a subset of hawkBit, chosen to cover the device update workflow first. This page is the source of truth for what exists today.
Baseline: this matrix is measured against hawkBit 1.x. hawkBit reached 1.0 in April 2026 and 1.1.0 in July, removing some features along the way — rows below reflect that, not the older 0.x contract.
Device API (DDI v1)
The DDI v1 contract is implemented field-for-field and verified with golden fixtures and an end-to-end test against a real hawkBit DDI client.
| Feature | Status |
|---|---|
Poll root, config.polling.sleep, _links | ✅ |
deploymentBase (download/update modes, chunks, artifacts) | ✅ |
| Deployment feedback state machine | ✅ |
cancelAction + cancel feedback | ✅ |
configData (attributes: merge / replace / remove) | ✅ |
installedBase | ✅ |
| Artifact download with HTTP Range (resume) | ✅ |
.MD5SUM companion endpoint | ✅ |
| Auto-registration (gateway token / anonymous¹) | ✅ |
confirmationBase confirmation flow | ✅ |
Maintenance windows (deployment.maintenanceWindow) | ✅ |
| DMF (AMQP) device path | ❌ (#11) |
pollingTime RSQL-matched overrides (hawkBit 0.10) | ✅ |
¹ hawkBit 0.8 removed anonymous controller support and anonymous download. raptor keeps anonymous mode as a raptor extension (useful for dev/lab setups), not a hawkBit 1.x compatibility item — see the Auth table.
Management API
| Area | Status |
|---|---|
Targets CRUD, assignedDS, installedDS, actions, attributes | ✅ |
| Software modules CRUD + multipart artifact upload/list/download/delete | ✅ |
| Distribution sets CRUD + module composition | ✅ |
| Actions (per-target and fleet-wide list/filter) | ✅ |
| Rollouts (create/start/pause/resume/stop/delete, deploy groups) | ✅ |
| Target filters + auto-assignment | ✅ |
| Per-target auto-confirm | ✅ |
FIQL filter targets by auto-confirm status (autoConfirm==, hawkBit 1.1) | ✅ |
Target groups (group attribute, q=group==) | ✅ |
Paging (offset/limit), sort=, q= FIQL on lists | ✅ |
Software-module / distribution-set / target types CRUD (composition drives complete; target-type/DS-type compatibility enforced) | ✅ |
Target / distribution-set tags CRUD, assign/unassign, q=tag==x | ✅ |
Metadata endpoints (targets / modules / DS, targetVisible on module entries) | ✅ |
All four action types + force escalation (PUT .../actions/{id}) and force-quit (DELETE ...?force=true) | ✅ |
Rollout approval workflow (rollout_approval_enabled, approve/deny with remark; raptorctl rollout approve|deny) | ✅ |
Dynamic rollouts (dynamic, dynamicGroupTemplate; trailing group absorbs newly-matching targets, rolls over at capacity, never self-completes) | ✅ (amountGroups: 0 “pure dynamic” rollouts, with no static groups at all, are not accepted — #147) |
| Maintenance windows on direct assignments | ✅ (hawkBit’s own Management API has no maintenanceWindow field on rollout creation or target-filter auto-assignment to be at parity with — see #116) |
Per-entity quotas ([quota], hawkBit’s defaults, 429 on breach) | ✅ |
Automatic action cleanup ([cleanup], action.cleanup.auto.*) | ✅ (raptor additionally keeps rollout progress stable across a sweep, which upstream does not) |
| Multi-assignment / action weights | removed upstream in hawkBit 0.10; not planned (#10) |
Wire-format alignment with hawkBit 0.10: successful deletes return 204 No Content — raptor’s mgmt delete handlers already do. Quota violations return
429 with hawkbit.server.error.quota.tooManyEntries, which raptor matches —
see the [quota] section.
Action types
hawkBit has forced, soft, downloadonly, and timeforced. raptor models all
four. timeforced starts out soft and escalates once its deadline passes;
downloadonly forces the download and never asks the device to install. An
operator can escalate a running action to forced with
PUT /rest/v1/targets/{controllerId}/actions/{actionId}, and force-quit one that
the device is not acknowledging with DELETE …/actions/{actionId}?force=true.
Auth
| Mechanism | Status |
|---|---|
| DDI target security token | ✅ |
| DDI shared gateway token | ✅ |
| DDI anonymous mode (raptor extension¹) | ✅ |
| Management API HTTP Basic (single admin) | ✅ |
| Session cookie for the web console | ✅ |
| mTLS / certificate DDI auth | ❌ (#13) |
| Multiple users / roles, OIDC | ❌ (#13) |
Tenancy
raptor is single-tenant (#12).
It answers to exactly one tenant on the DDI URL’s /{tenant}/controller/v1/...
segment — the tenant config key, default DEFAULT — and rejects any other
segment with 404, matching hawkBit’s own behavior. There is no per-tenant
data isolation — run one raptor instance per fleet. The schema does carry a
tenant column on every query-root table so isolation can land later without
a migration; see the
design doc.
Note: Items marked ❌ link to their tracking issue on the GitHub repository. The schema is designed so these can be added without breaking existing deployments.
Installation
raptor is a single binary. You can build it from source or install the Debian package.
Debian / Ubuntu package
Prebuilt .deb packages are attached to each
GitHub Release:
$ sudo dpkg -i raptor_*.deb
The package installs:
/usr/bin/raptor— the server, with the web console embedded/etc/raptor/config.toml— default config, registered as a dpkg conffile (your edits survive upgrades)/usr/lib/systemd/system/raptor.service— a hardened systemd unit running as a transientDynamicUser, with state (SQLite DB + artifacts) under/var/lib/raptor
The service is enabled but not started on install, because you must set an admin password first:
$ raptor hash-password # type a password, copy the hash
$ sudoedit /etc/raptor/config.toml # paste into password_hash, pick DDI auth
$ sudo systemctl start raptor
See Running as a systemd Service for the unit’s hardening details and how to supply secrets safely.
From source
You need a recent stable Rust toolchain.
$ git clone https://github.com/rosterloh/raptor
$ cd raptor
$ cargo build --release
$ ./target/release/raptor --version
The binary lands at target/release/raptor.
Building with the web console
The embedded web console is behind the embed-ui Cargo feature. It requires the
Dioxus CLI (dx) to build the WASM bundle first:
$ cargo binstall dioxus-cli@0.7.10
$ dx build --release --package raptor-ui
$ cargo build --release --features embed-ui
Without embed-ui, the server runs identically but does not serve /ui.
Building the Debian package yourself
$ cargo install cargo-deb
$ dx build --release --package raptor-ui # populate the embedded UI
$ cargo deb -p raptor # -> target/debian/raptor_*.deb
Database
raptor supports SQLite and Postgres, selected by the database_url
scheme. Migrations run automatically at startup, so there is no separate migrate
step.
- SQLite:
sqlite://raptor.db?mode=rwc(themode=rwccreates the file) - Postgres:
postgres://user:pass@host/dbname
Next steps
- Configuration — the
raptor.tomlfile - Your First Deployment — an end-to-end walkthrough
Configuration
raptor reads a single TOML file (default raptor.toml, override with
--config). Every key can also be set via a RAPTOR_* environment variable,
which is useful for secrets and containers.
Minimal config
bind = "0.0.0.0:8088"
database_url = "sqlite://raptor.db?mode=rwc" # or postgres://user:pass@host/db
artifact_dir = "./artifacts"
[ddi]
gateway_token = "change-me" # or anonymous = true for dev
[mgmt]
username = "admin"
password_hash = "$argon2id$..." # from `raptor hash-password`
Only database_url, artifact_dir, and the [mgmt] credentials are required;
everything else has a default. See the
Configuration Reference for every key and its
default.
Environment overrides
Any key maps to an environment variable prefixed with RAPTOR_, with nested
tables joined by a double underscore (__):
export RAPTOR_BIND="127.0.0.1:9090"
export RAPTOR_DDI__ANONYMOUS=true
export RAPTOR_DDI__GATEWAY_TOKEN="super-secret"
export RAPTOR_MGMT__PASSWORD_HASH='$argon2id$...'
Environment values override the TOML file. This is the recommended way to inject
secrets — a plaintext gateway token in a world-readable config file is a
liability (the Debian package addresses this with a root-only raptor.env; see
Running as a systemd Service).
Generating the admin password hash
The Management API and web console authenticate against a single admin credential stored as an argon2id hash:
$ printf 'yourpassword\n' | raptor hash-password
$argon2id$v=19$m=19456,t=2,p=1$...
Copy the output into mgmt.password_hash.
Key groups at a glance
| Section | Purpose |
|---|---|
| top level | bind, database_url, artifact_dir, max_artifact_size, url, rollout_eval_interval_secs, rollout_approval_enabled |
[ddi] | device-facing auth and polling: anonymous, gateway_token, polling_interval, confirmation_flow |
[quota] | per-entity growth caps mirroring hawkBit’s; 0 disables one |
[cleanup] | automatic deletion of old closed actions; off by default |
[mgmt] | Management API / web console admin: username, password_hash |
The url key
By default the _links in API responses are derived from the incoming request’s
Host header. If raptor sits behind a reverse proxy that rewrites the host, set
url to the externally visible base so devices receive dialable links:
url = "https://raptor.example.com"
Your First Deployment
This walks through a complete update cycle: create the content, assign it, and
watch a device install it. It uses curl for both the operator (Management API)
and the device (DDI API) sides.
Assume raptor is running on localhost:8088 with admin admin:yourpassword and
ddi.anonymous = true (so we can poll without a device token).
1. Create a software module
A software module is a named, versioned unit of a given type (os,
firmware, runtime, or application).
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/softwaremodules \
-H 'Content-Type: application/json' \
-d '[{"name":"rootfs","version":"1.0","type":"os"}]'
# -> [{"id":1, ...}]
2. Upload an artifact
Artifacts are uploaded as multipart form data. raptor streams the bytes to disk and computes the SHA-1, MD5, and SHA-256 hashes as it goes.
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/softwaremodules/1/artifacts \
-F 'file=@rootfs.img'
# -> {"id":1,"providedFilename":"rootfs.img","size":..., "hashes":{...}}
3. Compose a distribution set
A distribution set (DS) bundles one or more modules into a releasable unit. A
DS is complete once it has the modules its type requires.
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/distributionsets \
-H 'Content-Type: application/json' \
-d '[{"name":"release","version":"1.0","type":"os","modules":[{"id":1}]}]'
# -> [{"id":1,"complete":true, ...}]
4. Assign the DS to a target
You do not need to create the target first — an unknown controller ID is auto-registered on its first poll. Assigning creates an action (the record of one deployment).
curl -u admin:yourpassword -X POST localhost:8088/rest/v1/targets/my-device/assignedDS \
-H 'Content-Type: application/json' -d '{"id":1,"type":"forced"}'
# -> {"assigned":1,"alreadyAssigned":0,"total":1,"assignedActions":[{"id":1}]}
5. Device poll
The device polls the DDI root. Because an action is pending, the response carries
a deploymentBase link.
curl localhost:8088/DEFAULT/controller/v1/my-device
# _links.deploymentBase -> .../deploymentBase/1
6. Fetch the deployment
curl localhost:8088/DEFAULT/controller/v1/my-device/deploymentBase/1
The response describes the download/update modes and lists each module’s artifacts with hashes, sizes, and download links. A real client (SWUpdate, RAUC) downloads the artifacts from those links (which support HTTP Range for resume).
7. Report feedback
The device reports progress, then a final result. Feedback drives the action state machine.
# progress
curl -X POST localhost:8088/DEFAULT/controller/v1/my-device/deploymentBase/1/feedback \
-H 'Content-Type: application/json' \
-d '{"status":{"execution":"proceeding","result":{"finished":"none"}}}'
# success
curl -X POST localhost:8088/DEFAULT/controller/v1/my-device/deploymentBase/1/feedback \
-H 'Content-Type: application/json' \
-d '{"status":{"execution":"closed","result":{"finished":"success"}}}'
On closed/success the action becomes finished and the target’s
updateStatus becomes in_sync. A subsequent poll no longer offers a
deploymentBase, and installedBase now reflects the installed DS.
8. Verify
curl -u admin:yourpassword localhost:8088/rest/v1/targets/my-device
# "updateStatus": "in_sync"
curl -u admin:yourpassword localhost:8088/rest/v1/targets/my-device/actions
# the action is "finished"
That’s a full cycle. From here, explore Rollouts to stage a deployment across many devices, or Target Filters to assign automatically.
Targets & Auto-Registration
A target is a device raptor can update, identified by a unique
controllerId. Targets carry a security token, a reported set of attributes, and
an updateStatus.
Update status
Every target has an updateStatus reflecting where it is in the update cycle:
| Status | Meaning |
|---|---|
unknown | created via the Management API, never polled |
registered | known to the server, no update assigned |
pending | an update is assigned and in progress |
in_sync | running the assigned distribution set |
error | the last deployment failed |
Creating targets
Explicitly (Management API)
curl -u admin:pw -X POST localhost:8088/rest/v1/targets \
-H 'Content-Type: application/json' \
-d '[{"controllerId":"device-42","name":"Device 42"}]'
The request body is an array, so you can create many at once. A securityToken
is generated if you don’t supply one.
Automatically (auto-registration)
An unknown controllerId that polls the DDI API is created on the spot with
status registered — hawkBit’s plug-and-play behavior. Auto-registration
requires the poll to be authenticated by the shared gateway token, or DDI
anonymous mode to be on. See Authentication.
Listing and filtering
The list endpoint supports paging, sorting, and FIQL:
curl -u admin:pw 'localhost:8088/rest/v1/targets?offset=0&limit=50&sort=controllerId:ASC'
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=updateStatus==error'
Filterable fields include controllerId (alias id), name, description,
updateStatus, lastControllerRequestAt, address, and group. See
Filtering with FIQL.
Groups
A target can carry one group: its organisational placement in the fleet —
which plant, which customer, which vehicle line. It is a plain string, and a
/ in it is just a character, so nesting is a naming convention rather than a
structure the server enforces.
# set one at registration, or move a device later
curl -u admin:pw -X POST localhost:8088/rest/v1/targets \
-H 'Content-Type: application/json' \
-d '[{"controllerId": "dev-1", "group": "plant-a/line-3"}]'
curl -u admin:pw -X PUT localhost:8088/rest/v1/targets/dev-1 \
-H 'Content-Type: application/json' -d '{"group": "plant-b/line-1"}'
Because == supports * wildcards, the naming convention is what makes a
hierarchy queryable — plant-a/* matches every line in plant A:
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=group==plant-a/*'
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=group==plant-a/*;updateStatus==error'
The same query works in a saved target filter and in a rollout, since all three share one FIQL compiler.
Groups vs. tags vs. types
The three look similar and answer different questions:
| How many per target | Constrains anything | Answers | |
|---|---|---|---|
| Group | at most one | no | where does this device sit in the fleet |
| Tags | many | no | what is true about this device right now |
| Target type | at most one | yes — which DS types may be assigned | what kind of device is it |
A device belongs to one plant but can be both beta and field-trial; the type
is the only one of the three that can refuse an assignment.
Omitting group from a PUT leaves the current one unchanged. As with
description, there is no way to clear it back to unset through the update
body.
Last-seen address
A target’s address / ipAddress is recorded from its DDI polls, so a device
that self-registers shows one without any Management API call. By default it’s
the socket peer address.
Behind a reverse proxy the socket peer is the proxy, so point raptor at the header carrying the real address:
[ddi]
trusted_proxy_header = "x-forwarded-for"
This is unset by default because a device can put whatever it likes in that
header — only set it when a proxy you control is rewriting it. raptor reads the
rightmost entry, the hop appended by the proxy directly in front of it;
entries to the left are caller-supplied and so spoofable. Values that don’t parse
as an IP are ignored. Both X-Forwarded-For lists and RFC 7239 Forwarded
(for=…) syntax are understood, with or without a port.
Attributes
Devices report key/value attributes (hardware revision, OS version, …) via
the DDI configData endpoint. Retrieve them with:
curl -u admin:pw localhost:8088/rest/v1/targets/device-42/attributes
# {"hw":"rev2","os":"linux"}
Attributes are set by the device, in three modes — merge (default), replace,
and remove — described in the DDI API reference.
Note: target attributes (device-reported) are distinct from hawkBit metadata (operator-set key/value pairs), which raptor does not yet implement.
Poll status
When a target has polled at least once, its representation includes a
pollStatus block with the last request time, the next expected request time
(derived from the configured polling interval), and an overdue flag — handy for
spotting devices that have gone quiet.
Software Modules & Artifacts
A software module is a named, versioned unit of updatable content. An artifact is a file belonging to a module (the actual firmware image, package, etc.).
Module types
raptor seeds four software-module types: os, firmware, runtime, and
application. Types are read-only in raptor (created via migration); you
reference them by key when creating a module.
curl -u admin:pw -X POST localhost:8088/rest/v1/softwaremodules \
-H 'Content-Type: application/json' \
-d '[{"name":"rootfs","version":"1.0","type":"os","vendor":"ACME"}]'
The combination of name + version + type is unique; a duplicate returns 409 Conflict.
Uploading artifacts
Artifacts are uploaded as multipart/form-data. raptor streams the upload to
disk while computing the SHA-1, MD5, and SHA-256 hashes in one pass:
curl -u admin:pw -X POST localhost:8088/rest/v1/softwaremodules/1/artifacts \
-F 'file=@rootfs.img'
The response includes the computed hashes and the byte size. Maximum upload size
is governed by max_artifact_size, and the number of artifacts one module may
hold by [quota] max_artifacts_per_software_module (default 50) — exceeding it
is a 429. See the
Configuration Reference.
Content-addressed storage
Blobs are stored once per SHA-256, laid out git-object-style:
<artifact_dir>/<sha256[0..2]>/<sha256>
Uploading identical content twice stores the bytes once and adds a second reference; the blob is removed from disk only when the last artifact row referencing it is deleted. This dedup is transparent — each module still sees its own artifact row with its own filename.
Listing, downloading, deleting
# list a module's artifacts
curl -u admin:pw localhost:8088/rest/v1/softwaremodules/1/artifacts
# download (operator side)
curl -u admin:pw -O localhost:8088/rest/v1/softwaremodules/1/artifacts/1/download
# delete
curl -u admin:pw -X DELETE localhost:8088/rest/v1/softwaremodules/1/artifacts/1
Deleting a module (DELETE /rest/v1/softwaremodules/{id}) removes its
metadata, artifact rows, and any blobs that lose their last reference. A module
that belongs to a distribution set is refused with 409 Conflict and nothing is
deleted — remove it from the set first, and note that a distribution set already
referenced by actions cannot be deleted either. Re-publishing content under a
version number that has already been rolled out therefore means bumping the
version rather than deleting and recreating.
Device-side download
Devices fetch artifacts through the DDI API, not the Management API:
GET /{tenant}/controller/v1/{cid}/softwaremodules/{moduleId}/artifacts/{filename}
This endpoint supports HTTP Range requests (RFC 7233), so an interrupted
download resumes rather than restarting — important for large firmware images
over flaky links. A companion {filename}.MD5SUM endpoint returns the
md5sum-file format some clients verify against.
Distribution Sets
A distribution set (DS) is the releasable unit you assign to devices: a named, versioned bundle of software modules.
DS types
raptor seeds three distribution-set types: os, os_app, and app. Like
module types, they are read-only. The type determines which module types a DS
needs to be considered complete.
Creating a distribution set
curl -u admin:pw -X POST localhost:8088/rest/v1/distributionsets \
-H 'Content-Type: application/json' \
-d '[{"name":"release","version":"1.0","type":"os","modules":[{"id":1}]}]'
You can pass modules inline (as above) or add them afterward:
curl -u admin:pw -X POST localhost:8088/rest/v1/distributionsets/1/assignedSM \
-H 'Content-Type: application/json' -d '[{"id":2}]'
Completeness
A DS is complete when it contains the modules its type requires. Only complete
distribution sets can be assigned or deployed — assigning an incomplete DS (or
attaching one as a target-filter auto-assignment) returns 400 Bad Request.
curl -u admin:pw localhost:8088/rest/v1/distributionsets/1
# {"id":1,"complete":true, "modules":[...], ...}
Listing and filtering
Distribution sets support the usual paging, sorting, and FIQL query parameters:
curl -u admin:pw 'localhost:8088/rest/v1/distributionsets?q=name==release*&sort=version:DESC'
Lifecycle
Distribution sets are referenced by actions and rollouts. raptor keeps the content-addressing scheme independent of DS identity, so the same artifact bytes can back many distribution sets without duplication.
Assignments & Actions
An action is the record of one deployment: a distribution set being rolled out to one target. Assigning a DS creates an action; the device’s feedback drives it to completion.
Assigning a distribution set
curl -u admin:pw -X POST localhost:8088/rest/v1/targets/device-42/assignedDS \
-H 'Content-Type: application/json' -d '{"id":1,"type":"forced"}'
The type is the action type. All four of hawkBit’s are supported, and each maps
to the download/update handling modes the device is given in
deploymentBase:
type | download | update | Meaning |
|---|---|---|---|
forced (default) | forced | forced | install as soon as possible |
soft | attempt | attempt | the device may defer per its own policy |
timeforced | attempt → forced | attempt → forced | soft until forcetime, forced after |
downloadonly | forced | skip | fetch the artifacts, do not install |
An unknown type is rejected with 400.
timeforced takes a forcetime (epoch millis) alongside the type:
curl -u admin:pw -X POST localhost:8088/rest/v1/targets/device-42/assignedDS \
-H 'Content-Type: application/json' \
-d '{"id":1,"type":"timeforced","forcetime":1767225600000}'
Before that instant the device sees attempt; after it, forced — no server-side
job is involved, the mode is computed per request. Omitting forcetime means
“already reached”, so the action behaves as forced immediately (matching
hawkBit’s default of 0). Note the request body spells it all-lowercase
forcetime while the action response uses forceTime; that asymmetry is
hawkBit’s and raptor mirrors it.
Maintenance windows
A maintenance window splits download from install: the device fetches the artifacts as soon as the action is assigned, but is told to hold the install until the window opens. Use it when the update itself is disruptive — a vehicle that must not reboot mid-journey, a machine that may only restart overnight.
curl -u admin:pw -X POST localhost:8088/rest/v1/targets/device-42/assignedDS \
-H 'Content-Type: application/json' -d '{
"id": 1, "type": "forced",
"maintenanceWindow": {
"schedule": "0 0 2 ? * MON",
"duration": "02:00:00",
"timezone": "+02:00"
}
}'
That window opens at 02:00 every Monday, in UTC+02:00, and stays open for two hours.
schedule— Quartz cron, not Unix cron. It leads with a seconds field (six fields, or seven with a trailing year), accepts?as the “no specific value” wildcard in the two day fields, and numbers weekdays 1 = Sunday through 7 = Saturday. A Unix-cron five-field expression is rejected rather than silently misread, but a valid expression using the other weekday numbering is not detectable —2means Monday here and Tuesday in Unix cron, so double-check day-of-week schedules.duration— how long the window stays open,HH:mm:ss, up to23:59:59.timezone— offset from UTC as±HH:mm. It is a fixed offset, not a named zone, so it does not follow daylight-saving transitions: a window set at+01:00in winter opens an hour early once summer time starts.
While the window is shut the device’s deploymentBase reports
update: "skip" alongside maintenanceWindow: "unavailable", with download
left at the action’s real mode. Once it opens, update becomes the action’s
real mode and maintenanceWindow reads "available". Nothing is scheduled
server-side — the state is computed per request, the same way timeforced
works — so a device simply polls and finds the window open.
The action echoes its window back to operators, with the next opening:
"maintenanceWindow": {
"schedule": "0 0 2 ? * MON", "duration": "02:00:00",
"timezone": "+02:00", "nextStartAt": 1767225600000
}
A window the server cannot evaluate — a malformed schedule, a duration that is
not HH:mm:ss, an offset that is not ±HH:mm, or a schedule pinned to a year
already past — is rejected with 400 at assignment time, so a device is never
handed a window that would leave it waiting forever.
Windows currently apply to direct assignments only; rollouts and target-filter auto-assignment do not carry one yet (#7).
A downloadonly action completes when the device reports downloaded
feedback rather than closed. Because nothing was installed, the target’s
installedDS is deliberately left untouched — only assignedDS reflects the
distribution set. The action ends with status: finished and
detailStatus: downloaded.
Escalating a running action
A soft or timeforced action can be pushed through immediately:
curl -u admin:pw -X PUT localhost:8088/rest/v1/targets/device-42/actions/7 \
-H 'Content-Type: application/json' -d '{"forceType":"forced"}'
The next deploymentBase the device fetches carries forced. Escalating an
action that is no longer active returns 410 Gone.
One active action per target
raptor enforces hawkBit’s default invariant: a target has at most one active action. Assigning a new DS to a target that already has an active action cancels the old one and starts the new deployment. (hawkBit’s opt-in multi-assignment mode with action weights is not implemented.)
Action states
| State | active | Meaning |
|---|---|---|
wait_for_confirmation | yes | awaiting confirmation before deploying (see Confirmation Flow) |
running | yes | device has been told to deploy |
canceling | yes | cancellation requested, awaiting device acknowledgement |
canceled | no | cancellation confirmed (or forced) |
finished | no | deployment succeeded |
error | no | deployment failed |
Each transition and every piece of device feedback appends an ActionStatus history row (with optional messages).
Inspecting actions
# all actions on one target (newest first)
curl -u admin:pw localhost:8088/rest/v1/targets/device-42/actions
# a single action
curl -u admin:pw localhost:8088/rest/v1/targets/device-42/actions/1
# fleet-wide, filterable
curl -u admin:pw 'localhost:8088/rest/v1/actions?q=detailStatus==error'
The action JSON exposes status (pending while active, else finished) and
detailStatus (the fine-grained state from the table above).
Status history
Every state change an action goes through — assignment, each piece of device feedback, cancellation — is recorded as a status entry. List them with:
# chronological (oldest first); pass ?sort=id:DESC for newest first
curl -u admin:pw localhost:8088/rest/v1/targets/device-42/actions/1/status
Each entry has a type (the reported status, e.g. running, finished,
canceled), any messages the device or server attached, and reportedAt.
The list supports the usual offset/limit/sort paging.
Two settings bound how much of this accumulates. [quota] max_status_entries_per_action caps how many entries one device may report
against a single action (default 1000), and [cleanup] deletes closed actions
and their history past a retention window — off by default. Both are in the
Configuration Reference.
Cancelling
# request cancellation (device must acknowledge)
curl -u admin:pw -X DELETE localhost:8088/rest/v1/targets/device-42/actions/1
# force-cancel server-side (no device acknowledgement)
curl -u admin:pw -X DELETE 'localhost:8088/rest/v1/targets/device-42/actions/1?force=true'
A normal cancel moves the action to canceling and offers the device a
cancelAction link; the device confirms via cancel feedback, moving it to
canceled. A forced cancel closes the action immediately.
Installed vs assigned
GET /rest/v1/targets/{cid}/assignedDS— the DS currently assigned (what the device should run).GET /rest/v1/targets/{cid}/installedDS— the DS the device last successfully installed.
Both return 204 No Content when there is nothing to report.
Rollouts
A rollout deploys a distribution set across many targets in stages, advancing from one group to the next only when success thresholds are met — so a bad update is caught on a small group before it reaches the whole fleet.
How it works
- You create a rollout from a FIQL target filter, a distribution set, and a number of groups. Matching targets are split evenly across the groups at creation time.
- Each group has a success threshold and an error threshold (percentages).
- Starting the rollout schedules the first group only — its targets get the DS assigned.
- A background evaluator watches each running group:
- When the error threshold is reached, the group and rollout pause.
- When the success threshold is reached, the group finishes and the next group is scheduled.
- When the last group finishes, the rollout is finished.
Creating a rollout
curl -u admin:pw -X POST localhost:8088/rest/v1/rollouts \
-H 'Content-Type: application/json' \
-d '{
"name": "fleet-1.1",
"distributionSetId": 1,
"targetFilterQuery": "controllerId==device-*",
"amountGroups": 3,
"successCondition": {"condition":"THRESHOLD","expression":"90"},
"errorCondition": {"condition":"THRESHOLD","expression":"20"}
}'
amountGroupssplits matching targets into that many groups.successCondition.expression/errorCondition.expressionare percentages (0–100). IferrorConditionis omitted, the error threshold never trips.typeis the action type every action the rollout creates inherits —forced(default),soft,timeforcedordownloadonly— withforcetimealongside it fortimeforced. A staged download-then-install is therefore adownloadonlyrollout followed by aforcedone over the same filter. An unknown type is rejected with400.
The rollout starts in ready — or in waiting_for_approval when the
approval gate is on.
Lifecycle operations
curl -u admin:pw -X POST localhost:8088/rest/v1/rollouts/1/start
curl -u admin:pw -X POST localhost:8088/rest/v1/rollouts/1/pause
curl -u admin:pw -X POST localhost:8088/rest/v1/rollouts/1/resume
curl -u admin:pw -X POST localhost:8088/rest/v1/rollouts/1/stop
curl -u admin:pw -X DELETE localhost:8088/rest/v1/rollouts/1
- start —
ready→running; schedules the first group. - pause —
running→paused; the evaluator ignores paused rollouts. - resume —
paused→running; re-evaluates immediately. - stop — any non-terminal status →
stopping→stopped; see below. - delete — cancels any active actions in the rollout and removes it.
Stopping a rollout
Pause only stops raptor from scheduling more groups — the updates already sent out keep running on the devices that have them. Stop is the abort: it cancels those in-flight updates as well, and is terminal (a stopped rollout cannot be resumed; create a new one).
The cancellation is soft, so devices are told rather than cut off:
- Every active action the rollout issued moves to
cancelingand is served to the device ascancelActionon its next poll. Groups that had not finished are markedstopped; ones that already finished keep their outcome. - The rollout reports
stoppingwhile those cancels are outstanding. - As each device acknowledges over DDI, its action becomes
canceled. Once none are left active the rollout settles tostopped.
A rollout with nothing left in flight — every device it reached is already
finished — goes straight to stopped.
A device that is offline holds the rollout in stopping until it polls again.
That is the honest state: the update has not been called off out in the fleet
yet. To close one out without waiting, force-cancel its action
(DELETE /rest/v1/targets/{cid}/actions/{aid}?force=true).
Stop is accepted from any status that is not already terminal or draining —
ready, waiting_for_approval, approval_denied, running and paused,
matching hawkBit’s ROLLOUT_STATUS_STOPPABLE. A rollout that never started has
no actions to cancel, but stopping it is how you retire it while keeping the
record; deleting it throws that record away. It is rejected with 400 from
stopping, stopped and finished, so a second stop is an error.
Approval workflow
By default a rollout is created ready and an operator can start it straight
away. Set rollout_approval_enabled = true to put a second pair of eyes in
front of that:
rollout_approval_enabled = true
A rollout created with the gate on lands in waiting_for_approval instead.
start on it is refused until someone decides:
# Approve — the rollout moves to `ready` and can now be started.
curl -u admin:pw -X POST \
"localhost:8088/rest/v1/rollouts/1/approve?remark=checked+with+ops"
# Or deny it, permanently.
curl -u admin:pw -X POST \
"localhost:8088/rest/v1/rollouts/1/deny?remark=fleet+is+frozen"
Both take an optional remark query parameter and answer 204 No Content, so
re-read the rollout to see the outcome. The decision is reported on the rollout
as approveDecidedBy and approvalRemark — the asymmetric spelling is
hawkBit’s own, and raptor matches it.
Denial is terminal: approval_denied is not a startable status and nothing
transitions out of it, so a denied rollout can only be deleted. There is no
“undeny” — create a fresh rollout instead. Because raptor authenticates a
single operator account, approveDecidedBy is always that account’s username.
The flag is reported to clients as hawkBit’s rollout.approval.enabled tenant
config key on GET /rest/v1/system/configs.
Inspecting groups
# deploy groups with per-group status and target counts
curl -u admin:pw localhost:8088/rest/v1/rollouts/1/deploygroups
# one group
curl -u admin:pw localhost:8088/rest/v1/rollouts/1/deploygroups/5
# the controllerIds in a group
curl -u admin:pw localhost:8088/rest/v1/rollouts/1/deploygroups/5/targets
Tracking progress
Rollouts and groups both carry totalTargetsPerStatus, hawkBit’s breakdown of
their targets by deployment outcome:
{
"id": 1, "name": "fleet-1.1", "status": "running", "totalTargets": 9,
"totalTargetsPerStatus": {
"notstarted": 0, "scheduled": 3, "running": 3,
"error": 1, "finished": 2, "cancelled": 0
}
}
notstarted— the rollout has not been started, so nothing is deployed yet.scheduled— the group is waiting its turn; raptor creates actions only when a group is scheduled, so these targets have no action yet.running— an action is in flight (includingcancelingand, with the confirmation flow on,wait_for_confirmation).finished/error/cancelled— the action’s terminal state.
A rollout’s counts are the sum of its groups’. The web console renders both as progress bars — see the Web Console guide.
Evaluator cadence
The background evaluator runs every rollout_eval_interval_secs seconds
(default 5). Lower it for snappier progression in testing, raise it to reduce
load on large fleets. See the
Configuration Reference.
Dynamic rollouts
By default a rollout’s membership is a snapshot: the targets matching the filter at creation time, and no others. A device that registers an hour later is not part of it, however well it matches.
Setting dynamic: true appends a trailing dynamic group that keeps
absorbing targets as they start matching:
curl -u admin:pw -X POST localhost:8088/rest/v1/rollouts \
-H 'Content-Type: application/json' \
-d '{
"name": "fleet-1.1",
"distributionSetId": 1,
"targetFilterQuery": "controllerId==device-*",
"amountGroups": 3,
"successCondition": {"condition":"THRESHOLD","expression":"90"},
"dynamic": true,
"dynamicGroupTemplate": {"nameSuffix": "-dynamic", "targetCount": 20}
}'
That creates group-1, group-2, group-3 as usual plus group-4-dynamic,
which is empty at first. The static groups run in order exactly as before; when
the last of them finishes, the dynamic group starts and begins taking in
newcomers. A target absorbed while the group is running is deployed to
immediately, on the same action type and forced time as every other target of
the rollout.
dynamicGroupTemplate is optional and only allowed when dynamic is true
(otherwise the request is rejected, rather than silently ignored):
| Field | Meaning | Default |
|---|---|---|
targetCount | how many targets one dynamic group takes before the next is opened | the size of the last static group |
nameSuffix | appended to the generated group-<n> name | none |
What to expect
- A dynamic rollout never finishes on its own. There may always be another
device about to match, so the trailing group stays
runningno matter how many of its targets succeed. Ending one is an operator action:POST /rest/v1/rollouts/{id}/stop, orraptorctl— see below. - Groups do not reopen. Newcomers only ever land in the trailing group; a group that has finished stays finished.
- Full groups roll over. Once the trailing group holds
targetCounttargets, a new dynamic group opens behind it and the full one completes on its own thresholds. Numbering and the name suffix carry on (group-4-dynamic,group-5-dynamic, …), up to themax_rollout_groups_per_rolloutquota — at which point absorbing stops and a warning is logged. - Thresholds are measured against the group’s capacity, not against however many targets have landed in it so far. Otherwise a single early success would cross a 50% threshold in a group of one.
- Absorbing stops if the distribution set is invalidated. Withdrawing a
release with
ds invalidatekeeps it from being drawn onto further devices; usecancelRolloutsto stop the rollout itself as well. - A target that cannot take the set — an incompatible target type — is skipped with a warning rather than failing the sweep.
Absorbing starts as soon as the rollout does, not when the trailing group’s turn
comes: while the static groups ahead of it are still running, newcomers join the
dynamic group and count towards its totalTargets, but no action is issued
until the group itself starts. That is the same deal a target in group-3 gets
while group-1 is running.
Target Filters & Auto-Assignment
A target filter is a saved FIQL query. On its own it’s a convenient, named way to select devices. Attach a distribution set to it, and it becomes an auto-assignment rule: matching targets receive that DS automatically — now, and as new matching devices appear.
Creating a filter
curl -u admin:pw -X POST localhost:8088/rest/v1/targetfilters \
-H 'Content-Type: application/json' \
-d '{"name":"beta-ring","query":"controllerId==beta-*"}'
The query is validated against the target field map at write time — an invalid
FIQL expression returns 400 Bad Request, and a duplicate name returns 409 Conflict. Update, fetch, list, and delete follow the usual REST shape:
curl -u admin:pw -X PUT localhost:8088/rest/v1/targetfilters/1 \
-H 'Content-Type: application/json' -d '{"query":"name==beta-*"}'
curl -u admin:pw localhost:8088/rest/v1/targetfilters
curl -u admin:pw -X DELETE localhost:8088/rest/v1/targetfilters/1
Attaching an auto-assign distribution set
curl -u admin:pw -X POST localhost:8088/rest/v1/targetfilters/1/autoAssignDS \
-H 'Content-Type: application/json' \
-d '{"id":1,"type":"forced"}'
id— the distribution set to assign. It must be complete (400otherwise).type—forced(default) orsoft.
Attaching immediately assigns the DS to every currently-matching target. Read or detach the attachment with:
curl -u admin:pw localhost:8088/rest/v1/targetfilters/1/autoAssignDS # -> the DS, or 204
curl -u admin:pw -X DELETE localhost:8088/rest/v1/targetfilters/1/autoAssignDS
From the web console
Everything above is also available under Target filters in the web console:
- New filter / Edit — name plus FIQL query. The query is checked against
the live target list as you type, so the form shows how many targets it matches
(or the parser’s complaint) before you save; the API’s
400/409messages are shown against the query and name fields. - Auto-assign… — pick a distribution set and the action type (
forcedorsoft), or Detach to remove the attachment. An incomplete set is reported inline rather than as a raw failure. - The Auto-assign column names the attached set, so you can see which filters are live rules at a glance.
When auto-assignment runs
A matching target receives the DS:
- On registration — a device auto-registering via DDI that matches a filter
is assigned in the same poll (its very first poll can already return a
deploymentBase). - On attribute change — updating a target via
configDatare-evaluates the filters. - Periodically — a background sweep (sharing the rollout evaluator’s task) catches targets created through other paths.
Non-disruptive by design
Auto-assignment never disturbs work in flight. A matching target is skipped if it already has that DS assigned, or if it has any active action. So a device mid-deployment is never clobbered by an auto-assign rule; it’s picked up on a later sweep once it’s idle.
Note: FIQL auto-assignment matches on the standard target fields (
controllerId,name,updateStatus, …). Matching on device-reported attributes is not yet supported.
Tags
A tag is a free-form label you attach to targets or distribution sets:
beta, eu-west, kiosk, qa. Tags carry no behaviour of their own — they
exist so you can organise a fleet and then select it again with the tag== FIQL
term, in list queries, saved target filters and rollouts.
Target tags and distribution-set tags are separate namespaces: a beta target
tag and a beta DS tag are unrelated tags.
Creating tags
The create body is an array, like the other Management API create endpoints:
curl -u admin:pw -X POST localhost:8088/rest/v1/targettags \
-H 'Content-Type: application/json' \
-d '[{"name":"beta","description":"early access","colour":"#00ff00"}]'
name is required and unique (409 Conflict on a duplicate); description and
colour are optional. Distribution-set tags are identical, at
/rest/v1/distributionsettags.
Fetch, update and delete follow the usual REST shape. PUT only changes the
fields present in the body:
curl -u admin:pw localhost:8088/rest/v1/targettags/1
curl -u admin:pw -X PUT localhost:8088/rest/v1/targettags/1 \
-H 'Content-Type: application/json' -d '{"colour":"#ff0000"}'
curl -u admin:pw -X DELETE localhost:8088/rest/v1/targettags/1
Deleting a tag deletes its assignments, not the tagged entities. Removing
the beta tag leaves every target that carried it in place, simply untagged.
Tag lists support the usual paging, sort= and q= parameters over id,
name, description and colour:
curl -u admin:pw 'localhost:8088/rest/v1/targettags?q=name==be*&sort=name:ASC'
Assigning tags
Assign one target by controller id, or several at once with a JSON array body:
curl -u admin:pw -X POST localhost:8088/rest/v1/targettags/1/assigned/dev-1
curl -u admin:pw -X POST localhost:8088/rest/v1/targettags/1/assigned \
-H 'Content-Type: application/json' -d '["dev-2","dev-3"]'
Assignment is idempotent — re-assigning an already-tagged target succeeds and
changes nothing, so bulk calls are safe to retry. An unknown controller id fails
the whole call with 404.
Unassign with the same paths and DELETE, and list what a tag holds with
GET .../assigned (a paged list of full target objects, accepting offset,
limit, sort= and q=):
curl -u admin:pw -X DELETE localhost:8088/rest/v1/targettags/1/assigned/dev-1
curl -u admin:pw -X DELETE localhost:8088/rest/v1/targettags/1/assigned \
-H 'Content-Type: application/json' -d '["dev-2","dev-3"]'
curl -u admin:pw 'localhost:8088/rest/v1/targettags/1/assigned?limit=100'
The reverse lookup — the tags carried by one entity — is a raptor extension, since hawkBit only exposes the tag-to-entity direction:
curl -u admin:pw localhost:8088/rest/v1/targets/dev-1/tags
curl -u admin:pw localhost:8088/rest/v1/distributionsets/7/tags
Distribution-set tags work the same way, keyed by distribution set id:
curl -u admin:pw -X POST localhost:8088/rest/v1/distributionsettags/1/assigned/7
curl -u admin:pw -X POST localhost:8088/rest/v1/distributionsettags/1/assigned \
-H 'Content-Type: application/json' -d '[8,9]'
curl -u admin:pw localhost:8088/rest/v1/distributionsettags/1/assigned
In the web console
The web console has a Tags page with a tab per kind, where tags are created, recoloured and deleted, alongside a count of how many targets or sets carry each one. A target’s or distribution set’s own tags appear on its detail page, where Manage… assigns and unassigns them, and the targets and distributions lists gain a tag dropdown that filters the list to one tag — combined with whatever is in the search box.
Filtering by tag
tag is available as a FIQL field on /rest/v1/targets and
/rest/v1/distributionsets:
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=tag==beta'
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=tag!=beta'
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=tag=in=(beta,canary)'
curl -u admin:pw 'localhost:8088/rest/v1/distributionsets?q=tag==qa'
It accepts ==, !=, =in= and =out= (plus * wildcards on the tag name);
the ordering operators return 400. Negation applies to the membership rather
than the name, so tag!=beta means “not tagged beta” — a target that also
carries stable is still excluded.
Because saved target filters compile the same field map, tag== works there
too, which makes it a natural way to scope a rollout:
curl -u admin:pw -X POST localhost:8088/rest/v1/targetfilters \
-H 'Content-Type: application/json' \
-d '{"name":"beta-ring","query":"tag==beta"}'
Combine it with any other term to narrow further:
tag==beta ; updateStatus==error
tag==eu-* , tag==us-*
Confirmation Flow
By default an assignment becomes an active deployment immediately. With the
confirmation flow enabled, a new assignment first enters a
wait_for_confirmation state and does not deploy until it is confirmed —
either by the device over DDI, or by an operator activating auto-confirm.
This mirrors hawkBit’s confirmation flow (confirmationBase), which clients like
the RAUC hawkbit-updater support.
The client has to implement
confirmationBase. With the flow on, a waiting target’s poll offersconfirmationBaseand nodeploymentBase. A client that doesn’t parse that link — the mainline Zephyr hawkbit client (subsys/mgmt/hawkbit) ignores it entirely, as do older SWUpdate/RAUC setups — sees nothing it understands, so it polls forever and never installs, with no error reported anywhere. raptor logs a warning at startup when the flow is on. Useauto_confirm_default(below) or per-target auto-confirm for those devices.
Enabling the flow
It’s a server-wide toggle in the config:
[ddi]
confirmation_flow = true
When off (the default), behavior is exactly as before — assignments go straight
to running.
What a device sees
With the flow on, a target’s poll offers a confirmationBase link instead of
deploymentBase:
$ curl localhost:8088/DEFAULT/controller/v1/device-42
# _links.confirmationBase -> .../confirmationBase/7
GET .../confirmationBase/{actionId} returns the pending deployment (the same
chunk/artifact shape as deploymentBase, under a confirmation key) so the
device can decide. The device then confirms or denies:
# confirm -> action goes to running; next poll offers deploymentBase
curl -X POST localhost:8088/DEFAULT/controller/v1/device-42/confirmationBase/7/feedback \
-H 'Content-Type: application/json' \
-d '{"confirmation":"confirmed","details":["operator approved"]}'
# deny -> action stays waiting (a "denied" ActionStatus row is recorded)
curl -X POST localhost:8088/DEFAULT/controller/v1/device-42/confirmationBase/7/feedback \
-H 'Content-Type: application/json' \
-d '{"confirmation":"denied","details":["not now"]}'
A denied action remains in wait_for_confirmation; the device may confirm later,
or an operator can cancel it.
Auto-confirm
A target can be set to auto-confirm, so assignments skip the wait state entirely. Toggle it from the Management API:
curl -u admin:pw localhost:8088/rest/v1/targets/device-42/autoConfirm
# {"active": false}
curl -u admin:pw -X POST localhost:8088/rest/v1/targets/device-42/autoConfirm/activate
curl -u admin:pw -X POST localhost:8088/rest/v1/targets/device-42/autoConfirm/deactivate
Or by the device itself over DDI:
curl -X POST localhost:8088/DEFAULT/controller/v1/device-42/confirmationBase/activateAutoConfirm
curl -X POST localhost:8088/DEFAULT/controller/v1/device-42/confirmationBase/deactivateAutoConfirm
Activating auto-confirm releases any already-pending actions on that target —
they transition straight to running. New assignments to an auto-confirm target
never enter the wait state.
Auto-confirm by default
To run the flow only for the devices that actually support it, have every new target start out auto-confirming and deactivate it on the ones you want to confirm explicitly:
[ddi]
confirmation_flow = true
auto_confirm_default = true
This applies to targets created either way — DDI self-registration or
POST /rest/v1/targets. It only affects new targets; existing ones keep
whatever auto-confirm state they have.
Operator confirm/deny
There is currently no per-action operator confirm/deny over the Management API — device-driven confirm/deny is DDI-only. The operator path is to activate auto-confirm on the target (which releases pending actions), or to cancel the action.
Filtering with FIQL
Every Management API list endpoint accepts a q= query in FIQL (Feed Item
Query Language, the same dialect hawkBit uses, also called RSQL). raptor compiles
it to a database query.
Grammar
Comparison operators:
| Operator | Meaning |
|---|---|
== | equal (supports * wildcards → SQL LIKE) |
!= | not equal (supports * wildcards) |
=lt= | less than |
=le= | less than or equal |
=gt= | greater than |
=ge= | greater than or equal |
=in= | in a list: field=in=(a,b,c) |
=out= | not in a list |
Logical operators:
;— AND,— OR- AND binds tighter than OR; use parentheses to group.
Wildcards: * in a value becomes a SQL LIKE wildcard, so
controllerId==dev-* matches everything starting with dev-.
Examples
updateStatus==error
controllerId==beta-*
updateStatus=in=(pending,error)
name==prod-* ; updateStatus==in_sync
updateStatus==error , updateStatus==pending
URL-encode the query when passing it on the command line:
curl -u admin:pw 'localhost:8088/rest/v1/targets?q=updateStatus%3D%3Derror'
Filterable fields
Each resource exposes its own field map; an unknown field returns 400 Bad Request. Common maps:
- targets —
id/controllerId,name,description,updateStatus,lastControllerRequestAt,address,group,autoConfirm,tag,attribute.<key> - distribution sets —
id,name,version,description,complete,tag - target tags / DS tags —
id,name,description,colour - actions —
id,active,detailStatus - rollouts —
id,name,status - target filters —
id,name
Boolean fields (e.g. active) accept true/false and compile to typed boolean
comparisons.
The tag field
tag is not a column but a membership test against the entity’s tags, so it
only accepts ==, !=, =in= and =out= — the ordering operators return
400. Negation applies to the membership, not the tag name: tag!=beta means
“not tagged beta”, so a target tagged both beta and stable is excluded.
tag==beta # targets tagged beta
tag!=beta # targets not tagged beta
tag=in=(beta,canary) # tagged with either
tag==beta ; updateStatus==error # tagged beta and in error
The same term works on /rest/v1/distributionsets and inside saved target
filter queries (and therefore rollout target filters).
The attribute.<key> field
Targets report free-form key/value attributes (configData — zephyr,
hw_revision, kernel, rauc_slot, …). Prefix a key with attribute. to
filter on it, the same way tag== reaches the tag join table:
attribute.hw_revision==rev-C # exact match
attribute.hw_revision==rev-* # wildcard
attribute.kernel==6.6 ; updateStatus==error # combined with a column
An unknown key matches no targets rather than returning 400 — attributes are
free-form and vary by device class, so there is nothing to validate against.
This works on the target list, saved target filters and rollouts, since all
three share the same query compiler.
Where FIQL is used
Beyond q= on list endpoints, the same grammar drives:
- Rollout target selection (
targetFilterQuery). - Target filter queries and their auto-assignment matching.
The query is validated when a rollout or target filter is created, so an invalid expression is rejected up front rather than failing silently later.
Authentication
raptor has two independent auth zones: the device-facing DDI zone and the operator-facing Management zone.
DDI zone (devices)
Devices authenticate on every DDI request with one of:
- Target security token —
Authorization: TargetToken <token>, matched against the target’s stored token. Each target has its own token (generated at creation, or supplied). - Gateway token —
Authorization: GatewayToken <token>, a single shared token from config. A valid gateway token also enables auto-registration of unknown controller IDs.
Configure them under [ddi]:
[ddi]
gateway_token = "shared-secret-for-registration"
Anonymous mode
[ddi]
anonymous = true
This disables DDI authentication entirely — any controller ID can poll and register. It’s convenient for local development and should not be used in production. It is off by default.
Management zone (operators)
The Management API and web console authenticate against a single admin credential using HTTP Basic:
[mgmt]
username = "admin"
password_hash = "$argon2id$..." # from `raptor hash-password`
The password is stored as an argon2id hash, never in plaintext. Generate it with:
$ printf 'yourpassword\n' | raptor hash-password
The web console also issues a session cookie (via POST /rest/v1/login) so
browser users don’t send Basic credentials on every request.
Note: raptor has a single admin user in this version. Multiple users, roles, OIDC, and certificate-based (mTLS) DDI auth are not yet implemented.
Deployment guidance
- Terminate TLS at a reverse proxy in front of raptor; the DDI tokens and Basic credentials are bearer secrets that must not travel in cleartext.
- Set
urlto the externally visible base URL so_linksare dialable through the proxy (see Configuration). - Prefer environment variables or the systemd
raptor.envfile for the gateway token and password hash rather than committing them to a shared config file (see Running as a systemd Service).
CLI & TUI (raptorctl)
raptorctl is a separate binary (crate raptor-cli) that drives a running
raptor server over the same Management API the web console uses. It is a pure
HTTP client — install it anywhere that can reach raptor serve, no server-side
changes required.
Login
$ raptorctl login --url http://localhost:8088 --user admin
password for admin:
logged in as admin
This verifies the credentials with a request against the server, then saves
them to ~/.config/raptor/cli.toml (mode 0600). Every later command reuses
that file, so --url/--user are only needed again to switch servers.
raptorctl logout deletes the saved file.
Scripting / CI
Skip the config file entirely with environment variables — useful in CI where
there’s no interactive prompt for login to read a password from:
$ RAPTOR_URL=http://localhost:8088 RAPTOR_USER=admin RAPTOR_PASS=secret \
raptorctl target list
Precedence is flags > environment > the saved config file.
Commands
Every command accepts --json to print the server’s raw response instead of a
table — pipe it into jq:
$ raptorctl target list --json | jq '.content[].controllerId'
| Command | Does |
|---|---|
target list/get/create/set/delete | target CRUD |
target attributes <cid> | reported device attributes |
target tag add|rm <cid> <tag> | tag/untag a target |
target type list/set/clear | target types, and a target’s constraint |
tag list/create/delete [--ds] | tag CRUD (target tags, or --ds for set tags) |
target assign <cid> --ds <id> [--force ...] | assign a distribution set |
target actions <cid> | a target’s action history |
module list/create | software module CRUD |
artifact upload/list/delete <moduleId> | artifact management |
ds list/get/create | distribution set CRUD |
ds invalidate <id> | withdraw a set so it can no longer be deployed |
ds tag add|rm <id> <tag> | tag/untag a distribution set |
publish <file> --version <v> | module + artifact + distribution set in one call |
action list/status/cancel/force | deployment action control |
rollout list/approve/deny | list rollouts, decide ones awaiting approval |
status | fleet-wide statistics |
Run raptorctl <command> --help for full flag lists.
publish and the two type vocabularies
publish creates a software module, uploads the file to it, and wraps it in a
distribution set. Those are typed from two different hawkBit vocabularies:
| Flag | Vocabulary | Seeded values |
|---|---|---|
--module-type (alias --type) | software-module types | os, firmware, runtime, application |
--ds-type | distribution-set types | os, os_app, app |
--ds-type is optional: raptorctl reads /rest/v1/softwaremoduletypes and
/rest/v1/distributionsettypes and derives it — an exact key match wins (os
→ os), otherwise the one set type that requires exactly that module type
(application → app). When neither applies (e.g. --module-type firmware,
which no seeded set type requires) it errors and asks for --ds-type rather
than guessing, because a wrong guess builds an incomplete distribution set
that only fails later, at assign time.
Both types are validated against the server before anything is written. That
ordering matters: the sequence is create-module → upload → create-set, so a
type rejected at the last step would leave an orphaned module and a
multi-megabyte artifact behind, with no module/ds delete subcommand to
clean them up.
Withdrawing a release
ds invalidate is the inverse of publish: the set can no longer be assigned
or rolled out, and any target filter auto-assigning it is detached. There is no
undo.
$ raptorctl ds invalidate 7
invalidate distribution set 7 (fw:1.4.1)? this cannot be undone. [y/N] y
invalidated distribution set 7 (fw:1.4.1)
The bare command is the safe one, because it is the one that gets run in a hurry: in-flight actions and rollouts are left alone, so a device that has already installed the set still reports its result. The destructive parts are opt-in:
| Flag | Effect |
|---|---|
--cancel-rollouts | also stop rollouts deploying this set |
--cancel-actions soft | ask devices to stop; the action ends when the device confirms |
--cancel-actions force | cancel server-side at once, without waiting for the device |
--yes (-y) | skip the confirmation prompt |
The prompt only appears when stdin is a TTY, so scripts and CI need no --yes.
ds get reports the result as valid false.
Tags
tag manages the tag itself; target tag/ds tag only assign an existing one
to something. --ds switches every tag subcommand from target tags to
distribution-set tags.
$ raptorctl tag create zephyr --description 'Zephyr firmware fleet' --colour '#4caf50'
created target tag 3 (zephyr)
$ raptorctl tag list
ID NAME DESCRIPTION ASSIGNED
3 zephyr Zephyr firmware fleet 0
$ raptorctl target tag add dev-042 zephyr
tagged dev-042 with zephyr
Assigning a tag that doesn’t exist names the ones that do, so a typo and a never-created tag are distinguishable:
$ raptorctl target tag add dev-042 zephry
error: no target tag named 'zephry' — existing: zephyr, prod. Create it with 'raptorctl tag create zephry'.
Target types
A target type constrains which distribution-set types the target will accept, so it is a correctness setting rather than an organisational one — an incompatible assignment is rejected at assign time.
$ raptorctl target type list
ID NAME DESCRIPTION ACCEPTS_DS_TYPES
1 gateway - os
$ raptorctl target type set dev-042 gateway
dev-042 is now target type gateway (1)
$ raptorctl target get dev-042 | grep targetType
targetType gateway
$ raptorctl target assign dev-042 --ds 7
error: distribution set type 'app' is not compatible with target type 'gateway', which accepts: os (HTTP 400)
raptorctl target type clear <cid> removes the constraint. Creating target
types themselves is not in the CLI yet — use the console or
POST /rest/v1/targettypes.
Rollout approval
When the server runs with rollout_approval_enabled = true, a new rollout
waits in waiting_for_approval until an operator decides on it:
$ raptorctl rollout list
ID NAME STATUS FINISHED DECIDED BY
7 fw-1.4.2 waiting_for_approval 0/240
$ raptorctl rollout approve 7 --remark "checked with ops"
rollout 7 is now ready
deny is the other half, and is terminal — a denied rollout can only be
deleted, so create a fresh one to try again. Both take an optional --remark
recorded against the decision. See the
Rollouts guide.
End-to-end example
$ raptorctl module create --name fw --version 1.4.1 --type os
$ raptorctl artifact upload 1 ./firmware.bin
$ raptorctl ds create --name fw --version 1.4.1 --type os --module 1
$ raptorctl target create dev-042
$ raptorctl target assign dev-042 --ds 1 --force forced
$ raptorctl target get dev-042
TUI
raptorctl tui opens an interactive dashboard: the fleet on the left, the
selected target’s status, assigned/installed set, and action history on the
right, and running rollouts underneath the target list.
$ raptorctl tui [--refresh <seconds>] # default 5, 0 disables auto-refresh
| Key | Does |
|---|---|
↑↓ / j/k, g/G | move selection |
/ | filter targets (sent server-side as a FIQL q=) |
a | assign a distribution set to the selected target |
t | tag the selected target |
c / f | cancel / force the target’s active action (y to confirm) |
r | refresh now |
? | help |
q / Esc | quit |
It respects NO_COLOR, works over SSH and inside tmux, and requires at least
an 80x24 terminal.
Using raptor with Zephyr
Zephyr’s mainline hawkBit client (subsys/mgmt/hawkbit) works against raptor’s
DDI API unchanged. This page is the minimal wiring, plus the two footguns worth
knowing before you flash a fleet.
Kconfig symbols below are from Zephyr’s subsys/mgmt/hawkbit/Kconfig; the
compatibility matrix at the end records what the client actually exercises.
Minimal configuration
Device side (prj.conf):
CONFIG_HAWKBIT=y
CONFIG_HAWKBIT_SERVER="ota.example.com"
CONFIG_HAWKBIT_PORT=8088
CONFIG_HAWKBIT_TENANT="DEFAULT"
CONFIG_HAWKBIT_POLL_INTERVAL=5 # minutes; 1..43200
# Pick one auth mode (see below)
CONFIG_HAWKBIT_DDI_GATEWAY_SECURITY=y
CONFIG_HAWKBIT_DDI_SECURITY_TOKEN="shared-registration-secret"
# If raptor is behind TLS
# CONFIG_HAWKBIT_USE_TLS=y
If CONFIG_HAWKBIT_SERVER is a hostname rather than an IP, the device also needs
CONFIG_DNS_RESOLVER=y.
Server side (raptor.toml):
url = "http://ota.example.com:8088"
[ddi]
gateway_token = "shared-registration-secret"
polling_interval = "00:05:00"
Keep polling_interval and CONFIG_HAWKBIT_POLL_INTERVAL consistent — raptor
advertises its value as config.polling.sleep (HH:MM:SS), which the client
reads and honours, so the Kconfig value is really just the pre-first-poll default.
Choosing an auth mode
The client sends exactly one of two headers, selected at build time:
| Kconfig choice | Header sent | raptor side |
|---|---|---|
CONFIG_HAWKBIT_DDI_GATEWAY_SECURITY=y | Authorization: GatewayToken <token> | [ddi] gateway_token = "<token>" |
CONFIG_HAWKBIT_DDI_TARGET_SECURITY=y | Authorization: TargetToken <token> | the target’s own securityToken |
Either way the token value goes in CONFIG_HAWKBIT_DDI_SECURITY_TOKEN.
Gateway token is one shared secret for the whole fleet and permits auto-registration: a device that polls with it is created on first contact. Good for bringing a fleet up, weaker blast radius if extracted from one device.
Target token is per-device, so the target must exist in raptor first, with a token you provision into the firmware:
curl -u admin:pw -X POST localhost:8088/rest/v1/targets \
-H 'Content-Type: application/json' \
-d '[{"controllerId": "device-42", "securityToken": "per-device-secret"}]'
See Authentication for the full picture. [ddi] anonymous = true disables DDI auth entirely — convenient for a bring-up on a lab network,
never in production.
Footgun 1: the tenant must match
Zephyr’s CONFIG_HAWKBIT_TENANT defaults to "default". raptor answers to
exactly one tenant, set by the tenant config key (default DEFAULT, matched
case-insensitively, so the Zephyr default just works out of the box) — any
other segment gets 404 on every DDI request. If your fleet is genuinely
configured with a non-default tenant name, set tenant in raptor.toml to
match rather than reconfiguring every device.
Footgun 2: don’t enable the confirmation flow
The client parses exactly three _links keys from the base poll —
deploymentBase, configData, cancelAction. It has no confirmationBase
handling at all.
So with [ddi] confirmation_flow = true, a waiting target is offered only
confirmationBase, the device sees no link it understands, and it polls forever
without installing and without reporting an error. If you need the flow for other
clients, keep Zephyr devices out of it with:
[ddi]
confirmation_flow = true
auto_confirm_default = true # new targets auto-confirm
See Confirmation Flow.
Compatibility matrix
What the mainline Zephyr client uses, and raptor’s status for each. “Verified”
means covered by raptor’s integration tests, including a JSON-contract test
(zephyr_client_json_contract) pinning the exact response shape the client’s
strict JSON descriptors require.
| DDI feature | Zephyr client | raptor |
|---|---|---|
Base poll config.polling.sleep (HH:MM:SS) | reads and honours | ✅ verified |
_links.deploymentBase | parsed | ✅ verified |
_links.configData | parsed; uploads whenever present | ✅ verified — advertised only when attributes are wanted, so devices don’t re-upload every poll |
_links.cancelAction | parsed | ✅ verified |
_links.confirmationBase | not parsed | ⚠️ raptor supports it; do not enable for Zephyr (footgun 2) |
configData PUT, mode: "merge" | sends on every poll carrying the link | ✅ verified |
deploymentBase chunks/artifacts | parsed | ✅ verified |
Artifact hashes.sha256 | verified against flashed image | ✅ verified |
Artifact _links.download-http | the link it downloads from | ✅ verified — see download-http vs download |
Range: resume (CONFIG_HAWKBIT_SAVE_PROGRESS) | sends bytes=N- | ✅ verified — 206 + Content-Range |
Feedback execution / result | closed, proceeding, canceled, scheduled, rejected, resumed, none / success, failure, none | ✅ verified |
Authorization: TargetToken / GatewayToken | either, chosen at build time | ✅ verified |
| Multi-tenancy | CONFIG_HAWKBIT_TENANT | ⚠️ single-tenant, must match configured tenant (footgun 1) |
Verifying against a real device
raptor’s test suite drives its DDI API with the Rust hawkbit crate and the
JSON-contract test above — not with the Zephyr client itself, which needs
hardware or QEMU. For an end-to-end check, build Zephyr’s
samples/subsys/mgmt/hawkbit sample against a raptor instance and watch the
server log: a successful cycle is a base poll, a configData PUT, a
deploymentBase GET, artifact GETs, then feedback with execution: "closed".
Using raptor with SWUpdate (suricatta)
SWUpdate’s suricatta module speaks hawkBit DDI v1 and works against raptor unchanged. Unlike the Zephyr guide, there is no wiring to describe here beyond the server URL and a token — suricatta’s hawkBit server configuration is ordinary.
What this page covers is one symptom, because it is the one that brings operators to the server looking for a bug that isn’t there:
raptor keeps offering the same bundle. The device downloads it on every poll, the action never closes, and the target never reaches
in_sync.
There are four independent causes. All four are client-side — every one of them lives on the device, in suricatta’s state persistence. None of them are visible from raptor, which is exactly why they’re documented here. They were found and fixed on real hardware (Raspberry Pi 5 with U-Boot, and Jetson AGX Thor).
They also stack. Each one hides the next, so fixing one and seeing no change does not mean the fix was wrong.
Confirming it is client-side
Before reading further, check the action’s status history:
curl -u admin:pw localhost:8088/rest/v1/targets/device-42/actions/1/status
If the loop is one of the four below, you will see:
- the action stuck
runningandactive, neverfinished; - a status history with the initial entries and nothing since — no
proceeding, nosuccess, nofailure; - in the server log, repeated
GET .../deploymentBase/{id}and artifact GETs with noPOST .../feedbackbetween them.
Repeated downloads plus zero feedback is the signature. A slow-but-healthy
install looks different: it reports proceeding at least once. If you are
seeing feedback and the action still won’t close, this page is not your
problem.
Cause 1: suricatta cannot persist action_id
Symptom: every poll looks like a brand-new deployment to the device.
suricatta stores the action id it is working on via swupdate’s vars store.
swupdate_vars_initialize() (core/swupdate_vars.c) returns -EINVAL when no
namespace is configured, so the id is never written. On the next poll the
device has no memory of the action in flight and starts over.
The trap is that the classic two-line fw_env.config:
/dev/mmcblk0 0x400000 0x4000
yields a single unnamed context with nelem = 0, so any namespace lookup
fails. It looks like a working environment — fw_printenv is fine — but
suricatta cannot use it.
libubootenv’s YAML config form is required instead, along with two swupdate
settings: fwenv-config-location and namespace-vars.
# /etc/fw_env.config — offsets, paths and sizes are device-specific
u-boot:
size: 0x4000
lockfile: /var/lock/fw_printenv.lock
devices:
- path: /dev/mmcblk0
offset: 0x400000
sectorsize: 0x1000
swupdate:
size: 0x4000
lockfile: /var/lock/swupdate_vars.lock
devices:
- path: /dev/mmcblk0
offset: 0x404000
sectorsize: 0x1000
# /etc/swupdate.cfg
globals: {
fwenv-config-location = "/etc/fw_env.config";
namespace-vars = "swupdate";
}
Namespace order matters
libubootenv uses the first namespace in the file when the device tree names none. Put the boot environment first, as above. Get this backwards and a bootloader slot switch silently writes into the swupdate vars store, corrupting the state that suricatta depends on — while appearing to work.
Cause 2: only ustate=2 closes the action
Symptom: the install succeeds, the device boots the new image, and the action still reports “Testing Pending” forever.
Committing an update with just upgrade_available=0 and bootcount=0 leaves
ustate at 1 (INSTALLED). That is not enough.
Per server_handle_initial_state in suricatta/server_hawkbit.c:
ustate after boot | suricatta reports | action |
|---|---|---|
1 INSTALLED | proceeding (“Testing Pending”) | stays open |
2 TESTING | success | closes |
3 FAILED | failure | closes as failed |
Worse, the INSTALLED path then calls save_state(STATE_OK), which destroys the
very state its own “an already-installed update is pending testing” guard
depends on. The next boot has nothing to report.
So a post-update health-check unit must set ustate explicitly:
# /etc/systemd/system/health-check.service
[Service]
Type=oneshot
ExecStart=/usr/bin/health-check.sh
ExecStart=/usr/bin/fw_setenv ustate 2
ExecStopPost=/bin/sh -c '[ "$EXIT_STATUS" = 0 ] || fw_setenv ustate 3'
Set 2 on pass and 3 on failure. Clearing upgrade_available alone will not
close the action.
Cause 3: bootloader="none" has no state store at all
Symptom: the same as cause 1, on a platform that has no U-Boot — Tegra, for
instance — where the two-line fw_env.config was never an option to begin
with.
swupdate’s save_state / read_state do not go through swupdate_vars.
They go through bootloader_env_set / bootloader_env_get, and the none
backend (bootloader/none.c) is a process-local dictionary. State dies with the
process. After a reboot get_state() returns STATE_NOT_AVAILABLE, suricatta
reports nothing, and a forced deployment reinstalls on every poll.
Configuring the vars namespace from cause 1 does not fix this — it is a different store.
The fix that works: use the uboot backend even with no U-Boot present,
pointed at a plain file on persistent storage. Despite the name, that backend is
only libubootenv over whatever the fw_env config names:
# /etc/fw_env.config
swupdate-state:
size: 0x4000
lockfile: /var/lock/swupdate_state.lock
devices:
- path: /var/lib/swupdate/state.env
The file must exist and survive reboots. This is safe wherever the
sw-description has no bootenv entries, since swupdate’s own state is then
that environment’s only consumer.
Cause 4: the first update onto a device with no prior state
Symptom: looks intermittent across a fleet — some devices close their actions, some never do, with no apparent pattern.
The image being replaced is the one that has to record the state. A device whose current image has no usable state store therefore cannot close its first action, no matter how correct the incoming image is. Every update after that one closes normally.
There is no server-side fix and nothing to change on the device. Cancel that one action by hand:
curl -u admin:pw -X DELETE \
'localhost:8088/rest/v1/targets/device-42/actions/1?force=true'
See Assignments & Actions. Devices whose previous image already had a working environment are unaffected, which is what makes the pattern look random across a mixed fleet.
Checklist
Work down it in order; each item can mask the ones below.
-
fw_env.configis in libubootenv YAML form, not the two-line form - the boot environment namespace is listed first
-
swupdate.cfgsets bothfwenv-config-locationandnamespace-vars - the bootloader backend is not
none— useubootover a file if there is no real U-Boot - a health check sets
ustate=2on pass and3on failure - the very first action on a previously stateless device was cancelled by hand
Running as a systemd Service
The Debian package installs a hardened systemd unit at
/usr/lib/systemd/system/raptor.service. This guide covers how it’s wired and
how to operate it.
Layout
| Path | Purpose |
|---|---|
/usr/bin/raptor | the binary (web console embedded) |
/etc/raptor/config.toml | config, a dpkg conffile (edits survive upgrades) |
/var/lib/raptor/ | state: SQLite DB and artifact blobs |
/etc/raptor/raptor.env | optional, root-only secrets (not shipped) |
First start
The service is enabled but not started on install — you must set an admin password first:
$ raptor hash-password # type a password, copy the hash
$ sudoedit /etc/raptor/config.toml # set password_hash, choose DDI auth
$ sudo systemctl start raptor
$ systemctl status raptor
State directory
The unit uses systemd’s DynamicUser=yes with StateDirectory=raptor. That
means:
- raptor runs as a transient, unprivileged user allocated at runtime — there
is no
raptoruser to manage and nopostinstchown. /var/lib/raptoris created and owned by that user automatically, mode0750.
The default config points both the database and the artifact store there:
database_url = "sqlite:///var/lib/raptor/raptor.db?mode=rwc"
artifact_dir = "/var/lib/raptor/artifacts"
/var/lib is the FHS location for persistent, service-owned state — the
content-addressed artifact blobs are the source of truth (not regenerable
cache), which is why they live here rather than under /var/cache.
Secrets
The config file is world-readable because the DynamicUser must read it. Keep plaintext secrets out of it. Put them in a root-only environment file, which systemd reads before dropping privileges:
$ sudo install -m 600 /dev/null /etc/raptor/raptor.env
$ echo 'RAPTOR_DDI__GATEWAY_TOKEN=super-secret' | sudo tee -a /etc/raptor/raptor.env
$ sudo systemctl restart raptor
The unit loads it via EnvironmentFile=-/etc/raptor/raptor.env (the leading -
makes it optional). The mandatory password_hash is an argon2 hash and is safe
to keep in the config.
Hardening
The unit ships with a broad systemd sandbox: ProtectSystem=strict,
ProtectHome=yes, PrivateTmp=yes, NoNewPrivileges=yes,
MemoryDenyWriteExecute=yes, a restricted SystemCallFilter=@system-service, an
empty CapabilityBoundingSet, and address-family restrictions. raptor needs no
Linux capabilities because it binds a high port (8088) by default.
Note: If you change
bindto a privileged port (< 1024), grant the capability explicitly withAmbientCapabilities=CAP_NET_BIND_SERVICEvia a drop-in (systemctl edit raptor), or bind a high port and let the reverse proxy front it.
Logs
raptor logs to stdout/stderr, captured by the journal:
$ journalctl -u raptor -f
Adjust verbosity with the RUST_LOG env var (e.g. RUST_LOG=raptor=debug) via a
systemctl edit drop-in or raptor.env.
Web Console
raptor ships an optional web console — a Dioxus WASM single-page app — embedded directly in the binary.
Enabling it
The console is behind the embed-ui Cargo feature. The Debian package and the
release binaries are built with it on; if you build from source, include the
feature (see Installation):
$ dx build --release --package raptor-ui
$ cargo build --release --features embed-ui
Without the feature the server runs identically but does not serve the UI routes.
Developing the console
For UI work, dx build + cargo build --features embed-ui is too slow to
iterate with — every visual tweak pays a full wasm rebuild. Instead, run the
two halves of the app separately and let dx serve handle the frontend:
$ cargo run -- serve --config raptor.toml # the API, on :8088
$ dx serve --package raptor-ui # the console, on its own port
dx serve hosts the console on its own port (:8080 by default) rather than
:8088, but raptor-ui/Dioxus.toml already proxies /rest/* to
http://localhost:8088/rest ([[web.proxy]]), so the browser only ever talks
to the dx serve origin — the login flow’s SameSite=Strict session cookie
and every other same-origin API call work unmodified, no extra config needed.
Browse to http://localhost:<dx-port>/ui and log in as usual. dx serve
already gives you rsx hot-reload (markup/style edits apply without a rebuild);
a .rs logic change triggers an incremental rebuild, faster than a dx build
but still a rebuild.
Dioxus 0.7 also ships --hotpatch (dx serve --hotpatch --package raptor-ui),
which is meant to patch Rust logic changes into the running app without any
rebuild via the Subsecond engine. As of the pinned dioxus-cli 0.7.10, this
did not work for this crate in testing: the initial build succeeds and serves,
but the browser fails to boot the app with
TypeError: WebAssembly.instantiate(): Import #0 "__wbindgen_placeholder__": module is not an object or function
— reproduced on repeated clean runs. Plain dx serve (no --hotpatch) has no
such issue. Until this is resolved upstream, use plain dx serve for the fast
loop and fall back to a full dx build for a release check. If dx serve
itself gets confused (stale rebuild, weird state), press r in its terminal
UI to force a full rebuild.
Accessing it
Browse to /ui:
http://localhost:8088/ui
Log in with the same admin credentials as the Management API. The console
authenticates via POST /rest/v1/login and holds a session cookie, so you don’t
re-enter credentials on every request.
What it covers
The console surfaces the core read/observe workflow and common actions:
- a dashboard (fleet counters, the actions feed, active rollouts, and the server’s configuration),
- targets and target detail,
- target filters and auto-assignment,
- tags — create and edit target and distribution-set tags, tag an entity from its detail page, and filter the target and distribution lists by tag,
- distribution sets and detail,
- software modules and detail,
- rollouts and rollout detail,
- the actions feed.
Dashboard
The counter tiles — targets, in sync, pending, error, running actions — come
from GET /rest/v1/system/statistics, so the server counts the whole fleet and
the numbers stay correct however large it grows. The active-rollout and
recent-action lists below them are feeds rather than counts and read the
ordinary list endpoints. Everything refreshes on the console’s 5s polling.
The System configuration card at the foot of the dashboard shows the tenant
configuration devices see (GET /rest/v1/system/configs): the polling interval,
whether the confirmation flow is on, and which authentication modes are enabled.
It is read-only — raptor takes its configuration from raptor.toml, and the API
answers writes to these keys with 403. See
Configuration to change any of them.
Targets
The list shows each target’s group alongside its tags, state and installed set.
Clicking a group filters the list to it — the only way to discover which groups
are in use, as there is no endpoint that enumerates them. The group box beside
the search box takes a pattern as well as an exact path, so plant-a/* selects
a whole site; it compiles to a group== term that ANDs with the search, state
and tag filters, and the compiled query is shown under them.
Target detail shows the group and lets you edit it in place. A group can be
moved but not cleared: PUT /rest/v1/targets/{id} reads an omitted group as
“leave unchanged”, so there is no way to express “unset” — see the
Targets guide.
Rollouts
The rollouts list shows each rollout’s status and a progress bar of its targets; the detail page adds the deploy groups, each with its own bar and a legend of targets per status (finished / running / error / cancelled / scheduled / not started), refreshed by the same 5s polling as the rest of the console.
Lifecycle buttons — Start, Pause, Resume, Delete — appear for the transitions the rollout’s current status allows, so an operator can drive a rollout end to end without touching the API. Creating a rollout is still API-only; see the Rollouts guide.
When rollout_approval_enabled is on, a rollout held in waiting for approval
offers Approve and Deny instead. Both prompt for an optional remark —
the audit note stored with the decision — and the detail page then shows who
decided and what they wrote. Deny is terminal: a denied rollout can never be
started, only replaced by a fresh one.
Note: The console tracks the API and lags it slightly. A page for the confirmation flow is planned (tracked as an issue on GitHub). Anything not yet in the UI is always available through the Management API.
Architecture
raptor is a modular monolith: one process, one binary, with clear internal seams between the HTTP layer, the domain logic, and persistence.
Workspace
raptor/
├── Cargo.toml # workspace
├── raptor/ # main crate (bin + lib)
│ └── src/
│ ├── main.rs # CLI (serve, hash-password), startup, background tasks
│ ├── app.rs # router assembly
│ ├── config.rs # TOML + RAPTOR_* env overrides
│ ├── api/
│ │ ├── ddi/ # /{tenant}/controller/v1/... device handlers
│ │ └── mgmt/ # /rest/v1/... operator handlers
│ ├── domain/ # deployment state machine, rollouts, auto-assign
│ ├── entity/ # SeaORM entities
│ ├── fiql/ # FIQL/RSQL parser -> SeaORM Condition
│ ├── storage.rs # content-addressed artifact store
│ └── auth/ # tower middleware for both auth zones
├── raptor-api-types/ # shared Management API DTOs (also compiles to wasm)
├── raptor-ui/ # Dioxus web console (wasm32)
└── migration/ # sea-orm-migration crate (one set, both DBs)
Stack
- axum — HTTP routing and extractors.
- SeaORM — one entity/migration definition
targeting both SQLite and Postgres; the backend is chosen by the
database_urlscheme. - tokio — async runtime; also drives background tasks.
- winnow — the FIQL parser.
- sha2 / sha1 / md-5 — artifact hashing.
- argon2 — admin password hashing.
- Dioxus — the optional embedded web console.
Request path
- A request hits axum. A tower middleware enforces the right auth zone —
ddi_authfor/{tenant}/controller/v1/...,mgmt_authfor/rest/v1/.... - The handler (
api/ddiorapi/mgmt) validates input and calls into the domain layer. - The domain layer owns the rules — the action state machine, rollout evaluation, auto-assignment — and talks to persistence through entities.
- Artifact bytes bypass the database: they stream to/from the content-addressed store on disk.
Background tasks
A single tokio task runs on a fixed interval
(rollout_eval_interval_secs, default 5s) and performs two jobs:
- Rollout evaluation — advances or pauses running rollout groups based on action outcomes.
- Auto-assignment sweep — assigns target-filter distribution sets to newly-matching targets.
Shared DTOs
The raptor-api-types crate holds the Management API request/response types. It
compiles to wasm32 as well as native, so the server and the Dioxus web console
share exactly the same type definitions — the JSON contract can’t drift between
them. Round-trip tests assert the JSON shape matches hawkBit.
Persistence & migrations
There is one SeaORM entity set and one ordered list of migrations. They
run automatically at startup against whichever database database_url points to,
so upgrading raptor and migrating the schema is a single step: start the new
binary.
Domain Model
The entities below are the nouns of raptor. Understanding how they relate makes the API and the update lifecycle straightforward.
SoftwareModuleType DistributionSetType
│ │
▼ ▼
SoftwareModule ──< DsModule >── DistributionSet
│ │
▼ │ assigned / installed
Artifact ▼
(blob on disk) Target ──< Action >── (a deployment)
▲ │ │
│ ▼ ▼
content-addressed TargetAttribute ActionStatus
(+ messages)
Rollout ──< RolloutGroup >── RolloutTargetGroup
TargetFilter (optional auto-assign DS)
Core entities
- Target — a device, keyed by unique
controllerId. Holds a security token, anupdateStatus(unknown→registered→pending→in_sync/error), the last poll time and request address, anauto_confirmflag, and pointers to its assigned and installed distribution sets. - TargetAttribute — key/value pairs a device reports via DDI
configData. - SoftwareModule — a named, versioned unit of a seeded type (
os,firmware,runtime,application). Owns artifacts. - Artifact — a file (filename, size, sha1/md5/sha256) belonging to one module. The blob is stored once per sha256 on disk; artifact rows are references to it.
- DistributionSet — a named, versioned bundle of modules of a seeded type
(
os,os_app,app).completewhen it has the modules its type requires; only complete sets are assignable. - Action — one deployment of a DS to a target. Carries a
status(the action state machine), anactiveflag, and aforced/soft indicator. May belong to a rollout group. Invariant: at most one active action per target. - ActionStatus — an append-only history row (with optional messages) written on every device feedback and every server-side transition.
Rollout entities
- Rollout — a staged deployment of a DS to the targets matched by a FIQL filter, split into groups, with success/error thresholds.
- RolloutGroup — one stage; has its own status and thresholds.
- RolloutTargetGroup — the membership join: which targets are in which group (a static snapshot taken at creation).
Target filters
- TargetFilter — a saved FIQL query with an optional attached auto-assign distribution set and action type. Drives auto-assignment.
Seeded types
Software-module and distribution-set types are read-only in this version, created by the initial migration:
- Software-module types:
os,firmware,runtime,application - Distribution-set types:
os,os_app,app
Tenancy
raptor is single-tenant: every query-root table carries a tenant column
(default DEFAULT), but nothing filters on it yet — it exists so real
isolation can land later without a schema migration, not to isolate data
today. The DDI tenant URL segment must match the configured tenant (default
DEFAULT) or the request is rejected. See
the design doc
for the full rationale.
Update Lifecycle
This is the heart of raptor: how an assignment becomes an installed update, and how each party’s actions move the state machine.
The happy path
operator server device
──────── ────── ──────
assignedDS ──────────▶ Action(running), target=pending
◀────── poll (sees deploymentBase)
◀────── GET deploymentBase
(streams artifacts) ◀────── download artifacts
◀────── feedback: proceeding
ActionStatus += proceeding
◀────── feedback: closed/success
Action(finished), target=in_sync
◀────── poll (no deploymentBase;
installedBase set)
- Assign.
POST /rest/v1/targets/{id}/assignedDScreates an Action inrunning(orwait_for_confirmationif the confirmation flow is on), and sets the target topending. Any prior active action is cancelled. - Poll. The device polls the DDI root and sees
_links.deploymentBase. - Fetch & download. The device gets
deploymentBase/{actionId}and downloads the listed artifacts (with HTTP Range resume). - Feedback. The device reports
proceeding, then a final result. Each report appends an ActionStatus row. - Complete. On
closed/successthe Action becomesfinished, the target becomesin_sync, andinstalledBasereflects the deployment. Onclosed/failurethe Action becomeserrorand the target becomeserror.
Feedback vocabulary
Device feedback is {"status":{"execution": ..., "result":{"finished": ...}}}.
execution∈proceeding,scheduled,resumed,downloading,downloaded,canceled,rejected,closed.result.finished∈none,success,failure.
Only closed (with success or failure) is terminal; the others are recorded
as history and leave the action active.
Confirmation flow
With the confirmation flow enabled, a new
assignment lands in wait_for_confirmation and the poll offers confirmationBase
instead of deploymentBase. A confirmed feedback moves it to running; the
next poll then follows the happy path above.
Cancellation
operator server device
──────── ────── ──────
DELETE action ────────▶ Action(canceling)
◀────── poll (sees cancelAction)
◀────── GET cancelAction
◀────── cancel feedback: closed
Action(canceled); assigned reverts to installed
A normal cancel sets the action to canceling and offers the device a
cancelAction link; the device confirms and the action becomes canceled. A
forced cancel (?force=true) closes it immediately, without waiting for the
device.
Auto-registration
An unknown controllerId that polls with a valid gateway token (or in anonymous
mode) is created on the spot as registered. If an
auto-assignment filter matches it, the DS is
assigned during that same poll — so a brand-new device can receive a
deploymentBase on its very first request.
Configuration Reference
raptor reads a TOML file (default raptor.toml, override with
serve --config <path>). Every key can be overridden by a RAPTOR_*
environment variable; nested tables use a __ separator (e.g.
RAPTOR_DDI__ANONYMOUS).
Top level
| Key | Type | Default | Description |
|---|---|---|---|
bind | socket addr | 0.0.0.0:8088 | address the HTTP server listens on |
database_url | string | (required) | sqlite://… or postgres://…; selects the backend |
artifact_dir | path | (required) | root of the content-addressed artifact store |
max_artifact_size | integer (bytes) | 1073741824 (1 GiB) | maximum artifact upload size |
url | string | (unset) | external base URL for _links; derived from the Host header when unset |
rollout_eval_interval_secs | integer | 5 | how often the background evaluator / auto-assign sweep runs |
rollout_approval_enabled | bool | false | gate new rollouts behind an operator approval (waiting_for_approval); reported as hawkBit’s rollout.approval.enabled |
tenant | string | DEFAULT | tenant name this instance answers to on the DDI /{tenant}/... path segment (matched case-insensitively); any other segment gets 404 |
[quota] — per-entity growth caps
Mirrors hawkBit’s quotas (hawkbit.server.security.dos.*), with its default
values. These bound unbounded growth — a chatty device appending action-status
rows forever, a runaway upload loop — rather than rate-limiting requests. A
breach is rejected with 429 Too Many Requests and hawkBit’s
hawkbit.server.error.quota.tooManyEntries error code.
Set a key to 0 to disable that quota. This matches hawkBit, which treats
any limit <= 0 as unlimited.
| Key | Default | Caps |
|---|---|---|
max_status_entries_per_action | 1000 | status entries a device may report against one action |
max_messages_per_action_status | 50 | messages a device may attach to one reported status |
max_attribute_entries_per_target | 100 | attributes a device may report about itself |
max_metadata_entries_per_target | 100 | metadata entries per target |
max_metadata_entries_per_software_module | 100 | metadata entries per software module |
max_metadata_entries_per_distribution_set | 100 | metadata entries per distribution set |
max_artifacts_per_software_module | 50 | artifacts per software module |
max_software_modules_per_distribution_set | 100 | modules per distribution set |
max_rollout_groups_per_rollout | 500 | deployment groups per rollout |
max_targets_per_rollout_group | 20000 | targets in any one rollout group |
The two device-reported caps apply only to what a device sends over DDI. raptor’s own status entries — “rollout stopped”, “superseded by a new assignment” — are never capped, so a device that has exhausted its quota cannot stop the server recording why its action was cancelled.
max_status_entries_per_action also exempts feedback that closes an action
(closed, canceled, a downloadonly action’s downloaded, and a cancel’s
closed/rejected). Without that carve-out a device that spent its budget on
progress reports could never file its terminal one, leaving the action active
forever with no way to close it. hawkBit does the same, checking the count only
for intermediate statuses.
Artifact size is capped separately by the top-level max_artifact_size.
[cleanup] — automatic action cleanup
Deletes closed actions past a retention window, along with their status
history. action_status is the one table that otherwise grows without bound:
quotas cap how much history any single action can accumulate, but nothing
caps how many actions a fleet accumulates over years of updates.
Off by default — deleting deployment history is not something to start doing to an existing installation unasked.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | run the sweep at all |
action_expiry_days | integer | 30 | how long a closed action is kept, from when it was last modified |
action_statuses | list | ["finished", "error", "canceled"] | which statuses may be deleted |
interval_secs | integer | 3600 | how often the sweep runs |
[cleanup]
enabled = true
action_expiry_days = 90
An active action is never eligible, whatever action_statuses says — a
device can hold a listed status while its action is still live, and deleting it
would strand that device holding an action id the server no longer knows. Deletion is
batched — 1000 actions per statement, up to 10k per sweep — so a long-neglected
instance drains over successive sweeps rather than in one enormous statement.
(hawkBit bounds its own cleanup the same way, but at one batch per run.)
Rollout progress is unaffected. totalTargetsPerStatus is derived by counting
actions, so deleting a finished one would walk a completed rollout’s targets
back to scheduled — reporting as though the deployment had never run. raptor
records each group’s purged outcomes before the rows go and folds them back in.
(hawkBit has this drift and tolerates it; raptor does not.)
The two hawkBit tenant config keys are reported on
/rest/v1/system/configs: action.cleanup.auto.expiry (milliseconds, -1
when disabled) and action.cleanup.auto.status.
[ddi] — device-facing API
| Key | Type | Default | Description |
|---|---|---|---|
anonymous | bool | false | disable all DDI auth (dev only) |
gateway_token | string | (unset) | shared token; enables auto-registration |
polling_interval | string | 00:05:00 | poll sleep advertised to devices — see Polling time overrides below |
confirmation_flow | bool | false | require confirmation before a deployment starts |
auto_confirm_default | bool | false | give newly created targets autoConfirm, so confirmation_flow can’t strand confirmation-unaware clients |
artifact_http_url | string | (unset) | plain-HTTP base advertised in the DDI download-http links; unset means they reuse url |
trusted_proxy_header | string | (unset) | header to read the device address from behind a reverse proxy, e.g. x-forwarded-for; unset uses the socket peer |
Polling time overrides
polling_interval accepts hawkBit’s pollingTime grammar (hawkBit 0.10,
PR #2533): a default
interval, optionally followed by ordered <RSQL> -> <interval> override
rules, evaluated in written order — first match wins:
<default>[, <RSQL> -> <interval>]*
Each interval is HH:MM:SS, optionally with ~NN% jitter (NN 0–99):
raptor draws fresh randomness on every poll response, matching hawkBit
exactly, so a device’s advertised sleep can vary poll to poll rather than
settling on one value.
[ddi]
polling_interval = "00:05:00, group==eu -> 00:00:30~10%, updateStatus!=in_sync -> 00:02:00"
- The bare
HH:MM:SSform (no rules) is unchanged from before this feature and behaves identically. - Override filters use raptor’s own FIQL dialect — the same one every
q=parameter accepts — not hawkBit’s fuller Spring RSQL grammar. In particular, whitespace around the operator is not tolerated: writegroup==eu, not hawkBit’s own doc examplegroup == 'eu'. A rule that fails to parse is rejected at startup (see below), not silently ignored. - raptor does not support hawkBit’s multi-day (
d+:HH:mm:ss) or ISO-8601 (P2DT3H4M) interval forms — the incident-shaped use case this exists for (“poll this device faster while I’m watching it”) doesn’t need day-scale intervals. - A malformed
polling_interval— bad grammar, or a rule referencing a filter field raptor doesn’t recognize — failsraptor serveat startup with an error, rather than surfacing on a device’s poll. - Known limitation, inherited from hawkBit: the Management API’s
pollStatus.overdueis always computed from the default interval, even for a target currently matched by an override rule. hawkBit’s own release notes call this out as an accepted inaccuracy rather than a bug to fix.
[mgmt] — Management API / web console
| Key | Type | Default | Description |
|---|---|---|---|
username | string | (required) | admin username |
password_hash | string | (required) | argon2id hash from raptor hash-password |
Example
bind = "0.0.0.0:8088"
database_url = "postgres://raptor:raptor@localhost/raptor"
artifact_dir = "/var/lib/raptor/artifacts"
max_artifact_size = 2147483648 # 2 GiB
url = "https://raptor.example.com"
rollout_eval_interval_secs = 10
rollout_approval_enabled = false
[quota]
max_artifacts_per_software_module = 20
max_status_entries_per_action = 0 # unlimited
[ddi]
anonymous = false
gateway_token = "shared-registration-secret"
polling_interval = "00:05:00"
confirmation_flow = true
[mgmt]
username = "admin"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
Environment overrides
RAPTOR_BIND=127.0.0.1:9090
RAPTOR_DATABASE_URL=sqlite://raptor.db?mode=rwc
RAPTOR_DDI__ANONYMOUS=true
RAPTOR_DDI__GATEWAY_TOKEN=super-secret
RAPTOR_MGMT__PASSWORD_HASH='$argon2id$...'
Environment values take precedence over the TOML file — the recommended way to inject secrets.
Management API Reference
Operator-facing REST API under /rest/v1, authenticated with HTTP Basic (or a
session cookie). All list endpoints accept offset, limit, sort=field:ASC|DESC,
and q=<FIQL>, and return the hawkBit paged envelope {content, total, size}.
Base URL examples assume localhost:8088.
Handlers live under raptor/src/api/mgmt/, one module per resource family,
each exposing a routes() that mod.rs merges — e.g. target endpoints in
targets.rs, distribution-set/software-module/target type endpoints in
types/.
Auth & session
| Method | Path | Description |
|---|---|---|
POST | /rest/v1/login | exchange credentials for a session cookie |
POST | /rest/v1/logout | clear the session |
GET | /rest/v1/session | 204 if the request is authenticated, 401 if not |
GET | /health | liveness probe (returns ok) |
Targets
| Method | Path | Description |
|---|---|---|
POST | /rest/v1/targets | create targets (JSON array) |
GET | /rest/v1/targets | list (paging/sort/FIQL) |
GET | /rest/v1/targets/{cid} | get one |
PUT | /rest/v1/targets/{cid} | update name/description/token/requestAttributes/group |
DELETE | /rest/v1/targets/{cid} | delete |
GET | /rest/v1/targets/{cid}/attributes | device-reported attributes |
POST / DELETE | /rest/v1/targets/{cid}/targettype | assign / unassign the target type |
POST / GET | /rest/v1/targets/{cid}/metadata | create (JSON array) / list metadata |
| GET / PUT / DELETE | /rest/v1/targets/{cid}/metadata/{key} | get / update / delete one entry |
| POST | /rest/v1/targets/{cid}/assignedDS | assign a DS (creates an action); optional maintenanceWindow |
| GET | /rest/v1/targets/{cid}/assignedDS | currently assigned DS (or 204) |
| GET | /rest/v1/targets/{cid}/installedDS | last installed DS (or 204) |
| GET | /rest/v1/targets/{cid}/actions | actions for this target |
| GET | /rest/v1/targets/{cid}/actions/{aid} | one action |
| PUT | /rest/v1/targets/{cid}/actions/{aid} | escalate the force type, {"forceType":"forced"} |
| GET | /rest/v1/targets/{cid}/actions/{aid}/status | action status history (paging/sort) |
| DELETE | /rest/v1/targets/{cid}/actions/{aid} | cancel (?force=true to force) |
| GET | /rest/v1/targets/{cid}/autoConfirm | auto-confirm state |
| POST | /rest/v1/targets/{cid}/autoConfirm/activate | enable auto-confirm |
| POST | /rest/v1/targets/{cid}/autoConfirm/deactivate | disable auto-confirm |
A target’s requestAttributes flag controls whether its DDI poll advertises the
configData link. It is set when the target is created and cleared once the
device uploads its attributes; re-arm it to ask for a fresh upload:
curl -u admin:pw -X PUT localhost:8088/rest/v1/targets/device-42 \
-H 'Content-Type: application/json' -d '{"requestAttributes": true}'
Embedded set and tag references
GET /rest/v1/targets and GET /rest/v1/targets/{cid} include the target’s
installed and assigned distribution sets, and its tags, inline:
{
"controllerId": "dev-a91f3c",
"updateStatus": "pending",
"installedDs": { "id": 4, "name": "gw-linux", "version": "2026.05", "type": "os_app" },
"assignedDs": { "id": 7, "name": "gw-linux", "version": "2026.07", "type": "os_app" },
"tags": [{ "id": 2, "name": "linux-gw", "colour": "#4f9cf9" }]
}
A raptor extension, and additive: hawkBit exposes these only as installedDS,
assignedDS and per-tag reverse lookups, so a list view could otherwise only
fetch them one request per row. The fields are omitted when absent — no
installed set is different from an empty one — and every other endpoint that
returns a target (create, tag assignment) serialises exactly as before.
Resolved in a fixed number of queries for the whole page regardless of page size,
because assigned_ds_id and installed_ds_id are columns on the target row.
The set type is the type key (os, app, os_app), not its id: it is what
says whether a given device class can install that set at all.
Software modules & artifacts
| Method | Path | Description |
|---|---|---|
POST / GET | /rest/v1/softwaremodules | create / list |
GET / PUT / DELETE | /rest/v1/softwaremodules/{id} | get / update / delete (409 if the module belongs to a distribution set) |
POST / GET | /rest/v1/softwaremodules/{id}/artifacts | upload (multipart) / list |
GET / DELETE | /rest/v1/softwaremodules/{id}/artifacts/{aid} | get / delete |
GET | /rest/v1/softwaremodules/{id}/artifacts/{aid}/download | download |
POST / GET | /rest/v1/softwaremodules/{id}/metadata | create (JSON array) / list metadata |
GET / PUT / DELETE | /rest/v1/softwaremodules/{id}/metadata/{key} | get / update / delete one entry (targetVisible surfaces to devices) |
Distribution sets
| Method | Path | Description |
|---|---|---|
POST / GET | /rest/v1/distributionsets | create / list |
GET / PUT / DELETE | /rest/v1/distributionsets/{id} | get / update / delete |
POST | /rest/v1/distributionsets/{id}/invalidate | invalidate (stops rollouts / auto-assign, cancels actions) |
POST / GET | /rest/v1/distributionsets/{id}/assignedSM | add / list modules |
POST / GET | /rest/v1/distributionsets/{id}/metadata | create (JSON array) / list metadata |
GET / PUT / DELETE | /rest/v1/distributionsets/{id}/metadata/{key} | get / update / delete one entry |
Actions (fleet-wide)
| Method | Path | Description |
|---|---|---|
GET | /rest/v1/actions | list all actions (paging/sort/FIQL) |
GET | /rest/v1/system/configs | tenant configuration (read-only; file-driven) |
GET / PUT / DELETE | /rest/v1/system/configs/{key} | one config key (writes → 403) |
GET | /rest/v1/system/statistics | fleet counters (targets/actions/…), optional q= |
Every action carries deploymentFetchCount (raptor extension, not in hawkBit):
deploymentBase fetches since the last feedback report of any kind. A device
stuck re-fetching without ever reporting progress back looks identical to a
slow install otherwise — the web console flags an active action once this
passes a small threshold with a “fetched N×, no feedback” badge, distinguishing
a client-side reinstall loop from a healthy in-progress update at a glance.
Scoping statistics to a filter
/rest/v1/system/statistics accepts an optional FIQL q=, the same query the
target list and saved target filters take:
curl -u admin:pw 'localhost:8088/rest/v1/system/statistics?q=tag%3D%3Dlinux-gw'
q narrows the target counters — totalTargets and targetsByStatus — to
the matching targets. It lets a caller read one saved filter’s in-sync /
pending / error split in a single request rather than one request per status.
The catalogue and action counters (totalDistributionSets,
totalSoftwareModules, totalActions, totalRollouts, activeActions) stay
fleet-wide whether or not q is set: scoping “how many distribution sets exist”
to a set of targets has no meaning, and reporting them as 0 would read as
missing data. An unparseable or unknown-field query returns 400.
Types
Software-module, distribution-set and target types support full CRUD. The
default os / firmware / runtime / application module types and os /
os_app / app DS types are seeded, and a DS type’s mandatory module types
drive whether a distribution set is complete. Deleting a type that is still in
use returns 409.
| Method | Path | Description |
|---|---|---|
POST / GET | /rest/v1/softwaremoduletypes | create / list module types |
GET / PUT / DELETE | /rest/v1/softwaremoduletypes/{id} | get / update (description) / delete |
POST / GET | /rest/v1/distributionsettypes | create (with mandatory/optional module types) / list |
GET / PUT / DELETE | /rest/v1/distributionsettypes/{id} | get / update (description) / delete |
GET / POST | /rest/v1/distributionsettypes/{id}/mandatorymoduletypes | list / add mandatory module type |
DELETE | /rest/v1/distributionsettypes/{id}/mandatorymoduletypes/{mid} | remove mandatory module type |
GET / POST | /rest/v1/distributionsettypes/{id}/optionalmoduletypes | list / add optional module type |
DELETE | /rest/v1/distributionsettypes/{id}/optionalmoduletypes/{mid} | remove optional module type |
POST / GET | /rest/v1/targettypes | create (with compatible DS types) / list |
GET / PUT / DELETE | /rest/v1/targettypes/{id} | get / update / delete |
GET / POST | /rest/v1/targettypes/{id}/compatibledistributionsettypes | list / add compatible DS type |
DELETE | /rest/v1/targettypes/{id}/compatibledistributionsettypes/{dsid} | remove compatible DS type |
Tags
Target and distribution-set tags are free-form labels (name, description,
colour) used for fleet organisation and as the tag== FIQL term on the target
and distribution-set lists. Deleting a tag removes its assignments; the tagged
targets and sets are untouched. Assignment is idempotent — re-assigning an
already-tagged entity succeeds without creating a duplicate.
Every TagRest carries assignedCount (raptor extension, not in hawkBit’s
MgmtTag) — how many targets (or distribution sets) carry that tag, computed
with a GROUP BY over the assignment table so the tag list stays a single
request regardless of page size.
| Method | Path | Description |
|---|---|---|
POST / GET | /rest/v1/targettags | create (array body) / list |
GET / PUT / DELETE | /rest/v1/targettags/{id} | get / update / delete |
GET | /rest/v1/targettags/{id}/assigned | list tagged targets (paging, sort=, q=) |
GET | /rest/v1/targets/{cid}/tags | tags carried by one target (raptor extension) |
POST / DELETE | /rest/v1/targettags/{id}/assigned | bulk assign / unassign, body ["dev-1","dev-2"] |
POST / DELETE | /rest/v1/targettags/{id}/assigned/{cid} | assign / unassign one target |
POST / GET | /rest/v1/distributionsettags | create (array body) / list |
GET / PUT / DELETE | /rest/v1/distributionsettags/{id} | get / update / delete |
GET | /rest/v1/distributionsettags/{id}/assigned | list tagged distribution sets |
GET | /rest/v1/distributionsets/{id}/tags | tags carried by one set (raptor extension) |
POST / DELETE | /rest/v1/distributionsettags/{id}/assigned | bulk assign / unassign, body [1,2] |
POST / DELETE | /rest/v1/distributionsettags/{id}/assigned/{dsid} | assign / unassign one set |
Rollouts
| Method | Path | Description |
|---|---|---|
POST / GET | /rest/v1/rollouts | create / list |
GET / DELETE | /rest/v1/rollouts/{id} | get / delete |
POST | /rest/v1/rollouts/{id}/approve | approve (?remark=) — waiting_for_approval → ready |
POST | /rest/v1/rollouts/{id}/deny | deny (?remark=) — terminal |
POST | /rest/v1/rollouts/{id}/start | start (schedules first group) |
POST | /rest/v1/rollouts/{id}/pause | pause |
POST | /rest/v1/rollouts/{id}/resume | resume |
POST | /rest/v1/rollouts/{id}/stop | stop (cancels the updates it issued) |
GET | /rest/v1/rollouts/{id}/deploygroups | list groups |
GET | /rest/v1/rollouts/{id}/deploygroups/{gid} | one group |
GET | /rest/v1/rollouts/{id}/deploygroups/{gid}/targets | controllerIds in a group |
Rollout and group payloads carry totalTargetsPerStatus (notstarted, scheduled,
running, error, finished, cancelled) — see the
Rollouts guide.
Both also carry dynamic. On a rollout it reports whether a trailing group
keeps absorbing newly-matching targets; on a group, whether it is that trailing
group. Creation takes dynamic plus an optional dynamicGroupTemplate
({nameSuffix, targetCount}), which is rejected unless dynamic is true — see
Dynamic rollouts. A dynamic rollout
does not finish on its own; it runs until stopped.
Target filters
| Method | Path | Description |
|---|---|---|
POST / GET | /rest/v1/targetfilters | create / list |
GET / PUT / DELETE | /rest/v1/targetfilters/{id} | get / update / delete |
GET / POST / DELETE | /rest/v1/targetfilters/{id}/autoAssignDS | read / attach / detach auto-assign DS |
Common status codes
| Code | When |
|---|---|
200 / 201 | success / created |
204 | no content (e.g. no assigned DS) |
400 | invalid FIQL or malformed body |
401 | bad or missing credentials |
404 | unknown entity |
409 | duplicate key (e.g. module name+version+type) |
410 | feedback for a non-active action |
See Error Codes for the response body shape.
DDI API Reference
The device-facing API, under /{tenant}/controller/v1/{controllerId}. Requests
are authenticated by target token, gateway token, or anonymous mode — see
Authentication.
Configure clients with tenant
DEFAULT. raptor answers to exactly one tenant, set by thetenantconfig key (defaultDEFAULT, matched case-insensitively). A device configured with any other tenant — Zephyr’sCONFIG_HAWKBIT_TENANTin particular — gets404on every DDI request. If your fleet is genuinely configured with a non-default tenant name, settenantto match rather than reconfiguring every device.
Response JSON matches the hawkBit DDI v1 schemas field-for-field.
Handlers live under raptor/src/api/ddi/, one module per resource: poll root
in root.rs, deployment/installed base in deployment.rs, feedback/cancel in
feedback.rs, confirmation flow in confirmation.rs, and artifacts in
artifacts.rs, all wired together in mod.rs.
Endpoints
| Method | Path (under /{tenant}/controller/v1/{cid}) | Description |
|---|---|---|
GET | / | poll root: config.polling.sleep + _links |
PUT | /configData | report device attributes (merge / replace / remove) |
GET | /deploymentBase/{actionId} | the deployment to install |
POST | /deploymentBase/{actionId}/feedback | deployment progress/result |
GET | /confirmationBase/{actionId} | pending deployment awaiting confirmation |
POST | /confirmationBase/{actionId}/feedback | confirm / deny |
POST | /confirmationBase/activateAutoConfirm | device enables auto-confirm |
POST | /confirmationBase/deactivateAutoConfirm | device disables auto-confirm |
GET | /cancelAction/{actionId} | cancellation to acknowledge |
POST | /cancelAction/{actionId}/feedback | confirm cancellation |
GET | /installedBase/{actionId} | last successfully installed deployment |
GET | /softwaremodules/{moduleId}/artifacts | artifact list for a module |
GET | /softwaremodules/{moduleId}/artifacts/{filename} | artifact download (HTTP Range) |
GET | /softwaremodules/{moduleId}/artifacts/{filename}.MD5SUM | md5sum-file |
Poll root
{
"config": { "polling": { "sleep": "00:05:00" } },
"_links": {
"configData": { "href": ".../configData" },
"deploymentBase": { "href": ".../deploymentBase/7" }
}
}
config.polling.sleep is not always the configured default: [ddi] polling_interval can carry RSQL-matched override rules (hawkBit 0.10’s
pollingTime grammar) that give a slice of the fleet — or one device during
an incident — a different interval, with optional jitter. See Polling time
overrides in the configuration
reference.
Which _links appear depends on the target’s state: deploymentBase when an
action is running, confirmationBase when it’s wait_for_confirmation,
cancelAction when it’s canceling, and installedBase once something has been
installed.
configData appears only while raptor actually wants attributes — a freshly
registered target, or one an operator has re-armed with requestAttributes (see
Management API). It disappears as soon as a
configData PUT arrives. This matters because some clients (the Zephyr hawkbit
client among them) re-upload their entire attribute set on every poll that
carries the link, which is wasted uplink on a large or cellular fleet.
deploymentBase
{
"id": "7",
"deployment": {
"download": "forced",
"update": "forced",
"chunks": [
{ "part": "os", "version": "1.0", "name": "rootfs",
"artifacts": [
{ "filename": "rootfs.img", "size": 12345,
"hashes": { "sha1": "...", "md5": "...", "sha256": "..." },
"_links": { "download-http": {"href": "..."},
"md5sum-http": {"href": "..."} } }
],
"metadata": [ { "key": "signature", "value": "sig-1" } ] }
]
},
"actionHistory": { "status": "RUNNING", "messages": [] }
}
download/update follow the action’s type, matching hawkBit’s
calculateDownloadType/calculateUpdateType:
| Action type | download | update |
|---|---|---|
forced | forced | forced |
soft | attempt | attempt |
timeforced, before forcetime | attempt | attempt |
timeforced, after forcetime | forced | forced |
downloadonly | forced | skip |
The modes are computed per request, so a timeforced action flips on its own
once the deadline passes. See Assignments & Actions for
the operator side. The confirmationBase response is identical but keyed
confirmation instead of deployment.
maintenanceWindow
An action assigned with a
maintenance window adds one more
field inside deployment:
"deployment": { "download": "forced", "update": "skip",
"maintenanceWindow": "unavailable", "chunks": [] }
"unavailable"— the window is shut.updateis forced toskipwhatever the action type says, so the device downloads and waits."available"— the window is open.updateis the action’s real mode.
The key is omitted entirely for an action without a window, so payloads for
ordinary assignments are unchanged. Like the modes above it is computed per
request, so a device polling across the window boundary sees it flip with no
server-side scheduling involved. installedBase replays a finished action and
never carries the field.
A chunk’s metadata array carries any software-module metadata marked
targetVisible (see the Management API). The key is omitted entirely when a
module has no visible metadata.
Feedback
{ "status": { "execution": "closed", "result": { "finished": "success" } } }
execution∈proceeding,scheduled,resumed,downloading,downloaded,canceled,rejected,closed.result.finished∈none,success,failure.
closed is terminal for every action type. For a downloadonly action
downloaded is also terminal — that is the whole job, so the action closes
with detailStatus: downloaded and the target’s installed DS is left unchanged.
For all other types downloaded is recorded as progress only. Posting feedback
to a non-active action returns 410 Gone.
Confirmation feedback uses a different body:
{ "confirmation": "confirmed", "details": ["…"] } // or "denied"
configData
{ "mode": "merge", "data": { "hw": "rev2", "os": "linux" } }
mode ∈ merge (default; upsert keys), replace (drop all, then set), remove
(delete the listed keys). Extra legacy fields in the body are ignored.
Artifact download & Range
The artifact download endpoint honors HTTP Range (RFC 7233) so an interrupted
download resumes with a 206 Partial Content response rather than restarting.
download-http vs download
Each artifact carries up to two link families, following hawkBit’s convention:
download-http / md5sum-http are the plain-HTTP URLs, download / md5sum
the HTTPS ones. Clients pick one and take the scheme from the href itself — the
Zephyr hawkbit client uses download-http.
raptor only advertises a genuinely different plain-HTTP URL when you have told it
one is reachable, via [ddi] artifact_http_url:
url = "https://ota.example.com"
[ddi]
artifact_http_url = "http://dl.example.com:8088"
| Config | download-http | download |
|---|---|---|
url http, no artifact_http_url | the url (http) | (absent) |
url https, no artifact_http_url | the url (https) | the url (https) |
url https + artifact_http_url | artifact_http_url (http) | the url (https) |
The middle row is why download-http can carry an https:// href: on a TLS-only
deployment there is no plain-HTTP port to point at, and emitting one anyway would
break every client that follows the link. Set artifact_http_url when you want
device downloads to bypass TLS.
CLI Reference
The raptor binary has two subcommands.
raptor serve
Runs the server: loads config, connects to the database, applies migrations, starts the background evaluator, and serves HTTP.
$ raptor serve [--config <path>]
| Flag | Default | Description |
|---|---|---|
--config <path> | raptor.toml | path to the TOML config file |
Migrations are applied automatically before the listener starts. The server logs
to stdout/stderr; control verbosity with the RUST_LOG environment variable,
e.g. RUST_LOG=raptor=debug,tower_http=info.
$ RUST_LOG=raptor=debug raptor serve --config /etc/raptor/config.toml
raptor listening bind=0.0.0.0:8088
raptor hash-password
Reads a password from stdin and prints its argon2id hash for use in
mgmt.password_hash.
$ printf 'yourpassword\n' | raptor hash-password
$argon2id$v=19$m=19456,t=2,p=1$...
Pipe from a file or a secrets manager rather than typing interactively in scripts. The output is safe to store in the config file (it’s a hash, not the password).
Version
$ raptor --version
Error Codes
raptor returns hawkBit-shaped error bodies so that existing clients — which
branch on the HTTP status code and sometimes the errorCode string — behave
identically against raptor.
Body shape
{
"exceptionClass": "org.eclipse.hawkbit.repository.exception.EntityNotFoundException",
"errorCode": "hawkbit.server.error.repo.entitiyNotFound",
"message": "target not found"
}
The exceptionClass and errorCode strings mirror hawkBit’s for the covered
cases (including hawkBit’s historical entitiy spelling), because some clients
match on them. Most clients branch on the status code, which is the hard
contract.
Status codes
| Code | Meaning | Example |
|---|---|---|
400 Bad Request | invalid FIQL or malformed body | q=bogusField==1, incomplete DS assignment |
401 Unauthorized | bad or missing credentials | wrong target token, missing Basic auth |
404 Not Found | unknown entity | target / module / action doesn’t exist |
409 Conflict | duplicate key | module name+version+type, duplicate filter name |
410 Gone | feedback for a non-active action | device reports on a finished/canceled action |
Notable errorCode strings
| errorCode | Paired status |
|---|---|
hawkbit.server.error.repo.entitiyNotFound | 404 |
hawkbit.server.error.rest.body.notReadable | 400 |
hawkbit.server.error.unauthorized | 401 |
hawkbit.server.error.repo.entitiyAlreadyExists | 409 |
hawkbit.server.error.repo.actionNotActive | 410 |
Auth responses
401 from the Management zone includes a WWW-Authenticate: Basic header so
tools prompt for credentials. The web console’s own session checks use a “quiet”
variant that omits the header, to avoid triggering the browser’s native Basic-auth
dialog on the single-page app.
FAQ
Is raptor a drop-in replacement for hawkBit?
For the DDI v1 device protocol, yes — devices using SWUpdate, the RAUC hawkbit-updater, or other hawkBit DDI clients work unchanged. For the Management API, raptor implements the core workflow but not every endpoint; see hawkBit Compatibility.
Which databases are supported?
SQLite and Postgres, selected by the database_url scheme. Migrations run
automatically at startup. SQLite is great for small/single-node deployments;
Postgres for larger fleets or when you want an external managed database.
Does raptor need RabbitMQ?
No. raptor implements only the HTTP-based DDI device path, not hawkBit’s DMF (AMQP) path, so there is no broker to run.
Can I run multiple tenants on one raptor?
No. raptor is single-tenant: it answers to exactly one tenant name (the
tenant config key, default DEFAULT) and rejects any other DDI tenant
segment with 404. Run one raptor instance per fleet. See
the design doc
for why.
How do devices authenticate?
Per-target security tokens, a shared gateway token (which also enables auto-registration), or anonymous mode for development. See Authentication.
Where does raptor store artifacts?
In a content-addressed store on local disk under artifact_dir, laid out as
<sha256[0..2]>/<sha256>. Identical content is stored once. The Debian package
defaults this to /var/lib/raptor/artifacts.
Can I offload artifact storage to S3?
Not yet — storage is local disk only. S3/object-storage backends are a tracked enhancement.
How do I change the admin password?
Generate a new hash with raptor hash-password, put it in mgmt.password_hash
(or the RAPTOR_MGMT__PASSWORD_HASH env var), and restart. There is a single
admin user in this version.
How do I make a deployment wait for approval?
Two different gates, depending on what you want to hold back:
- Every assignment, waiting on the device. Enable the
confirmation flow
(
[ddi] confirmation_flow = true). Assignments then wait for the device (or an operator via auto-confirm) before deploying. - Each rollout, waiting on an operator. Set
rollout_approval_enabled = true. A new rollout lands inwaiting_for_approvaland cannot be started until someone approves it — see Approval workflow.
Why is my deploymentBase returning 404?
The action is not in a state that serves a deployment. If the confirmation flow
is on, the device should be using confirmationBase until the action is
confirmed; deploymentBase only serves running actions. Check the action’s
detailStatus via GET /rest/v1/targets/{cid}/actions/{aid}.
How do I upgrade raptor?
Install the new binary/package and restart. Schema migrations apply automatically
on startup. With the Debian package, your /etc/raptor/config.toml edits are
preserved (it’s a dpkg conffile).
Where do I report bugs or request features?
On the GitHub repository. The compatibility matrix lists what’s not yet implemented; most of those items have tracking issues.