Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

raptor logo

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

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:

hawkBitraptor
RuntimeJVM / Spring Bootsingle static binary
DatabaseMySQL / PostgresSQLite or Postgres
Message brokerRabbitMQ (DMF)none
Device APIDDI v1 + DMFDDI v1
Deploymentmulti-serviceone 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 = true disables 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.

FeatureStatus
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

AreaStatus
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 weightsremoved 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

MechanismStatus
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 transient DynamicUser, 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 (the mode=rwc creates the file)
  • Postgres: postgres://user:pass@host/dbname

Next steps

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

SectionPurpose
top levelbind, 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:

StatusMeaning
unknowncreated via the Management API, never polled
registeredknown to the server, no update assigned
pendingan update is assigned and in progress
in_syncrunning the assigned distribution set
errorthe 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 targetConstrains anythingAnswers
Groupat most onenowhere does this device sit in the fleet
Tagsmanynowhat is true about this device right now
Target typeat most oneyes — which DS types may be assignedwhat 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:

typedownloadupdateMeaning
forced (default)forcedforcedinstall as soon as possible
softattemptattemptthe device may defer per its own policy
timeforcedattemptforcedattemptforcedsoft until forcetime, forced after
downloadonlyforcedskipfetch 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.

  • scheduleQuartz 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 — 2 means 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 to 23: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:00 in 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

StateactiveMeaning
wait_for_confirmationyesawaiting confirmation before deploying (see Confirmation Flow)
runningyesdevice has been told to deploy
cancelingyescancellation requested, awaiting device acknowledgement
cancelednocancellation confirmed (or forced)
finishednodeployment succeeded
errornodeployment 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

  1. 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.
  2. Each group has a success threshold and an error threshold (percentages).
  3. Starting the rollout schedules the first group only — its targets get the DS assigned.
  4. 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.
  5. 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"}
      }'
  • amountGroups splits matching targets into that many groups.
  • successCondition.expression / errorCondition.expression are percentages (0–100). If errorCondition is omitted, the error threshold never trips.
  • type is the action type every action the rollout creates inherits — forced (default), soft, timeforced or downloadonly — with forcetime alongside it for timeforced. A staged download-then-install is therefore a downloadonly rollout followed by a forced one over the same filter. An unknown type is rejected with 400.

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
  • startreadyrunning; schedules the first group.
  • pauserunningpaused; the evaluator ignores paused rollouts.
  • resumepausedrunning; re-evaluates immediately.
  • stop — any non-terminal status → stoppingstopped; 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:

  1. Every active action the rollout issued moves to canceling and is served to the device as cancelAction on its next poll. Groups that had not finished are marked stopped; ones that already finished keep their outcome.
  2. The rollout reports stopping while those cancels are outstanding.
  3. As each device acknowledges over DDI, its action becomes canceled. Once none are left active the rollout settles to stopped.

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 (including canceling and, 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):

FieldMeaningDefault
targetCounthow many targets one dynamic group takes before the next is openedthe size of the last static group
nameSuffixappended to the generated group-<n> namenone

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 running no matter how many of its targets succeed. Ending one is an operator action: POST /rest/v1/rollouts/{id}/stop, or raptorctl — 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 targetCount targets, 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 the max_rollout_groups_per_rollout quota — 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 invalidate keeps it from being drawn onto further devices; use cancelRollouts to 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 (400 otherwise).
  • typeforced (default) or soft.

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/409 messages are shown against the query and name fields.
  • Auto-assign… — pick a distribution set and the action type (forced or soft), 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 configData re-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 offers confirmationBase and no deploymentBase. 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. Use auto_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:

OperatorMeaning
==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:

  • targetsid/controllerId, name, description, updateStatus, lastControllerRequestAt, address, group, autoConfirm, tag, attribute.<key>
  • distribution setsid, name, version, description, complete, tag
  • target tags / DS tagsid, name, description, colour
  • actionsid, active, detailStatus
  • rolloutsid, name, status
  • target filtersid, 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 (configDatazephyr, 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 tokenAuthorization: TargetToken <token>, matched against the target’s stored token. Each target has its own token (generated at creation, or supplied).
  • Gateway tokenAuthorization: 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 url to the externally visible base URL so _links are dialable through the proxy (see Configuration).
  • Prefer environment variables or the systemd raptor.env file 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'
CommandDoes
target list/get/create/set/deletetarget CRUD
target attributes <cid>reported device attributes
target tag add|rm <cid> <tag>tag/untag a target
target type list/set/cleartarget 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/createsoftware module CRUD
artifact upload/list/delete <moduleId>artifact management
ds list/get/createdistribution 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/forcedeployment action control
rollout list/approve/denylist rollouts, decide ones awaiting approval
statusfleet-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:

FlagVocabularySeeded values
--module-type (alias --type)software-module typesos, firmware, runtime, application
--ds-typedistribution-set typesos, os_app, app

--ds-type is optional: raptorctl reads /rest/v1/softwaremoduletypes and /rest/v1/distributionsettypes and derives it — an exact key match wins (osos), otherwise the one set type that requires exactly that module type (applicationapp). 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:

FlagEffect
--cancel-rolloutsalso stop rollouts deploying this set
--cancel-actions softask devices to stop; the action ends when the device confirms
--cancel-actions forcecancel 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
KeyDoes
↑↓ / j/k, g/Gmove selection
/filter targets (sent server-side as a FIQL q=)
aassign a distribution set to the selected target
ttag the selected target
c / fcancel / force the target’s active action (y to confirm)
rrefresh now
?help
q / Escquit

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 choiceHeader sentraptor side
CONFIG_HAWKBIT_DDI_GATEWAY_SECURITY=yAuthorization: GatewayToken <token>[ddi] gateway_token = "<token>"
CONFIG_HAWKBIT_DDI_TARGET_SECURITY=yAuthorization: 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 featureZephyr clientraptor
Base poll config.polling.sleep (HH:MM:SS)reads and honours✅ verified
_links.deploymentBaseparsed✅ verified
_links.configDataparsed; uploads whenever present✅ verified — advertised only when attributes are wanted, so devices don’t re-upload every poll
_links.cancelActionparsed✅ verified
_links.confirmationBasenot 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/artifactsparsed✅ verified
Artifact hashes.sha256verified against flashed image✅ verified
Artifact _links.download-httpthe 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 / resultclosed, proceeding, canceled, scheduled, rejected, resumed, none / success, failure, none✅ verified
Authorization: TargetToken / GatewayTokeneither, chosen at build time✅ verified
Multi-tenancyCONFIG_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 running and active, never finished;
  • a status history with the initial entries and nothing since — no proceeding, no success, no failure;
  • in the server log, repeated GET .../deploymentBase/{id} and artifact GETs with no POST .../feedback between 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 bootsuricatta reportsaction
1 INSTALLEDproceeding (“Testing Pending”)stays open
2 TESTINGsuccesscloses
3 FAILEDfailurecloses 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.config is in libubootenv YAML form, not the two-line form
  • the boot environment namespace is listed first
  • swupdate.cfg sets both fwenv-config-location and namespace-vars
  • the bootloader backend is not none — use uboot over a file if there is no real U-Boot
  • a health check sets ustate=2 on pass and 3 on 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

PathPurpose
/usr/bin/raptorthe binary (web console embedded)
/etc/raptor/config.tomlconfig, a dpkg conffile (edits survive upgrades)
/var/lib/raptor/state: SQLite DB and artifact blobs
/etc/raptor/raptor.envoptional, 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 raptor user to manage and no postinst chown.
  • /var/lib/raptor is created and owned by that user automatically, mode 0750.

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 bind to a privileged port (< 1024), grant the capability explicitly with AmbientCapabilities=CAP_NET_BIND_SERVICE via 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_url scheme.
  • 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

  1. A request hits axum. A tower middleware enforces the right auth zone — ddi_auth for /{tenant}/controller/v1/..., mgmt_auth for /rest/v1/....
  2. The handler (api/ddi or api/mgmt) validates input and calls into the domain layer.
  3. The domain layer owns the rules — the action state machine, rollout evaluation, auto-assignment — and talks to persistence through entities.
  4. 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, an updateStatus (unknownregisteredpendingin_sync / error), the last poll time and request address, an auto_confirm flag, 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). complete when 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), an active flag, and a forced/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)
  1. Assign. POST /rest/v1/targets/{id}/assignedDS creates an Action in running (or wait_for_confirmation if the confirmation flow is on), and sets the target to pending. Any prior active action is cancelled.
  2. Poll. The device polls the DDI root and sees _links.deploymentBase.
  3. Fetch & download. The device gets deploymentBase/{actionId} and downloads the listed artifacts (with HTTP Range resume).
  4. Feedback. The device reports proceeding, then a final result. Each report appends an ActionStatus row.
  5. Complete. On closed/success the Action becomes finished, the target becomes in_sync, and installedBase reflects the deployment. On closed/failure the Action becomes error and the target becomes error.

Feedback vocabulary

Device feedback is {"status":{"execution": ..., "result":{"finished": ...}}}.

  • executionproceeding, scheduled, resumed, downloading, downloaded, canceled, rejected, closed.
  • result.finishednone, 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

KeyTypeDefaultDescription
bindsocket addr0.0.0.0:8088address the HTTP server listens on
database_urlstring(required)sqlite://… or postgres://…; selects the backend
artifact_dirpath(required)root of the content-addressed artifact store
max_artifact_sizeinteger (bytes)1073741824 (1 GiB)maximum artifact upload size
urlstring(unset)external base URL for _links; derived from the Host header when unset
rollout_eval_interval_secsinteger5how often the background evaluator / auto-assign sweep runs
rollout_approval_enabledboolfalsegate new rollouts behind an operator approval (waiting_for_approval); reported as hawkBit’s rollout.approval.enabled
tenantstringDEFAULTtenant 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.

KeyDefaultCaps
max_status_entries_per_action1000status entries a device may report against one action
max_messages_per_action_status50messages a device may attach to one reported status
max_attribute_entries_per_target100attributes a device may report about itself
max_metadata_entries_per_target100metadata entries per target
max_metadata_entries_per_software_module100metadata entries per software module
max_metadata_entries_per_distribution_set100metadata entries per distribution set
max_artifacts_per_software_module50artifacts per software module
max_software_modules_per_distribution_set100modules per distribution set
max_rollout_groups_per_rollout500deployment groups per rollout
max_targets_per_rollout_group20000targets 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.

KeyTypeDefaultDescription
enabledboolfalserun the sweep at all
action_expiry_daysinteger30how long a closed action is kept, from when it was last modified
action_statuseslist["finished", "error", "canceled"]which statuses may be deleted
interval_secsinteger3600how 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

KeyTypeDefaultDescription
anonymousboolfalsedisable all DDI auth (dev only)
gateway_tokenstring(unset)shared token; enables auto-registration
polling_intervalstring00:05:00poll sleep advertised to devices — see Polling time overrides below
confirmation_flowboolfalserequire confirmation before a deployment starts
auto_confirm_defaultboolfalsegive newly created targets autoConfirm, so confirmation_flow can’t strand confirmation-unaware clients
artifact_http_urlstring(unset)plain-HTTP base advertised in the DDI download-http links; unset means they reuse url
trusted_proxy_headerstring(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:SS form (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: write group==eu, not hawkBit’s own doc example group == '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 — fails raptor serve at startup with an error, rather than surfacing on a device’s poll.
  • Known limitation, inherited from hawkBit: the Management API’s pollStatus.overdue is 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

KeyTypeDefaultDescription
usernamestring(required)admin username
password_hashstring(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

MethodPathDescription
POST/rest/v1/loginexchange credentials for a session cookie
POST/rest/v1/logoutclear the session
GET/rest/v1/session204 if the request is authenticated, 401 if not
GET/healthliveness probe (returns ok)

Targets

MethodPathDescription
POST/rest/v1/targetscreate targets (JSON array)
GET/rest/v1/targetslist (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}/attributesdevice-reported attributes
POST / DELETE/rest/v1/targets/{cid}/targettypeassign / unassign the target type
POST / GET/rest/v1/targets/{cid}/metadatacreate (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

MethodPathDescription
POST / GET/rest/v1/softwaremodulescreate / 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}/artifactsupload (multipart) / list
GET / DELETE/rest/v1/softwaremodules/{id}/artifacts/{aid}get / delete
GET/rest/v1/softwaremodules/{id}/artifacts/{aid}/downloaddownload
POST / GET/rest/v1/softwaremodules/{id}/metadatacreate (JSON array) / list metadata
GET / PUT / DELETE/rest/v1/softwaremodules/{id}/metadata/{key}get / update / delete one entry (targetVisible surfaces to devices)

Distribution sets

MethodPathDescription
POST / GET/rest/v1/distributionsetscreate / list
GET / PUT / DELETE/rest/v1/distributionsets/{id}get / update / delete
POST/rest/v1/distributionsets/{id}/invalidateinvalidate (stops rollouts / auto-assign, cancels actions)
POST / GET/rest/v1/distributionsets/{id}/assignedSMadd / list modules
POST / GET/rest/v1/distributionsets/{id}/metadatacreate (JSON array) / list metadata
GET / PUT / DELETE/rest/v1/distributionsets/{id}/metadata/{key}get / update / delete one entry

Actions (fleet-wide)

MethodPathDescription
GET/rest/v1/actionslist all actions (paging/sort/FIQL)
GET/rest/v1/system/configstenant configuration (read-only; file-driven)
GET / PUT / DELETE/rest/v1/system/configs/{key}one config key (writes → 403)
GET/rest/v1/system/statisticsfleet 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.

MethodPathDescription
POST / GET/rest/v1/softwaremoduletypescreate / list module types
GET / PUT / DELETE/rest/v1/softwaremoduletypes/{id}get / update (description) / delete
POST / GET/rest/v1/distributionsettypescreate (with mandatory/optional module types) / list
GET / PUT / DELETE/rest/v1/distributionsettypes/{id}get / update (description) / delete
GET / POST/rest/v1/distributionsettypes/{id}/mandatorymoduletypeslist / add mandatory module type
DELETE/rest/v1/distributionsettypes/{id}/mandatorymoduletypes/{mid}remove mandatory module type
GET / POST/rest/v1/distributionsettypes/{id}/optionalmoduletypeslist / add optional module type
DELETE/rest/v1/distributionsettypes/{id}/optionalmoduletypes/{mid}remove optional module type
POST / GET/rest/v1/targettypescreate (with compatible DS types) / list
GET / PUT / DELETE/rest/v1/targettypes/{id}get / update / delete
GET / POST/rest/v1/targettypes/{id}/compatibledistributionsettypeslist / 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.

MethodPathDescription
POST / GET/rest/v1/targettagscreate (array body) / list
GET / PUT / DELETE/rest/v1/targettags/{id}get / update / delete
GET/rest/v1/targettags/{id}/assignedlist tagged targets (paging, sort=, q=)
GET/rest/v1/targets/{cid}/tagstags carried by one target (raptor extension)
POST / DELETE/rest/v1/targettags/{id}/assignedbulk assign / unassign, body ["dev-1","dev-2"]
POST / DELETE/rest/v1/targettags/{id}/assigned/{cid}assign / unassign one target
POST / GET/rest/v1/distributionsettagscreate (array body) / list
GET / PUT / DELETE/rest/v1/distributionsettags/{id}get / update / delete
GET/rest/v1/distributionsettags/{id}/assignedlist tagged distribution sets
GET/rest/v1/distributionsets/{id}/tagstags carried by one set (raptor extension)
POST / DELETE/rest/v1/distributionsettags/{id}/assignedbulk assign / unassign, body [1,2]
POST / DELETE/rest/v1/distributionsettags/{id}/assigned/{dsid}assign / unassign one set

Rollouts

MethodPathDescription
POST / GET/rest/v1/rolloutscreate / list
GET / DELETE/rest/v1/rollouts/{id}get / delete
POST/rest/v1/rollouts/{id}/approveapprove (?remark=) — waiting_for_approvalready
POST/rest/v1/rollouts/{id}/denydeny (?remark=) — terminal
POST/rest/v1/rollouts/{id}/startstart (schedules first group)
POST/rest/v1/rollouts/{id}/pausepause
POST/rest/v1/rollouts/{id}/resumeresume
POST/rest/v1/rollouts/{id}/stopstop (cancels the updates it issued)
GET/rest/v1/rollouts/{id}/deploygroupslist groups
GET/rest/v1/rollouts/{id}/deploygroups/{gid}one group
GET/rest/v1/rollouts/{id}/deploygroups/{gid}/targetscontrollerIds 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

MethodPathDescription
POST / GET/rest/v1/targetfilterscreate / list
GET / PUT / DELETE/rest/v1/targetfilters/{id}get / update / delete
GET / POST / DELETE/rest/v1/targetfilters/{id}/autoAssignDSread / attach / detach auto-assign DS

Common status codes

CodeWhen
200 / 201success / created
204no content (e.g. no assigned DS)
400invalid FIQL or malformed body
401bad or missing credentials
404unknown entity
409duplicate key (e.g. module name+version+type)
410feedback 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 the tenant config key (default DEFAULT, matched case-insensitively). A device configured with any other tenant — Zephyr’s CONFIG_HAWKBIT_TENANT in particular — gets 404 on every DDI request. If your fleet is genuinely configured with a non-default tenant name, set tenant to 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

MethodPath (under /{tenant}/controller/v1/{cid})Description
GET/poll root: config.polling.sleep + _links
PUT/configDatareport device attributes (merge / replace / remove)
GET/deploymentBase/{actionId}the deployment to install
POST/deploymentBase/{actionId}/feedbackdeployment progress/result
GET/confirmationBase/{actionId}pending deployment awaiting confirmation
POST/confirmationBase/{actionId}/feedbackconfirm / deny
POST/confirmationBase/activateAutoConfirmdevice enables auto-confirm
POST/confirmationBase/deactivateAutoConfirmdevice disables auto-confirm
GET/cancelAction/{actionId}cancellation to acknowledge
POST/cancelAction/{actionId}/feedbackconfirm cancellation
GET/installedBase/{actionId}last successfully installed deployment
GET/softwaremodules/{moduleId}/artifactsartifact list for a module
GET/softwaremodules/{moduleId}/artifacts/{filename}artifact download (HTTP Range)
GET/softwaremodules/{moduleId}/artifacts/{filename}.MD5SUMmd5sum-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 typedownloadupdate
forcedforcedforced
softattemptattempt
timeforced, before forcetimeattemptattempt
timeforced, after forcetimeforcedforced
downloadonlyforcedskip

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. update is forced to skip whatever the action type says, so the device downloads and waits.
  • "available" — the window is open. update is 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" } } }
  • executionproceeding, scheduled, resumed, downloading, downloaded, canceled, rejected, closed.
  • result.finishednone, 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" } }

modemerge (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"
Configdownload-httpdownload
url http, no artifact_http_urlthe url (http)(absent)
url https, no artifact_http_urlthe url (https)the url (https)
url https + artifact_http_urlartifact_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>]
FlagDefaultDescription
--config <path>raptor.tomlpath 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

CodeMeaningExample
400 Bad Requestinvalid FIQL or malformed bodyq=bogusField==1, incomplete DS assignment
401 Unauthorizedbad or missing credentialswrong target token, missing Basic auth
404 Not Foundunknown entitytarget / module / action doesn’t exist
409 Conflictduplicate keymodule name+version+type, duplicate filter name
410 Gonefeedback for a non-active actiondevice reports on a finished/canceled action

Notable errorCode strings

errorCodePaired status
hawkbit.server.error.repo.entitiyNotFound404
hawkbit.server.error.rest.body.notReadable400
hawkbit.server.error.unauthorized401
hawkbit.server.error.repo.entitiyAlreadyExists409
hawkbit.server.error.repo.actionNotActive410

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 in waiting_for_approval and 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.