Configuration reference¶
This reference covers how to configure and deploy WaWarden. It lists the
environment variables, the checks serve makes before it opens a socket, the
data directory and what it holds, backups, and the container image. It
describes the code at the commit or tag you are reading.
wawarden version prints a binary's version, and the
changelog names the release that introduced each part.
The rest of the reference is split by topic:
- cli.md: subcommands, flags, exit codes and shutdown.
- api.md: the listeners, the client API, MCP, the admin routes and the shared responses.
- observability.md: logs, notification events, the webhook, metrics and the audit chain.
- internals.md: the WhatsApp engine, ingest, history sync, the archive's schema, byte layouts, memory bounds and design rationale.
These five files describe every feature of this build; the service has no
other. A service without a paired device makes no connection to WhatsApp until
wawarden admin pair requests pairing. Terms such as LID, re-key
and key id are explained in the glossary.
Contents¶
Environment variables¶
These are all the variables this build reads.
| Variable | Default | Accepted values | Reason codes |
|---|---|---|---|
WAWARDEN_DATA_DIR |
/data |
A path to a directory; see Data directory. | data_dir_unusable, data_dir_not_directory, data_dir_foreign_owner, data_dir_permissions |
WAWARDEN_LISTEN |
127.0.0.1:8080 |
The client listener's address; see Listen addresses. | listen_address_invalid, listen_address_shared |
WAWARDEN_ADMIN_LISTEN |
127.0.0.1:8082 |
The admin listener's address. It is validated even while the admin listener is disabled. | listen_address_invalid, listen_address_shared |
WAWARDEN_HEALTH_LISTEN |
127.0.0.1:8081 |
The health listener's address; it must be a loopback address. | listen_address_invalid, health_address_not_loopback, listen_address_shared |
WAWARDEN_ADMIN_TOKEN_SHA256 |
unset | The admin token's SHA-256: exactly 64 hexadecimal characters, either case, nothing else (no surrounding whitespace). | admin_hash_invalid, admin_hash_sources_conflict |
WAWARDEN_ADMIN_TOKEN_SHA256_FILE |
unset | A path to a regular file of at most 4096 bytes that holds the hash. Whitespace around the hash is removed. Symbolic links are followed. | admin_hash_file_unreadable, admin_hash_invalid, admin_hash_sources_conflict |
WAWARDEN_LOG_LEVEL |
info |
debug, info, warn or error, in lower case. |
log_level_invalid |
WAWARDEN_STORAGE_PROFILE |
local |
local or nfs, in lower case; see Storage profiles. |
storage_profile_invalid |
WAWARDEN_MIN_FREE_BYTES |
268435456 (256 MiB) |
A number of bytes in decimal digits, from 0 to 18446744073709551615: no sign, unit, separator or white space. 0 turns the floor off. See Free space. |
min_free_bytes_invalid |
WAWARDEN_OWNER_PHONE |
unset | The phone number of the WhatsApp account that WaWarden may link to, in E.164 form: +, then 7 to 15 digits, the first not 0, and nothing else (no spaces or separators), for example +15550100001. Unset, the service starts but refuses to pair; see Pairing. |
owner_phone_invalid |
WAWARDEN_HISTORY_MAX_BYTES |
33554432 (32 MiB) |
The largest history-sync blob, in bytes, before and after decompression: a number in decimal digits from 1 to 268435456 (256 MiB), with no sign, unit, separator or white space. The memory that history sync needs grows with it; see History sync. |
history_max_bytes_invalid |
WAWARDEN_UNSAFE_DEBUG |
unset | A window, in minutes, during which the protocol library's debug output is logged: a number in decimal digits from 1 to 60. Unset, that output is discarded at every log level. See Protocol library logs before setting it. |
unsafe_debug_invalid |
WAWARDEN_METRICS_EMF |
unset | 1 writes metrics in embedded metric format (EMF) on standard output; 0 or unset writes none. |
metrics_emf_invalid |
WAWARDEN_NOTIFY_URL |
unset | The webhook that receives every notification event: an https URL with a host, optionally a port, a path and a query, without credentials, a fragment or an IPv6 zone. Treat it as a secret when its path or query holds one; the service never logs it. Unset, events go to standard output only. |
notify_url_invalid |
WAWARDEN_NOTIFY_SECRET_FILE |
unset | A path to the webhook's signing secret, required with WAWARDEN_NOTIFY_URL and refused without it: a regular file (symbolic links are followed) with no write permission for its group and no permission at all for others (0400, 0440, 0600 and 0640 pass; 0644 and 0444 do not), of at most 4096 bytes, holding at least 32 bytes apart from surrounding white space. Generate one with openssl rand -hex 32. |
notify_url_missing, notify_secret_missing, notify_secret_unreadable, notify_secret_permissions, notify_secret_invalid |
WAWARDEN_NOTIFY_ALLOW_PRIVATE |
unset | 1 lets the webhook reach loopback, private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7) and shared (100.64.0.0/10, which mesh VPNs use) addresses; 0 or unset refuses them. Only with WAWARDEN_NOTIFY_URL. |
notify_allow_private_invalid, notify_url_missing |
WAWARDEN_BACKUP_AGE_RECIPIENT |
unset | The one age recipient that backups are encrypted to: an X25519 recipient (age1 followed by 58 characters, as age-keygen prints it) or a post-quantum hybrid one (age1pq1...), with no white space, comment or second recipient. An SSH key, a plugin recipient or an age secret key is refused, and the refusal never repeats the value. Unset, no backup is ever taken and backup_disabled is logged at start. |
backup_recipient_invalid |
WAWARDEN_BACKUP_TIME |
03:00 |
The time of day, in UTC and written HH:MM, of the nightly backup slot. Only with WAWARDEN_BACKUP_AGE_RECIPIENT. |
backup_time_invalid, backup_option_without_recipient |
WAWARDEN_BACKUP_KEEP |
14 |
How many backups backups/ keeps: after each successful backup, the oldest beyond this count, by the time in their names, are deleted. A whole number from 1 to 365. Only with WAWARDEN_BACKUP_AGE_RECIPIENT. |
backup_keep_invalid, backup_option_without_recipient |
WAWARDEN_AUDIT_RETENTION_DAYS |
90 |
How many days the audit chain keeps its rows: each night, after the backup, rows written more than this many days before are removed behind a signed anchor. A whole number from 7 to 3650 in decimal digits. Unset means 90; no setting keeps rows forever. | audit_retention_invalid |
WAWARDEN_READ_PER_CLIENT_PER_MINUTE |
600 |
Each client's read budget: requests per minute, from 1 to 600, refilled evenly with a burst of at most 60. Every authenticated client request, whatever its route or answer, costs one. | read_per_minute_invalid |
WAWARDEN_SEARCH_PER_CLIENT_PER_MINUTE |
60 |
Each client's search budget: searches per minute, from 1 to 60, refilled evenly with a burst of at most 6. A search costs one from both budgets. | search_per_minute_invalid |
WAWARDEN_UNSAFE_RATE_CAPS |
unset | 1 lifts the upper caps of the two read budgets to 1000000 per minute and logs unsafe_rate_caps at start; 0 or unset keeps them. Zero or a negative budget stays refused. It never lifts the send limits. |
unsafe_rate_caps_invalid |
WAWARDEN_UNSAFE_SEND_CAPS |
unset | 1 lifts the upper caps of the two send limits to 600 an hour and 60 a minute and logs unsafe_send_caps at every start; 0 or unset keeps them. Zero or a negative limit stays refused. |
unsafe_send_caps_invalid |
WAWARDEN_SEND_GLOBAL_PER_HOUR |
30 |
The global send limit: sends a sliding hour, all clients together, from 1 to 60, or to 600 with WAWARDEN_UNSAFE_SEND_CAPS=1. Each client's hourly share is this divided by the number of clients that may write, at least 1. |
send_global_per_hour_invalid |
WAWARDEN_SEND_PER_CLIENT_PER_MINUTE |
10 |
Each client's send limit per sliding minute, from 1 to 10, or to 60 with WAWARDEN_UNSAFE_SEND_CAPS=1. |
send_per_client_per_minute_invalid |
WAWARDEN_DEV_FAKE_ENGINE |
unset | Development builds only; a release build refuses it with dev_variable_in_release. 1 runs the fake engine in place of WhatsApp, wrong_account runs it pairing a number that is not the owner's, and 0 or unset runs the WhatsApp engine. |
dev_fake_engine_invalid |
Rules for all variables¶
- A variable set to the empty string is set, and is validated like any other
value:
WAWARDEN_LISTEN=is refused, it does not mean the default. - When the environment holds a name more than once, the first occurrence is used.
- Set at most one of
WAWARDEN_ADMIN_TOKEN_SHA256andWAWARDEN_ADMIN_TOKEN_SHA256_FILE. With neither, the admin listener is not opened at all. The hash and the file are read once, at start; to rotate the admin token, replace the hash and restart. WAWARDEN_ADMIN_TOKENis never accepted, with any value: the service takes the admin credential only as a hash (plaintext_admin_token).
Go runtime and library variables¶
serve also checks three variables outside the WAWARDEN_ namespace:
| Variable | Accepted values | Reason code |
|---|---|---|
GOTRACEBACK |
unset, empty, none or single |
traceback_level_unsafe |
MCPGODEBUG |
unset | library_debug_set |
JSONSCHEMAGODEBUG |
unset | library_debug_set |
Every other value is refused, numeric levels included, 0 and 1 as well. The
named levels all, system, crash and wer make a crash print the stack of
every goroutine. serve sets the level to single itself before doing anything
else, but the Go runtime does not let a program lower the level that this
variable sets, and combined with single, every other refused value (0, any
other number, or a name the runtime does not know) prints every goroutine too.
MCPGODEBUG and JSONSCHEMAGODEBUG switch compatibility behaviour of the MCP
library and of its JSON Schema library, such as how tool schemas and results are
encoded, so a set value, even an empty one, is refused. The MCP library reads
MCPGODEBUG when the program starts, before serve checks anything: a value
that is not a comma-separated list of key=value pairs makes the binary panic
in every subcommand, with exit status 2, no startup_refused event and a
message that repeats the malformed part, so leave the variable unset.
Unknown variables stop the service¶
wawarden serverefuses to start when the environment contains any variable whose name begins withWAWARDEN_and that this build does not implement. A typo, a variable meant for a later release, or a leftover from another deployment stops the service with the reason codeunknown_variableinstead of being ignored. An empty value counts as set:WAWARDEN_SEND_RATE=is refused as well.
Only the names in Environment variables are accepted.
Names are matched exactly, so a lower-case wawarden_listen is not recognised as a
WaWarden variable and has no effect.
WAWARDEN_TOKEN belongs on an agent's host, never in the service's
environment; see Registering with Claude Code.
Reserved names¶
WAWARDEN_DEV_* names are reserved for development builds; a release build
refuses every one of them with dev_variable_in_release. A development build
implements WAWARDEN_DEV_FAKE_ENGINE (see Fake engine) and
refuses any other WAWARDEN_DEV_* name with unknown_variable.
Listen addresses¶
A listen address is an IP literal and a port from 1 to 65535: 127.0.0.1:8080,
[::1]:8080, 0.0.0.0:8080, [::]:8080.
- Host names such as
localhost, an empty host (:8080), port0, URLs and IPv6 zones ([fe80::1%eth0]:8080) are refused withlisten_address_invalid. - An IPv4-mapped IPv6 address (
[::ffff:127.0.0.1]:8080) is treated as the IPv4 address. - An IPv6 listener accepts IPv6 connections only;
[::]does not also accept IPv4. - The client and admin listeners may use an unspecified address (
0.0.0.0or[::]). Every start then logs alistener_not_loopbackwarning for that listener, as for any other non-loopback address, whateverWAWARDEN_LOG_LEVELis set to. - Two enabled listeners may not overlap: same port and same address family, with
equal addresses or one of them unspecified (
listen_address_shared). The refusal names the later of the two in the order client, health, admin. The admin address is compared only when the admin listener is enabled.
Example environment file¶
A complete environment file for a host install, such as the
EnvironmentFile= of a systemd unit. Replace each
value in angle brackets: a placeholder left in place is refused at start.
systemd and Compose's env_file both accept whole-line comments and blank
lines. Variables left out keep their defaults; here the read and send limits
do.
# Data directory and the WhatsApp account WaWarden may link to
WAWARDEN_DATA_DIR=/var/lib/wawarden
WAWARDEN_OWNER_PHONE=+15550100001
# Listeners: the defaults, written out
WAWARDEN_LISTEN=127.0.0.1:8080
WAWARDEN_ADMIN_LISTEN=127.0.0.1:8082
WAWARDEN_HEALTH_LISTEN=127.0.0.1:8081
# The admin token's SHA-256 only, as printed by `wawarden admin init`
WAWARDEN_ADMIN_TOKEN_SHA256_FILE=/etc/wawarden/admin.sha256
WAWARDEN_LOG_LEVEL=info
WAWARDEN_STORAGE_PROFILE=local
# Nightly backups, encrypted to an age public key
WAWARDEN_BACKUP_AGE_RECIPIENT=<age1... public key printed by age-keygen>
WAWARDEN_BACKUP_TIME=03:00
WAWARDEN_BACKUP_KEEP=14
WAWARDEN_AUDIT_RETENTION_DAYS=90
# Notification events go to standard output, and also to this webhook
WAWARDEN_NOTIFY_URL=https://alerts.example.com/wawarden
WAWARDEN_NOTIFY_SECRET_FILE=/etc/wawarden/notify.secret
# Metrics in embedded metric format on standard output
WAWARDEN_METRICS_EMF=1
Startup refusals¶
serve validates its whole configuration, the master key, the
archive and the device store before opening any socket. The first failed check stops it: it writes one log line with
"event":"startup_refused", the reason code in reason and a fixed explanation in
error, and exits 2. A refusal names the variable or the file in the data
directory involved, never a configured value or the data directory's path. For
example:
{"time":"2026-10-04T08:09:24.446116295Z","level":"ERROR","msg":"startup refused","event":"startup_refused","reason":"data_dir_foreign_owner","error":"config: WAWARDEN_DATA_DIR: is not owned by the current user"}
{"time":"2026-10-04T08:11:02.518840121Z","level":"ERROR","msg":"startup refused","event":"startup_refused","reason":"master_key_permissions","error":"keys: keys/master must grant no access to group or others and have no setuid, setgid or sticky bit"}
{"time":"2026-10-04T08:12:40.002931604Z","level":"ERROR","msg":"startup refused","event":"startup_refused","reason":"archive_db_permissions","error":"db: archive.db must grant no access to group or others and have no setuid, setgid or sticky bit"}
The checks run in this order. What to do about each refusal is in the runbook.
| # | Reason code | Variable named | Refused when |
|---|---|---|---|
| 1 | plaintext_admin_token |
WAWARDEN_ADMIN_TOKEN |
WAWARDEN_ADMIN_TOKEN is set, to any value. |
| 2 | dev_variable_in_release |
the first such name, in sorted order | A release build sees any WAWARDEN_DEV_* variable. |
| 3 | unknown_variable |
the first such name, in sorted order | Any other WAWARDEN_ name is not one of the environment variables. |
| 4 | traceback_level_unsafe |
GOTRACEBACK |
GOTRACEBACK is set to anything other than empty, none or single, a numeric level included. |
| 5 | library_debug_set |
the variable | MCPGODEBUG or JSONSCHEMAGODEBUG is set, to any value, an empty one included. A value of MCPGODEBUG that is not a comma-separated list of key=value pairs never reaches this check: the MCP library panics while the program initialises, in every subcommand, and the process exits 2 without a startup_refused event (see Go runtime and library variables). |
| 6 | listen_address_invalid |
the listen variable | WAWARDEN_LISTEN, WAWARDEN_ADMIN_LISTEN or WAWARDEN_HEALTH_LISTEN (checked in that order) is not a valid listen address. |
| 7 | health_address_not_loopback |
WAWARDEN_HEALTH_LISTEN |
The health address is not a loopback address. |
| 8 | admin_hash_sources_conflict |
WAWARDEN_ADMIN_TOKEN_SHA256_FILE |
Both admin hash variables are set. |
| 9 | admin_hash_file_unreadable |
WAWARDEN_ADMIN_TOKEN_SHA256_FILE |
The file cannot be opened or read, or is not a regular file (a directory, a device or a named pipe, for example). |
| 10 | admin_hash_invalid |
the hash variable | The hash is not exactly 64 hexadecimal characters, or the file holds more than 4096 bytes. |
| 11 | listen_address_shared |
the later listener | Two enabled listeners overlap. |
| 12 | log_level_invalid |
WAWARDEN_LOG_LEVEL |
The level is not debug, info, warn or error. |
| 13 | storage_profile_invalid |
WAWARDEN_STORAGE_PROFILE |
The profile is not local or nfs. |
| 14 | min_free_bytes_invalid |
WAWARDEN_MIN_FREE_BYTES |
The value is not a number of bytes in decimal digits that fits in 64 bits. |
| 15 | owner_phone_invalid |
WAWARDEN_OWNER_PHONE |
The value is set but is not an E.164 number: + and 7 to 15 digits, the first not 0. |
| 16 | history_max_bytes_invalid |
WAWARDEN_HISTORY_MAX_BYTES |
The value is not a number of bytes in decimal digits from 1 to 268435456. |
| 17 | unsafe_debug_invalid |
WAWARDEN_UNSAFE_DEBUG |
The value is not a number of minutes in decimal digits from 1 to 60. |
| 18 | metrics_emf_invalid |
WAWARDEN_METRICS_EMF |
The value is not 0 or 1. |
| 19 | notify_url_invalid |
WAWARDEN_NOTIFY_URL |
The value is not an https URL with a host, or it holds credentials, a fragment or an IPv6 zone. |
| 20 | notify_allow_private_invalid |
WAWARDEN_NOTIFY_ALLOW_PRIVATE |
The value is not 0 or 1. |
| 21 | notify_url_missing |
WAWARDEN_NOTIFY_SECRET_FILE, else WAWARDEN_NOTIFY_ALLOW_PRIVATE |
That variable is set without WAWARDEN_NOTIFY_URL. |
| 22 | notify_secret_missing |
WAWARDEN_NOTIFY_SECRET_FILE |
WAWARDEN_NOTIFY_URL is set without a secret file. |
| 23 | notify_secret_unreadable |
WAWARDEN_NOTIFY_SECRET_FILE |
The file cannot be opened or read, or is not a regular file. |
| 24 | notify_secret_permissions |
WAWARDEN_NOTIFY_SECRET_FILE |
The file grants write permission to its group or any permission to others. |
| 25 | notify_secret_invalid |
WAWARDEN_NOTIFY_SECRET_FILE |
The file holds more than 4096 bytes, or less than 32 bytes apart from surrounding white space. |
| 26 | backup_recipient_invalid |
WAWARDEN_BACKUP_AGE_RECIPIENT |
The value is not exactly one age X25519 or hybrid recipient: it is empty, holds white space, a comment, a second recipient or an age secret key, or fails age's parser. The value is never repeated. |
| 27 | backup_time_invalid |
WAWARDEN_BACKUP_TIME |
The value is not a time of day written HH:MM with two digits each, from 00:00 to 23:59: it is empty, has one digit for the hour, seconds, a zone or another separator. |
| 28 | backup_keep_invalid |
WAWARDEN_BACKUP_KEEP |
The value is not a whole number from 1 to 365 written in decimal digits: it is empty, 0, above 365, signed, has a leading zero or a unit. |
| 29 | backup_option_without_recipient |
WAWARDEN_BACKUP_TIME, or WAWARDEN_BACKUP_KEEP when only it is set |
WAWARDEN_BACKUP_TIME or WAWARDEN_BACKUP_KEEP is set while WAWARDEN_BACKUP_AGE_RECIPIENT is not. |
| 30 | audit_retention_invalid |
WAWARDEN_AUDIT_RETENTION_DAYS |
The value is not a whole number from 7 to 3650 written in decimal digits: it is empty, 0, below 7, above 3650, signed, or has a leading zero, white space or a unit. |
| 31 | unsafe_rate_caps_invalid |
WAWARDEN_UNSAFE_RATE_CAPS |
The value is not 0 or 1. |
| 32 | read_per_minute_invalid |
WAWARDEN_READ_PER_CLIENT_PER_MINUTE |
The value is not a whole number written in decimal digits, without a sign or leading zero, from 1 to 600, or to 1000000 with WAWARDEN_UNSAFE_RATE_CAPS=1. Zero, a negative number and an empty value are always refused. |
| 33 | search_per_minute_invalid |
WAWARDEN_SEARCH_PER_CLIENT_PER_MINUTE |
The value is not a whole number written in decimal digits, without a sign or leading zero, from 1 to 60, or to 1000000 with WAWARDEN_UNSAFE_RATE_CAPS=1. Zero, a negative number and an empty value are always refused. |
| 34 | unsafe_send_caps_invalid |
WAWARDEN_UNSAFE_SEND_CAPS |
The value is not 0 or 1. |
| 35 | send_global_per_hour_invalid |
WAWARDEN_SEND_GLOBAL_PER_HOUR |
The value is not a whole number written in decimal digits, without a sign or leading zero, from 1 to 60, or to 600 with WAWARDEN_UNSAFE_SEND_CAPS=1. Zero, a negative number and an empty value are always refused; WAWARDEN_UNSAFE_RATE_CAPS does not lift this cap. |
| 36 | send_per_client_per_minute_invalid |
WAWARDEN_SEND_PER_CLIENT_PER_MINUTE |
The value is not a whole number written in decimal digits, without a sign or leading zero, from 1 to 10, or to 60 with WAWARDEN_UNSAFE_SEND_CAPS=1. Zero, a negative number and an empty value are always refused; WAWARDEN_UNSAFE_RATE_CAPS does not lift this cap. |
| 37 | dev_fake_engine_invalid |
WAWARDEN_DEV_FAKE_ENGINE |
A development build sees a value other than 0, 1 or wrong_account. A release build refuses the name itself at check 2. |
| 38 | running_as_root |
none | The real or the effective user ID is 0 and --allow-root was not given. |
| 39 | data_dir_unusable |
WAWARDEN_DATA_DIR |
The directory does not exist and cannot be created, or cannot be inspected; the path is empty. |
| 40 | data_dir_not_directory |
WAWARDEN_DATA_DIR |
The path is not a directory, or its last component is a symbolic link. |
| 41 | data_dir_foreign_owner |
WAWARDEN_DATA_DIR |
The directory is not owned by the process's effective user ID. |
| 42 | data_dir_permissions |
WAWARDEN_DATA_DIR |
The directory's mode is not exactly 0700. |
| 43 | history_dir_unusable |
none; the error names history/ |
history exists in the data directory but cannot be inspected. |
| 44 | history_dir_not_directory |
none; the error names history/ |
history exists but is not a directory, or is a symbolic link. |
| 45 | history_dir_foreign_owner |
none; the error names history/ |
history is not owned by the process's effective user ID. |
| 46 | history_dir_permissions |
none; the error names history/ |
The mode of history is not exactly 0700. |
| 47 | backups_dir_unusable |
none; the error names backups/ |
backups exists in the data directory but cannot be inspected. |
| 48 | backups_dir_not_directory |
none; the error names backups/ |
backups exists but is not a directory, or is a symbolic link. |
| 49 | backups_dir_foreign_owner |
none; the error names backups/ |
backups is not owned by the process's effective user ID. |
| 50 | backups_dir_permissions |
none; the error names backups/ |
The mode of backups is not exactly 0700. |
| 51 | keys_dir_unusable |
none; the error names keys/ |
keys in the data directory does not exist and cannot be created, or cannot be inspected. |
| 52 | keys_dir_not_directory |
none; the error names keys/ |
keys is not a directory, or is a symbolic link. |
| 53 | keys_dir_foreign_owner |
none; the error names keys/ |
keys is not owned by the process's effective user ID. |
| 54 | keys_dir_permissions |
none; the error names keys/ |
The mode of keys is not exactly 0700. |
| 55 | master_key_unusable |
none; the error names keys/master |
keys/master does not exist and cannot be created, or cannot be inspected, opened or read. |
| 56 | master_key_not_regular |
none; the error names keys/master |
keys/master is not a regular file: a symbolic link (even to a valid key), a directory or a named pipe, for example. |
| 57 | master_key_foreign_owner |
none; the error names keys/master |
keys/master is not owned by the process's effective user ID. |
| 58 | master_key_permissions |
none; the error names keys/master |
keys/master grants any access to group or others, or has the setuid, setgid or sticky bit. |
| 59 | master_key_size |
none; the error names keys/master |
keys/master does not hold exactly 32 bytes. |
| 60 | retired_keys_dir_unusable |
none; the error names keys/retired/ |
keys/retired exists but cannot be inspected or listed. |
| 61 | retired_keys_dir_not_directory |
none; the error names keys/retired/ |
keys/retired is not a directory, or is a symbolic link. |
| 62 | retired_keys_dir_foreign_owner |
none; the error names keys/retired/ |
keys/retired is not owned by the process's effective user ID. |
| 63 | retired_keys_dir_permissions |
none; the error names keys/retired/ |
The mode of keys/retired is not exactly 0700. |
| 64 | retired_key_name |
none; the error names keys/retired/ |
An entry of keys/retired whose name does not start with . is not named by a key id: 8 lower-case hexadecimal digits derived from the key (see Master key). |
| 65 | retired_keys_too_many |
none; the error names keys/retired/ |
keys/retired holds more than 32 keys. |
| 66 | retired_key_unusable |
none; the error names the retired key | A retired key cannot be inspected, opened or read. |
| 67 | retired_key_not_regular |
none; the error names the retired key | A retired key is not a regular file: a symbolic link (even to a valid key) or a directory, for example. |
| 68 | retired_key_foreign_owner |
none; the error names the retired key | A retired key is not owned by the process's effective user ID. |
| 69 | retired_key_permissions |
none; the error names the retired key | A retired key grants any access to group or others, or has the setuid, setgid or sticky bit. |
| 70 | retired_key_size |
none; the error names the retired key | A retired key does not hold exactly 32 bytes. |
| 71 | retired_key_id_mismatch |
none; the error names the retired key | The key id derived from a retired key is not its name. |
| 72 | retired_key_current |
none; the error names the retired key | A retired key is the current master key, as a keys rotate interrupted before it replaced keys/master leaves it; run keys rotate again. |
| 73 | storage_filesystem_unknown |
none | The filesystem of the data directory cannot be inspected (statfs). |
| 74 | storage_network_filesystem |
none | The storage profile is local and the data directory is on a network filesystem; see Storage profiles. |
| 75 | archive_db_unusable |
none; the error names archive.db |
archive.db does not exist and cannot be created, or cannot be inspected. |
| 76 | archive_db_not_regular |
none; the error names archive.db |
archive.db is not a regular file: a symbolic link or a directory, for example. |
| 77 | archive_db_foreign_owner |
none; the error names archive.db |
archive.db is not owned by the process's effective user ID. |
| 78 | archive_db_permissions |
none; the error names archive.db |
archive.db grants any access to group or others, or has the setuid, setgid or sticky bit. |
| 79 | archive_journal_unusable |
none; the error names archive.db-journal |
archive.db-journal exists but cannot be inspected. |
| 80 | archive_journal_not_regular |
none; the error names archive.db-journal |
archive.db-journal exists but is not a regular file. |
| 81 | archive_journal_foreign_owner |
none; the error names archive.db-journal |
archive.db-journal is not owned by the process's effective user ID. |
| 82 | archive_journal_permissions |
none; the error names archive.db-journal |
archive.db-journal grants any access to group or others, or has the setuid, setgid or sticky bit. |
| 83 | storage_ofd_unavailable |
none | On Linux, the storage profile is local and the kernel or the data directory's filesystem refused open-file-description locks; see Storage profiles. |
| 84 | archive_schema_newer |
none; the error names archive.db |
The archive's schema version is newer than this build knows: a newer release wrote it. |
| 85 | session_db_unusable |
none; the error names session.db |
session.db does not exist and cannot be created, or cannot be inspected. |
| 86 | session_db_not_regular |
none; the error names session.db |
session.db is not a regular file: a symbolic link or a directory, for example. |
| 87 | session_db_foreign_owner |
none; the error names session.db |
session.db is not owned by the process's effective user ID. |
| 88 | session_db_permissions |
none; the error names session.db |
session.db grants any access to group or others, or has the setuid, setgid or sticky bit. |
| 89 | session_journal_unusable |
none; the error names session.db-journal |
session.db-journal exists but cannot be inspected. |
| 90 | session_journal_not_regular |
none; the error names session.db-journal |
session.db-journal exists but is not a regular file. |
| 91 | session_journal_foreign_owner |
none; the error names session.db-journal |
session.db-journal is not owned by the process's effective user ID. |
| 92 | session_journal_permissions |
none; the error names session.db-journal |
session.db-journal grants any access to group or others, or has the setuid, setgid or sticky bit. |
The table is in the order the checks run. Checks 1 to 38 read only the environment and the process; checks 39 to 92 inspect the data directory.
- Checks 60 to 72 run only when
keys/retiredexists. Checks 66 to 72 run for each retired key in turn, in order of name. - Checks 85 to 92 run on
session.dbafter the archive is open, and not at all when the fake engine runs. The storage profile is checked again forsession.db, which passes once it passed for the archive.
What a refused start leaves behind:
- When the data directory is missing,
servecreates it only after checks 1 to 38 pass, so a start refused by any of them leaves nothing behind. history/andbackups/are checked only when they exist;servecreates neither at start (see Data directory).keys/and the master key are created, when missing, only after checks 39 to 50 pass.- An empty
archive.dbis created only after checks 39 to 74 pass, and an emptysession.dbonly once the archive is open. - A refusal by a later check (on a filesystem that forces its own ownership or
mode, for example), or a
startup_failedexit, leaves what was created in place: the data directory,keys/, the master key,archive.dbandsession.db.
Data directory¶
The data directory is WAWARDEN_DATA_DIR, by default /data.
- Use an absolute path. A relative path is resolved against the process's working directory.
- When the directory does not exist,
servecreates it with mode0700. Only the last path component is created; its parent must exist. - Its last path component must not be a symbolic link. Symbolic links earlier in the path are followed.
- It must be a directory owned by the effective user ID of the process.
- Its mode must be exactly
0700: full access for the owner, nothing for group or others, and no setuid, setgid or sticky bit.0750,0755,0701and0500are all refused.
The checks run once, at start. When they exist, history/ and backups/ in the
data directory must also be directories, not symbolic links, owned by the
effective user ID with mode exactly 0700 (refusals 43 to 50). Then serve
creates the master key when it is missing and opens the
message archive, archive.db, and the
device store, session.db. The
engine creates history/ with mode 0700 when it first
downloads a history-sync blob, and keeps each blob there as
history/<id>.bin, mode 0600, only until it is processed or quarantined.
A backup creates backups/ and backups/tmp/ with mode 0700 when
it is first taken, and serve removes backups/tmp/ at every start once
archive.db is locked. After each successful backup the service deletes backups
beyond WAWARDEN_BACKUP_KEEP from backups/, and refuses to delete anything
when that directory holds an entry it did not write. The directory must be on a writable, persistent
filesystem that supports hard links and that only the service's user can read.
Mechanisms that add group permissions or the setgid bit to a volume, such as
Kubernetes fsGroup, make the directory fail the mode check.
Master key¶
keys/master in the data directory holds the service's master key: exactly 32
bytes. Right after the data directory checks, serve:
- creates
keys/with mode0700when it does not exist; - checks
keys/(refusals 51 to 54): a directory, not a symbolic link, owned by the effective user ID, with mode exactly0700; - when
keys/masterdoes not exist, writes 32 bytes from the operating system's cryptographic random source to a new temporary filekeys/.master-<random>with mode0600, flushes it to disk, hard-links it askeys/master, removes the temporary name and flushes the directory. When two processes start at once, the second link fails, and both use the key that was linked first.servenever replaces an existing key; onlykeys rotatedoes; - checks
keys/master(refusals 55 to 59): a regular file, not a symbolic link, owned by the effective user ID, without any permission for group or others and without the setuid, setgid or sticky bit, holding exactly 32 bytes. Mode0600and mode0400are both accepted; - when
keys/retiredexists, checks it and the retired keys in it (refusals 60 to 72): a directory, not a symbolic link, owned by the effective user ID, with mode exactly0700, holding at most 32 keys besides names that start with., each named by its key id and checked askeys/masteris, none of them the current master key.keys rotateputs each master key it replaces there.
The service derives every key it uses from the master key with HKDF-SHA256 (no
salt, the information string wawarden/v1/ followed by the purpose) and keeps
only the derived keys, of the master key and of each retired key:
| Purpose | Length | Use in this build |
|---|---|---|
key-id |
4 bytes | The key id: logged as 8 hexadecimal digits in the keys_loaded event, so that log lines can be grouped by the key their pseudonyms were made with, and in each audit line. It names a retired key's file, and the key of each audit row, of the audit anchor, and of each cursor and message reference. |
log-redact |
32 bytes | The key of the log pseudonyms. |
chat-hmac |
32 bytes | The chat_hmac of audit lines: the first 16 bytes of HMAC-SHA256 under this key over the chat's canonical identifier. No notification event names a chat. |
cursor-seal |
32 bytes | Seals the next cursors of the client API. |
mref |
32 bytes | Seals the message references of the client API. |
audit-chain |
32 bytes | The key of the audit chain's row HMACs. |
What leaves the service:
- Apart from the log pseudonyms, the chat HMACs and the chain heads of audit lines, the key id is the only value derived from the master key that the service writes out. No key reaches a log, an event, a metric or a response.
servewrites the master key only tokeys/master, through the temporary name in step 3.keys rotatewrites keys only tokeys/masterandkeys/retired/, through temporary names. Neither writes a derived key.
Replacing or deleting the master key changes the key id and every log
pseudonym. Audit rows written afterwards carry the new key id, so
audit verify reports the older rows as unknown_key unless it is given the
old key as a retired key: keep a copy of every master key the service ran
with.
keys rotate replaces the master key and keeps the old
one in keys/retired/. Everything new is sealed, chained and keyed under the
master key. The service uses a retired key only:
- to open what was sealed under it: a cursor or a message reference whose key id is a retired key's;
- to verify the audit rows and the audit anchor made under it, before the audit retention removes them.
To provide your own key, write 32 random bytes to keys/master with mode
0600 or 0400, owned by the service's user, before the first start.
A crash or power loss during the first start can leave a
keys/.master-<random> file behind, with mode 0600. It holds either an
unused key or, after a crash right after the link, a second name of the
current master key. Delete it together with keys/master when you replace the
key; it can be deleted at any time while the service is stopped.
Message archive¶
archive.db in the data directory is the message archive: a SQLite database
written by the SQLite engine compiled into the binary (modernc.org/sqlite).
serve creates it, applies its schema and records each start in it, and the
engine writes WhatsApp traffic into it once a device is
paired.
Right after the master key, serve:
- checks the data directory's filesystem against the storage profile (refusals 73 and 74);
- creates
archive.dbempty with mode0600when it does not exist, and refuses (75 to 78) one that is not a regular file, belongs to another user, or grants any access to group or others; then refuses (79 to 82) anarchive.db-journalthat exists and fails the same checks; - opens one connection and reads back every setting it applies, refusing the
connection on any difference: foreign keys on, the rollback journal in
TRUNCATEmode,synchronousFULL, exclusive locking, temporary storage in memory,secure_deleteON(notFAST) and a 5-second busy timeout. None of these is configurable; write-ahead logging,PERSISTand every other journal mode are refused, and a database left in write-ahead-log mode is converted back to a rollback journal; - takes an exclusive lock on the database, which no other process can read or
write while the service runs. When another process holds it,
servelogsdb_lock_waitonce and retries with growing pauses for up to five minutes, then exits1withstartup_failed. A stop signal ends the wait within one busy timeout and also exits1. On Linux, profilelocaltakes it with open-file-description locks, and a kernel or filesystem that refuses them is refused in turn (83) instead of falling back to classic locks; - brings the schema up to date (an archive written by a newer release is
refused, 84): each schema version is applied in one transaction that has no
deadline, because it runs only here, under the lock, and can take seconds on
a large archive (about 8 seconds for 500,000 messages on a laptop). Moving
to version 3 then numbers the changes of the messages already stored, in
transactions of 10,000 messages that each run under the 5-minute rewrite
deadline, and a stop in between leaves the rest to the next start. Version 4
follows version 3 and only adds a column, tables, indexes and triggers and
rebuilds the identity-mapping table, which takes one to two seconds per
500,000 messages on a laptop, most of it checking the new column's
constraint against every stored message. Then
servecompletes a rewrite of the full-text index that a stop or a failed rewrite left due (see the end of this section) and logsarchive_opened.
The connection that holds the lock is the only one the service ever opens to
the archive. It survives a call that runs out of time, and should it ever be
lost, the service refuses to open another, logs db_lost and answers 503 on
/healthz from then on; restart it. No other code in the service opens the
database file, and on Linux, profile local uses open-file-description locks,
so that closing some other descriptor of the file cannot drop the lock; it
refuses to start (storage_ofd_unavailable) where the kernel or the filesystem
rejects them, because SQLite would otherwise fall back to classic locks without
a sign.
The journal, archive.db-journal, appears at the first write and is truncated to
zero bytes after every transaction; SQLite gives it the database file's mode.
Temporary data stays in memory, so the service writes no other file next to the
archive.
Every read of the archive must finish within 2 seconds, every write within
10 seconds and every rewrite of its full-text index within 5 minutes; a schema
migration has no deadline (step 5 above). A call
that runs out of time is logged as db_deadline when its deadline passes, while
it still runs, with the profile of every goroutine of the process (function
names and source positions only), and is interrupted: SQLite stops its statement
between two steps, and the call fails. A call that waits on the filesystem, such
as a read that hangs on a network filesystem, or that is committing, is not
interrupted and runs until that wait or the commit ends; a write whose commit
ends succeeds. A deadline that passes just as a statement starts can be lost in
the database driver: that statement then runs to its end before the call fails.
Revoked, edited and expired message text is removed from the database file, its journal and the full-text index, as described in the threat model (I-8). Outside that guarantee are:
- backups, which keep the old text until backup retention deletes them;
- the same text, or any three-character run of it, held elsewhere in the archive: by another message, a message waiting in the inbox, a push name, a chat name or group subject, an identifier or the schema;
- a revocation or edit that arrives before history sync has stored its target (see Ingest).
To keep removed text out of the full-text index, a deletion can make the
service rewrite the whole index right after it commits. A rewrite holds the
archive while it runs, so other calls wait and may reach their own deadlines.
It needs free space of about the index's size. A rewrite that did not commit
runs again at the next start, before archive_opened is logged.
Full-text index rewrites explains when one is
needed.
The archive's tables are listed under Archive schema.
Device store¶
session.db in the data directory is the device store: the keys and state of the
linked device that the WhatsApp protocol library (go.mau.fi/whatsmeow) keeps,
in its own tables, through the same SQLite engine. Right after the archive,
serve opens it with every step the archive gets: the storage profile is
checked, the file is created empty with mode 0600 when it does not exist, the
file and its journal must pass refusals 85 to 92, the connection reads back the
same fixed settings and takes the same exclusive lock, kept the same way, with
db_lock_wait naming session and db_lost too. Then the protocol library
brings its tables up to date, within one minute. A store written by a newer
version of the protocol library is used unchanged when it declares itself compatible
with the version this build links, and stops the start with startup_failed
when it does not. serve logs
session_opened with paired set to whether the store holds a linked device.
A development build running the fake engine opens no device
store: it neither creates nor reads session.db.
The protocol library runs its own queries on that connection, so the read and
write deadlines of the archive do not apply to them, and it keeps only its own
log lines out of the store: the store gets no logger, and its few log lines are
discarded. A store holds at most one device; a second one stops the start. Never
copy session.db to a second running service: two services that hold the same
device replace each other's connection (replaced).
Storage profiles¶
| Profile | Use it for | Locks | Free-space floor |
|---|---|---|---|
local (default) |
A local disk or a container's own filesystem | Open-file-description locks on Linux, where a kernel or filesystem that refuses them is refused (storage_ofd_unavailable); classic POSIX record locks elsewhere |
Applies |
nfs |
A network filesystem such as Amazon EFS | Classic POSIX record locks | Does not apply |
With profile local, serve refuses a data directory on a network filesystem
(storage_network_filesystem). On Linux it recognises NFS (Amazon EFS
included), SMB and CIFS, Ceph, AFS, Coda, NCP and 9P by the filesystem type that
statfs reports; on macOS, any mount without the local flag. FUSE filesystems
are not recognised, so do not run profile local on a FUSE mount of remote
storage. Profile nfs accepts any filesystem. Both profiles use the same
rollback journal and exclusive locking, and neither ever uses write-ahead
logging. Running SQLite on a network filesystem remains outside SQLite's
recommended configurations, and only one process may use the data directory at
a time.
Free space¶
WAWARDEN_MIN_FREE_BYTES sets a floor on the free space of the data
directory's filesystem, for profile local only: below it ingest pauses, and it
resumes once the free space reaches 1.25 times the floor. 0 turns the floor
off. The engine measures the free space when it starts and
every 30 seconds, also while it works through its inbox or history sync: it
checks whether a measurement is due before each inbox row, each history blob
and each batch of a blob. While ingest is paused it writes no message and no
history-sync notification and leaves them unacknowledged, which loses them (see
Delivery), and applies nothing from its inbox or from history sync;
a blob that the pause
interrupts stays waiting without using an attempt and is applied from its start
once ingest resumes. Pausing reports the
notification event ingest_paused, and resuming logs
ingest_resumed. A backup refuses to start, with
backup_failed reason space, when its copies could take the free space
below the floor. With profile nfs the floor
never applies, because a network filesystem such as Amazon EFS grows on demand;
watch its own capacity metrics instead.
Backups¶
With WAWARDEN_BACKUP_AGE_RECIPIENT set, the service writes encrypted backups of
the archive and the device store to
backups/ in the data directory. It holds only the
age recipient, a public key: it encrypts to it and
can never read a backup back. Without the variable no backup is ever taken, and
every start logs the warning backup_disabled. The fake engine
of a development build opens no device store, so it takes no backup either and
logs the same warning even with the variable set.
Backup schedule¶
Every 30 seconds the backup scheduler checks, in this order, whether the initial backup and whether a nightly backup are due.
- Initial backup. One backup is taken for each paired device once its
initial history sync has settled. The first time the
scheduler sees the owner's device paired, it records that moment in the
archive's
sync_statetable asbackup_paired_at, so a restart does not start the wait again. The backup is taken once no history blob is waiting to be processed (a quarantined blob does not count) and 10 minutes have passed since the later of that moment and the arrival of the latest history blob. Its success is recorded asbackup_taken_at. When a check finds the device no longer paired, or the engine has reported an unpairing since the previous check (a device unpaired and paired again between two checks), both keys are cleared, so the next paired device is backed up once in turn, without a restart. - Nightly backup. Once a backup has succeeded, one backup is owed per day at
the slot
WAWARDEN_BACKUP_TIME, a UTC time of day (default03:00). Every successful backup, initial or nightly, records the time it began asbackup_last_at, which unpairing does not clear. Abackup_taken_atwithout abackup_last_atcounts as one. A backup is owed when the newest success began before the most recent slot, or is recorded later than the current time, so that a clock set back cannot silence backups. This is also the catch-up: after downtime across one or more slots, the first check after the start takes one backup at once, while a start shortly before the slot, with the previous slot's backup done, waits for the slot. A service that has never taken a backup, such as one never paired, takes none. The nightly backup does not depend on the engine: an unpaired service goes on backing up its archive. When neither database has been written since the last backup this process took and recorded, the slot is skipped andbackup_skippedis logged once; the first slot after a start always backs up, and a paired, connected service writes often enough that it practically never skips. A write that lands between the end of a backup's copy and its bookkeeping is noticed only with the next write. - Failures. A failed backup, initial or nightly, reports
backup_failedand is tried again 15 minutes later, then after 30 minutes, 1, 2 and 4 hours, then every 4 hours, until one succeeds and its time is recorded, which resets the wait. The wait is held in memory: after a restart a backup that is due is tried at once. A backup cut short by shutdown (cancelled) is not retried before the next start.
At each slot, and at the first check after a start, the scheduler then deletes
the rows of history blobs processed more than 30 days before,
in batches of 10,000 rows for at most 15 minutes, and logs
history_blobs_pruned when it deleted any. It does so with or without a
recipient.
How a backup is written¶
First, on the local storage profile with a
non-zero free-space floor, the backup checks that the data
directory has at least the floor plus the size of the larger database plus the
sizes of both databases free, since its staging copy and its output come out of
the space ingest needs; otherwise it fails with reason space and writes
nothing. Each database is copied online, in steps of 256 pages on its own
connection, each step bound by the 10-second write deadline, so ingest goes on
between steps. A copy that stops early, for a failure or a cancellation, is
closed on that connection as soon as the connection is free, waiting out other
calls if it must, so that no copy stays attached to the database unless the
database is closed first. The archive is copied first, then
the device store, so the two
copies are separate points in time. Each copy is staged as a plaintext file of
mode 0600 in backups/tmp/ (archive.stage, then session.stage, one at a
time) and then streamed, without a second plaintext copy, through age into
backups/tmp/<time>.age. That file is flushed to disk with fsync, renamed to
backups/<time>.age and the directory is flushed in turn; backups/tmp/ is then
removed. <time> is the UTC time the backup began, as 20261005T120000Z. A
backup whose name already exists is refused. Any failure removes backups/tmp/
with the staging copies and the partial file, and a file renamed into place but
not flushed, then reports backup_failed; success reports backup_done.
Backup files¶
backups/ entry |
Mode | Holds |
|---|---|---|
backups/ |
0700 |
Created at the first backup; checked at start like the data directory when it exists. |
backups/tmp/ |
0700 |
Only while a backup runs: one plaintext staging copy at a time and the encrypted file being written. |
backups/<time>.age |
0600 |
One finished backup: a binary (not armoured) age file. |
Decrypted, a backup is a tar archive of three files: archive.db, session.db
and manifest.json. The manifest holds format (1), the build version, the
created time in UTC and, for each database, its name, schema_version and
size in bytes; it holds no identifier. The two databases hold everything the
archive and the device store hold, the linked device's keys included. The
master key keys/master and the retired keys of keys/retired/
are not in a backup.
Backup retention¶
After each successful backup, and only then, the service deletes old backups. It keeps:
- the newest
WAWARDEN_BACKUP_KEEPbackups (default14), ordered by the time in their names, never by the files' modification times; - the file just written, even when a clock set back gave it an older name.
It never keeps fewer than one. It deletes only regular files of mode 0600,
owned by the service's user, whose name is a backup's name with a valid time.
It accepts backups/tmp/ only as a 0700 directory of that user, which it
does not enter. Any other entry in backups/ makes it delete nothing and
report backup_prune_failed with reason unexpected_entry, after every
backup until the entry is removed. Such an entry is, for example, a symbolic
link, another directory, a copy under another name, or a backup with another
mode or owner.
A full disk never makes the service delete backups: it reports
ingest_paused or backup_failed with reason space and leaves the decision
to the operator. On an active service the backups cover the last
WAWARDEN_BACKUP_KEEP nights. Copies of the data directory made by the
storage, such as snapshots or recovery points, hold deleted backups for as
long as they are kept.
Backup failure reasons¶
backup_prune_failed gives one of these reasons:
backup_prune_failed reason |
Old backups were not all deleted because |
|---|---|
directory |
backups/ cannot be read or inspected, or is not a directory owned by the service's user with mode exactly 0700. Nothing was deleted. |
unexpected_entry |
backups/ holds an entry the service did not write, as above. Nothing was deleted. |
remove |
Deleting a file failed; the files deleted before it stay deleted and the rest are kept until the next backup. |
sync |
Flushing backups/ to disk after the deletions failed. |
backup_failed gives one of these reasons:
backup_failed reason |
The backup failed because |
|---|---|
directory |
backups/ or backups/tmp/ cannot be created or inspected, or is not a directory owned by the service's user with mode exactly 0700 (a symbolic link is refused). |
exists |
A file with the backup's name already exists in backups/, or its absence cannot be checked. |
output |
The encrypted file cannot be created in backups/tmp/. |
archive |
Copying archive.db or writing it into the encrypted file failed, for example on a full disk. |
session |
The same for session.db. |
manifest |
Writing the manifest failed. |
encrypt |
Starting or finishing the encryption failed, for example when its last chunk cannot be written. |
sync |
Flushing the file or backups/ to disk failed. |
rename |
Renaming the file into backups/ failed. |
cleanup |
Removing backups/tmp/ failed after the file was in place; the file is removed too. |
cancelled |
The service began shutting down during the backup. |
space |
The data directory has less free space than the floor plus the larger database plus both databases, or the free space or a database's size cannot be read. Only on the local profile with a non-zero floor. |
Checking a backup offline¶
Create the key pair on a machine other than the service's host with the age tools. Keep the identity file there, and set the public key it prints as the recipient:
Copy a backup off the host and check it on that machine with
backup verify, which reads what age decrypts and holds
no key itself; that section shows the pipeline. A truncated or altered file
fails to decrypt, and so does a file given the wrong identity. With
--extract-to, backup verify leaves archive.db and session.db in a new
directory, for a restore or for audit verify.
To inspect a backup by hand instead, age --decrypt ... | tar -x gives the
three files. sqlite3 needs version 3.34 or later with FTS5 to read the
archive's full-text index. The decrypted files hold the whole archive and the
linked device's keys: keep them as private as the data directory, and delete
them when done.
Restores¶
No command restores a backup. A restore is done by hand, into a stopped service, by the procedure in the runbook, which also lists what a restore takes back to the time of the backup. Two facts from this page shape it:
- A backup holds
archive.dbandsession.dbas two points in time, the archive's first. Restore both from the same backup, never one from another. - No backup holds
keys/masterorkeys/retired/. Keep your own copy of them. Without the master key, the restored service creates a new one, and audit rows written under the old key verify asunknown_key(see Master key).
Container image¶
Release images are published as ghcr.io/dortort/wawarden for linux/amd64 and
linux/arm64, by the release workflow only. Deploy them by digest;
RELEASING.md explains how to verify one, and
the hardening guide gives the flags to run
one with. Release images follow this contract:
| Item | Value |
|---|---|
| Base | gcr.io/distroless/static-debian13:nonroot, pinned by digest: no shell, no package manager |
| Binary | /wawarden, mode 0555 |
| Entrypoint and command | ENTRYPOINT ["/wawarden"], CMD ["serve"]; pass another subcommand as the command, for example admin init |
| User | 65532:65532 |
| Writable path | /data, the only path the service writes and the only one that must be writable: an empty directory owned by 65532:65532 with mode 0700; mount a volume there |
| Directories and modes | In /data the service creates keys/, history/, backups/ and backups/tmp/ with mode 0700, and every file (keys/master, archive.db, session.db and their journals, history blobs, backups) with mode 0600, and keys rotate creates keys/retired/ with mode 0700 and the retired keys in it with mode 0600; every directory must keep mode 0700 and the service's owner, see Data directory |
| Root filesystem | Mount it read-only (docker run --read-only, or readOnlyRootFilesystem: true in Kubernetes) so that /data is also the only writable path; the service writes nothing outside the data directory |
| Listeners | Three, plain HTTP, by default client 127.0.0.1:8080, admin 127.0.0.1:8082 (only with an admin token hash) and health 127.0.0.1:8081 (loopback only); see Listeners |
| Network | Inbound: the three listeners only. Outbound: name resolution; WhatsApp over HTTPS and a WebSocket on port 443, only while a device is paired or pairing was requested; and the webhook's host and port when WAWARDEN_NOTIFY_URL is set. Nothing else, and never through a proxy |
| Health check | ["/wawarden","healthcheck"], in exec form; the image declares no health check of its own |
| Exposed ports | None declared |
| Labels | org.opencontainers.image.source, licenses (GPL-3.0-or-later), version, revision |
| Licence | WaWarden is GPL-3.0-or-later; the image links modules under GPL-3.0, MPL-2.0, Apache-2.0, ISC, MIT and BSD-3-Clause, listed in the README |
| Licence texts | /licenses: the licence files of the Go standard library (std@<Go version>), of WaWarden and of every other Go module linked into /wawarden, one directory per module and version; see RELEASING.md |
Data volume¶
An empty named Docker volume mounted on /data takes the image directory's
owner and mode, so it passes the data directory checks as
is. A volume on a network filesystem, such as Amazon EFS, needs
WAWARDEN_STORAGE_PROFILE=nfs; see Storage profiles. A
bind-mounted host directory must be owned by 65532:65532 with mode 0700,
or serve refuses it (data_dir_foreign_owner or data_dir_permissions).
Health check form¶
Use the exec form: test: ["CMD", "/wawarden", "healthcheck"] in Compose, an
exec probe with command: ["/wawarden", "healthcheck"] in Kubernetes.
docker run --health-cmd wraps the command in a shell, which this image does
not have, so that check always fails. The check runs inside the container,
where the health listener's loopback address is reachable.
Reaching the listeners¶
Inside a container, a listener on 127.0.0.1 is reachable only from the same
network namespace, for example from a sidecar that forwards to it. Port
publishing does not reach it. To publish a port, set
WAWARDEN_LISTEN=0.0.0.0:8080 (and WAWARDEN_ADMIN_LISTEN=0.0.0.0:8082 if
needed). The service then logs listener_not_loopback at every start, and
every host or container that can reach the container's address can reach the
listener. Publish the port on the host's loopback address only, as the
Compose example below does.
Running as root¶
When the runtime overrides the user to root, serve refuses to start
(running_as_root) unless it is given --allow-root. With --allow-root,
the data directory must be owned by root with mode 0700. The image's
/data, and a named volume initialised from it, are owned by 65532:65532,
so serve refuses them with data_dir_foreign_owner.
Docker Compose example¶
A Compose service that follows the contract above and the
hardening guide: a read-only root
filesystem, user 65532:65532, a named volume on /data, the exec-form
health check, and ports published on the host's loopback address only. The
client and admin listeners bind 0.0.0.0 inside the container so that the
published ports reach them, which logs listener_not_loopback at every start.
services:
wawarden:
image: ghcr.io/dortort/wawarden@sha256:<digest>
user: "65532:65532"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 256
mem_limit: 512m
stop_grace_period: 15s
restart: unless-stopped
environment:
WAWARDEN_OWNER_PHONE: "+15550100001"
WAWARDEN_ADMIN_TOKEN_SHA256: "<the sha256 line printed by wawarden admin init>"
WAWARDEN_BACKUP_AGE_RECIPIENT: "<age1... public key printed by age-keygen>"
WAWARDEN_LISTEN: "0.0.0.0:8080"
WAWARDEN_ADMIN_LISTEN: "0.0.0.0:8082"
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:8082:8082"
volumes:
- wawarden-data:/data
healthcheck:
test: ["CMD", "/wawarden", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
wawarden-data: {}
WAWARDEN_DATA_DIRkeeps its default,/data, the volume's mount point.- The health listener keeps its loopback default and its port is not published: the health check runs inside the container.
stop_grace_period: 15sgives the service more than its 10-second shutdown grace period. Compose's default is 10 seconds.mem_limit: 512mleaves room for history sync at the defaultWAWARDEN_HISTORY_MAX_BYTES; see Memory bounds.-
Admin commands run inside the container and read the token on standard input.
-Tgives the command no terminal, so--token-stdinreads the redirected file: