Installing the server

Qawk is one static binary and one PostgreSQL. The console is a page and a small Python proxy. There is no application server, no message broker and no cache to run.

What you are running

PieceIsNeeds
qawkThe server. Devices and the API talk to it.PostgreSQL, a directory for artifacts, one port.
PostgreSQLAll the state.16 or later. A volume.
qawk-consoleThe web console and its proxy.To reach the server. Nothing else.

The server image also carries qawk-sim and qawk-load, so you can test a real install with simulated devices without pulling anything else.

The quickest route — Docker Hub, a build from source, compose, or no Docker at all — is in Get started. This page is the rest.

Configuration

Everything is an environment variable, read once at startup. Nothing is read from a file except the optional users list.

VariableDefaultMeaning
QAWK_DATABASE_URLpostgres://qawk:qawk@localhost:5432/qawk?sslmode=disablePostgreSQL.
QAWK_ADMIN_USER
QAWK_ADMIN_PASSWORD
admin / adminThe administrator. Every permission, never stored, always works — which is how a new server is set up and how a lost password is fixed. Set the password.
QAWK_USERS_FILEnoneA YAML file of users, applied at every start. See below.
QAWK_LISTEN:8080hawkBit's port, so devices need no change.
QAWK_PUBLIC_URLthe request's addressBase of every link Qawk hands out. Set it when a proxy rewrites the address devices reach it at.
QAWK_ARTIFACT_DIR/var/lib/qawk/artifactsWhere artifact bytes live.
QAWK_TENANTDEFAULTThe tenant in the device URL.
QAWK_POLLING_TIME00:05:00Device polling interval, until one is set through the API.
QAWK_DB_MAX_CONNS20This instance's pool. Across all instances, keep under PostgreSQL's max_connections.
QAWK_DB_WAIT5mHow long to wait for the database at startup. PostgreSQL's first start initialises its data directory before it listens — over a minute on a busy disk — so the default is generous.
QAWK_AUDIT_DAYS180Audit retention. 0 keeps it for ever.
QAWK_METRICS_TOKENnoneBearer token for /metrics. Empty leaves it open, for a scraper on an internal network.
QAWK_SOURCE_URLthe Qawk repositoryWhere this build's source is. Answered by /qawk/v1/info and shown on the console's About page. If you have changed Qawk and other people use it over a network, point this at your own repository — see below. Blanking it stops the server.
QAWK_LOG_LEVELinfodebug logs every request.
OTEL_*noneThe standard OpenTelemetry variables turn on OTLP export.

The console has exactly one: HB_URL, the server it talks to.

Tenant settings — the gateway token, the polling interval, whether the confirmation flow is on — are hawkBit's /rest/v1/system/configs, with hawkBit's keys and defaults, stored in the database rather than in the environment.

Users from a file

A new server has one user: the administrator. Others are created in the console, through /qawk/v1/users, or listed in a file the server applies at every start — which is how you keep users in configuration management:

users:
  - username: release-manager-1
    password: at-least-eight-characters
    roles: [release-manager]
  - username: operator-1
    password: another-password
    display_name: The night operator
    roles: [operator]
docker run ... -v "$PWD/users.yaml:/etc/qawk/users.yaml:ro" \
  -e QAWK_USERS_FILE=/etc/qawk/users.yaml padovanl/qawk:0.1.0

Missing users are created; existing ones are brought to the file's roles, display name, enabled flag and password. Users not in the file are left alone — the file adds and corrects, it does not delete. A file that breaks the rules stops the server, naming the user it could not take: a server that came up with half its users applied would be worse.

HTTPS

Devices send a bearer token on every poll. Over plain HTTP anyone on the path has your gateway token, and with it your whole fleet. So this is not a detail to leave for later — it is the difference between an update server and a way to install software on somebody's machines.

There are two ways to get it, and both are fine:

Use when
Qawk terminates TLS itself A server in a centre, a single box, a lab — anywhere putting a proxy in front of one Go binary is a second thing to install, configure and patch for no gain.
A proxy terminates it Something already owns your certificates, or you are on Kubernetes where it is the ingress's job.

Qawk terminating TLS

Give it a certificate and a key. Both, or neither — half a pair is refused at startup, because a server that quietly fell back to plain HTTP because of a typo would be the worst possible answer to one.

docker run -d --name qawk --network qawk --restart unless-stopped -p 443:8443 \
  -v qawk-artifacts:/var/lib/qawk \
  -v /etc/qawk/tls:/tls:ro \
  -e QAWK_LISTEN=':8443' \
  -e QAWK_TLS_CERT=/tls/cert.pem \
  -e QAWK_TLS_KEY=/tls/key.pem \
  -e QAWK_DATABASE_URL='postgres://qawk:db-secret@qawk-db:5432/qawk?sslmode=disable' \
  -e QAWK_ADMIN_PASSWORD='a-long-admin-password' \
  qawk:local
VariableDoes
QAWK_TLS_CERT
QAWK_TLS_KEY
PEM certificate and private key. Both together turn HTTPS on. The certificate file should carry the full chain, leaf first.
QAWK_TLS_CLIENT_CA A PEM bundle of authorities. Set it and a device must present its own certificate, verified before any handler runs — mutual TLS.
QAWK_REDIRECT_HTTP An extra plain-HTTP address (:8080) that answers everything with a 308 to the HTTPS one, so devices still pointed at the old URL are told where to go. 308, not 301: it keeps the method and the body, so a device POSTing its feedback does not have it turned into a GET and lost.

TLS 1.2 is the floor — devices in the field outlive fashions in cryptography — and 1.3 is used whenever the client can. Check what you got:

openssl s_client -connect updates.example.com:443 </dev/null | grep -E 'Protocol|Cipher'
Good to know

The container does not run as root, so it cannot bind port 443 inside itself. Listen on a high port and let Docker map it — -p 443:8443 with QAWK_LISTEN=':8443', as above. The certificate files must be readable by uid 10001.

Making a certificate to test with

A self-signed certificate is enough to prove the setup works. It is not enough for production — nothing trusts it, so every client needs to be told to, and that is exactly the habit you do not want devices to have.

mkdir -p /etc/qawk/tls && cd /etc/qawk/tls

openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem \
  -subj "/CN=updates.example.com" \
  -addext "subjectAltName=DNS:updates.example.com,DNS:localhost,IP:127.0.0.1"

chmod 644 cert.pem key.pem      # the container runs as uid 10001
Careful

Put every name devices use in subjectAltName. Modern clients ignore the common name entirely and check only the SAN list. A certificate whose CN is right and whose SAN is missing is rejected by everything, and the error rarely says so.

Then start the server as above and check it end to end:

# the certificate is its own authority, so hand it to curl
curl --cacert /etc/qawk/tls/cert.pem https://localhost/qawk/v1/info

# which protocol and cipher were negotiated
openssl s_client -connect localhost:443 -CAfile /etc/qawk/tls/cert.pem </dev/null \
  2>/dev/null | grep -E 'Protocol|Cipher|Verify return'

# a client that does not know the certificate refuses -- as it should
curl https://localhost/qawk/v1/info        # exit 60: certificate not trusted

# the plain-HTTP port redirects, keeping the method
curl -i -X POST http://localhost:8080/anything   # 308, Location: https://...

For a device, point swupdate.cfg at https:// and give it the certificate as a CA — Connecting devices has the detail. With a real certificate from a public authority, or from your own internal one, nothing has to be told anything.

Mutual TLS, if you want it

A gateway token is a fleet-wide secret: anything holding it can register a device. Client certificates raise that floor — a device must also hold a key signed by your authority, checked during the handshake, before any request exists.

# your device authority, once
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
  -keyout ca-key.pem -out ca.pem -subj "/CN=Qawk device CA"

# a certificate per device
openssl req -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev.csr \
  -subj "/CN=device-0001"
openssl x509 -req -in dev.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
  -out dev.pem -days 365
-e QAWK_TLS_CLIENT_CA=/tls/ca.pem
# with a certificate: served
curl --cacert cert.pem --cert dev.pem --key dev-key.pem https://localhost/qawk/v1/info

# without: refused during the handshake, before any handler runs
curl --cacert cert.pem https://localhost/qawk/v1/info
Note

This applies to everything, not only devices — the console and your scripts need a client certificate too. And you now have certificates to issue, ship and renew per device. Worth it for a fleet you cannot physically trust; overkill for one on a private network.

A proxy terminating TLS instead

Leave QAWK_TLS_CERT unset and Qawk speaks plain HTTP. Three things the proxy must get right:

  1. A generous body size and timeout. Artifacts are uploaded and downloaded through it. nginx's default client_max_body_size of 1 MB will reject every artifact you have.
  2. QAWK_PUBLIC_URL, if the proxy changes the address devices reach Qawk at — otherwise the download links Qawk hands out point somewhere the device cannot get to.
  3. Range requests passed through, so a device on a bad line resumes a download instead of starting over.
server {
  listen 443 ssl;
  server_name updates.example.com;

  client_max_body_size 0;
  proxy_request_buffering off;

  location / {
    proxy_pass http://qawk:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 300s;
  }
}
-e QAWK_PUBLIC_URL=https://updates.example.com

X-Forwarded-For matters beyond routing: it is the address the audit log records for every change and every refused sign-in. Without it, every entry says the proxy did it.

Letting a browser call the API

A browser refuses to hand a page an answer from another origin unless that server says it may. So a front end of your own — or the "send this request" panel in this documentation — needs the origin allowed:

-e QAWK_CORS_ORIGINS='https://tools.example.com,http://localhost:8099'

Off by default, and that is the right default: a server only devices and scripts talk to gains nothing from it, and a browser refusing somebody else's answer is a protection rather than an obstacle. Name the origins; * is accepted but browsers refuse it for credentialed requests anyway. The console does not need it — it is served through its own proxy, so the browser sees one origin.

Kubernetes

server/deploy/kubernetes/ has a Deployment of three instances with probes, a Service, a PodDisruptionBudget, an autoscaler, a shared artifact volume and — for a lab, not for production — a PostgreSQL. The commands are at the top of qawk.yaml.

Two things to know before you run it:

/live and /health are the probes. /live says the process is up; /health says it can reach the database.

Backup and upgrade

Back up two things

  1. The database — every target, action, channel, release, user and audit entry.
  2. The artifact directory — the bytes devices download.

They must be consistent with each other: a database that references an artifact the filesystem does not have will hand devices a download that 404s. Snapshot both together, or dump the database after copying the artifacts — artifacts are only ever added, so a newer filesystem than database is harmless, while the reverse is not.

docker exec qawk-db pg_dump -U qawk -Fc qawk > qawk-$(date +%F).dump
docker run --rm -v qawk-artifacts:/src -v "$PWD":/out alpine \
  tar czf /out/qawk-artifacts-$(date +%F).tgz -C /src .

Upgrading

Pull the new image and restart. Qawk applies its own schema migrations at startup, in order, each in a transaction, recording what it has applied.

docker pull padovanl/qawk:0.2.0
docker stop qawk && docker rm qawk
docker run -d --name qawk ... padovanl/qawk:0.2.0
Note

Back up the database before an upgrade. Migrations go forward only — there is no down-migration, deliberately, because a down-migration that drops a column drops the data in it. If you need to go back, restore.

With several instances, take them through one at a time. Devices retry, so a poll that misses is a poll that happens thirty seconds later.

Starting over

Drop the database and the artifact volume. There is no other state — no temporary directory, no embedded store, nothing in the image:

docker rm -f qawk qawk-console qawk-db
docker volume rm qawk-db qawk-artifacts

If you have changed Qawk

Qawk is AGPL-3.0-or-later, and section 13 of that licence is the reason it is the AGPL rather than the GPL: if you modify Qawk and let other people use it over a network, those people are entitled to the source of your version.

Running it unchanged, or changing it and keeping it to yourself, carries no obligation at all — most deployments are one of those two and can stop reading here.

If you did change it, one variable is what makes the offer reach the people entitled to it:

-e QAWK_SOURCE_URL=https://git.example.com/acme/qawk

Qawk then reports it in two places, which between them are everyone who interacts with the server:

Careful

Leaving the variable unset is fine — it falls back to the upstream repository, so you cannot blank the offer by forgetting. Setting it to whitespace stops the server at startup, because that would be hiding it on purpose.

What the licence does and does not ask of you, in full: Licence and credits.

Before you call it production