Usage
Setup
You need two machines: one with internet access, one behind the diode. Run one command on each.
Machine 1 — internet side
uv tool install wj-diode-quantum
wj-diode-quantum setup send
wj-diode-quantum collect -c config.json
setup send asks for your ANU API key and your diode's details, then writes everything it needs.
Leave collect running — it polls each ANU endpoint on its own schedule and streams what it gets
across the diode.
Machine 2 — protected side
uv tool install wj-diode-quantum
wj-diode-quantum setup recv
wj-diode-quantum serve -c config.json
serve runs the receiving tool for you, so this is the only process you need on this machine.
Check it works
curl localhost:8099/api/status
{
"available": 160,
"capacity": 100000,
"oldest": "2026-08-02T04:14:58Z",
"newest": "2026-08-02T04:14:58Z",
"served_total": 0,
"last_ingest": "2026-08-02T04:15:12Z"
}
Then take a number:
curl localhost:8099/api/random
{
"value": "74b033a4332c7112cf0c3ff5...67f6b30ee",
"timestamp": "2026-08-02T04:14:58Z",
"source": "quantumnumbers"
}
value is your 1024-bit random number, 256 lowercase hex characters. timestamp is when it was
collected from ANU. You will never be given that number again.
There is also a page at http://localhost:8099/ with the same reference and live pool figures.
That is the whole setup. Everything below is reference material you only need if you are changing something.
Reference
Getting an ANU API key
The current ANU API needs a free account. Sign up and generate a key at
quantumnumbers.anu.edu.au/api-key, then give it to
setup send when asked.
The older endpoint at qrng.anu.edu.au needs no key, but it is limited to one request per minute
and ANU are retiring it. It works as a fallback or for trying things out. Both can be enabled at
once, in which case each is polled on its own schedule and their values are merged into the pool,
each tagged with where it came from.
Commands
wj-diode-quantum — collects quantum random numbers from ANU, pushes them across a
data diode, and serves them one at a time on the far side.
quick start:
On the internet-connected machine:
1. wj-diode-quantum setup send — answer a few questions
2. wj-diode-quantum collect -c CONFIG — start collecting
On the protected machine:
1. wj-diode-quantum setup recv — answer a few questions
2. wj-diode-quantum serve -c CONFIG — start serving
commands:
setup Interactive wizard; writes a working config (send or recv)
collect Collect from ANU and push batches over the diode
serve Receive batches, store them, and serve numbers over HTTP
stats Print pool depth, age range, and served totals
sample-config Print a full example config with every option
flags:
--version Show version and exit
--license Show licence information and exit
--help Show this help
Run wj-diode-quantum COMMAND --help for command-specific help.
Configuration
setup writes a minimal config. To see every available option:
wj-diode-quantum sample-config
The only path a config can hold is server.pool_file, and it is optional. There is no outbox and no
spool: the sending side writes nothing at all.
anu
| Field | Meaning |
|---|---|
timeout_seconds |
How long to wait for an ANU response. Default 180 — the legacy endpoint is slow. |
quantumnumbers.enabled |
Use the keyed API. |
quantumnumbers.api_key |
Your API key. Required when that source is enabled. |
quantumnumbers.length |
Blocks per request, 1–1024. |
quantumnumbers.interval_seconds |
How often to poll. Default 28800 — eight hours. |
quantumnumbers.size |
16-bit values per block, 1–10 on this API. A block is twice this in bytes. |
legacy.enabled |
Use the old unkeyed API. |
legacy.length |
Blocks per request, 1–1024. |
legacy.interval_seconds |
How often to poll. Default 3600 — one hour. |
legacy.size |
Bytes per block, 1–1024 on this API. Default 64; see the timing note below. |
The two APIs cap size differently, and neither comes close to the 128 bytes a value needs. That
does not matter: whatever blocks come back are concatenated and re-sliced into 1024-bit values
locally. Any leftover bytes are discarded rather than padded, and the count discarded is logged so a
badly chosen length/size pair is visible rather than silent.
They also interpret size differently, despite being sent the same type=hex16 request. The
keyed API treats it as a count of 16-bit values, so a block is twice size in bytes; the legacy API
treats it as a byte count. Both were measured against the live endpoints:
| Request | Bytes per block | Total | Values |
|---|---|---|---|
keyed, length: 1024, size: 10 |
20 | 20,480 | 160 |
legacy, length: 1024, size: 64 |
64 | 65,536 | 512 |
The legacy endpoint is slow, and that — not what it will accept — is what bounds size.
Measured against the live service it returns roughly two seconds per kilobyte:
legacy.size |
Response | Time | Values |
|---|---|---|---|
| 32 | 32,768 B | 68 s | 256 |
| 64 | 65,536 B | 132 s | 512 |
| 128 | 131,072 B | ~264 s | 1,024 |
The default of 64 sits comfortably inside the 180-second timeout_seconds. Raising it means
raising the timeout too, and a request of several minutes against an endpoint ANU are retiring
fails more often than it succeeds — a timeout costs the whole cycle, since the next attempt is a
full interval away.
Since the blocks are concatenated and re-sliced regardless, this affects only how much one request returns, never the values themselves.
Each endpoint is polled on its own schedule, because their rate limits are nothing alike: the keyed API allows roughly 100 requests a month and the legacy one a request a minute. The defaults are sized to those limits — three keyed requests a day is about 90 a month. Raising the keyed frequency much at all will exhaust the monthly quota.
At the defaults the two together supply about 530 values an hour — 512 from the legacy endpoint hourly, plus 160 from the keyed one every eight hours.
collect
| Field | Meaning |
|---|---|
retry_limit |
Send attempts within a cycle before the values are discarded. Default 5. |
Nothing is buffered on disk. If the diode will not accept a transfer after retry_limit attempts,
those values are dropped — the next cycle collects fresh ones.
diode
| Field | Meaning |
|---|---|
send_command |
Argument list. The values are written to the command's standard input. |
timeout_seconds |
How long a send may take. Default 600. |
The command is run directly, not through a shell, so arguments containing spaces need no quoting.
There is no {} placeholder: this is a stream, not a file to point at.
Use the stream subcommands
diodetool stream-send and stream-recv, not send and auto-recv. The file-based
subcommands take a path and would need somewhere to write it — which is exactly what this
program no longer does. --no-wait and -f do not exist on stream-send; passing either makes
every cycle fail.
server
| Field | Meaning |
|---|---|
listen |
Address for the API. Default 127.0.0.1:8099. |
max_entries |
Pool capacity. Default 100000. |
served_history |
How many served values to remember for deduplication. Default 10000. |
receive_command |
Required. Run repeatedly by serve; its output is the arriving stream. |
pool_file |
Absolute path to keep the pool at, encrypted. Optional; unset means memory only. |
hot_entries |
How many of the newest values the small pool file carries. Default 256. |
At max_entries: 100000 a full pool is roughly 100 MB of memory, since a value is 128 bytes and is
held as hex alongside a dedupe index.
served_history now costs time per request as well as memory, because it is rewritten every time a
value is served. Measured at the defaults, the small pool file is 3.5 MB and a request takes
about 18 ms, so the server tops out near 55 requests a second.
That is far above what supply allows — both ANU sources together produce roughly 530 values an hour
— so it only matters if there is no rate limit in front. If it does, served_history is the lever:
2000 brings the file to about 0.7 MB and the ceiling to roughly 275 a second, at the cost of a
shorter replay-protection window (about four hours of arrivals rather than nineteen).
Keeping the pool across restarts
Without pool_file, a restart starts with an empty pool. That is safe but expensive: the far side
of a diode cannot be asked to resend, so a container flap costs the whole stock until the next
collection cycle.
Set pool_file and the pool is written to disk encrypted, under a passphrase taken from the
environment:
export WJ_POOL_KEY="$(openssl rand -hex 32)"
wj-diode-quantum serve -c config.json
Any non-empty string works — it is stretched with PBKDF2-HMAC-SHA256 before use — but a random one
is better than a memorable one. wj-diode-quantum setup recv will generate one and print it.
Two files are written, both mode 0600: pool_file itself, and a companion pool_file + .hot.
The small one is rewritten every time a value is served; the large one only when values arrive and
periodically. That split is what makes the guarantee below affordable.
The key is not recoverable
There is no escrow and no recovery. Without $WJ_POOL_KEY the pool cannot be read, and the
server will refuse to start rather than overwrite it — which is the right behaviour, but it
means a lost key is a lost pool. A diode carries nothing back, so there is nobody to ask for
the numbers again.
If the key is wrong, or a pool file has been altered, serve reports it and exits without touching
the files.
The API
There is no authentication. Anyone who can reach the port can drain the pool. Keep it on loopback or a trusted network, or put a reverse proxy in front of it.
| Endpoint | Returns |
|---|---|
GET / |
A documentation page with curl examples and live pool figures. |
GET /api/random |
One value as JSON, with its collection timestamp. 503 with {"error":"pool empty"} when drained. |
GET /api/random/hex |
The same value as 256 bare hex characters and a newline. |
GET /api/random/base64 |
The same value base64 encoded, and a newline. |
GET /api/random/binary |
The same value as exactly 128 raw bytes. No encoding, no newline. |
GET /api/status |
Pool depth, timestamp range, totals. |
GET /api/version |
Product and running version. |
GET /api/spec |
An OpenAPI 3.1 description of the API, as JSON. |
GET /api/help |
This section as plain text, for a terminal. |
One value per request, by design.
Every one of the four value endpoints also returns its metadata in response headers, which is the only place the raw form can carry it:
| Header | |
|---|---|
X-Quantum-Timestamp |
When the value was collected from ANU. |
X-Quantum-Source |
Which endpoint produced it, quantumnumbers or legacy. |
X-Quantum-Bits |
Always 1024. |
X-Quantum-Format |
json, hex, base64 or binary. |
X-Quantum-Available |
Values left in the pool after this one was taken. |
All four also set Cache-Control: no-store. A cached random value is not a random value, and there
is usually a reverse proxy in front of this.
The text and binary endpoints report a drained pool as plain text rather than JSON, so a client reading the body as bytes is not handed an error document it would accept as a value.
# The usual shell shape:
KEY=$(curl -s localhost:8099/api/random/hex)
# Or straight to a file:
curl -s -o seed.bin localhost:8099/api/random/binary
How the pool behaves
The pool is a bounded LIFO. Requests are served the newest value first, and once the pool
reaches max_entries the oldest values are discarded to make room. It is a sliding window over the
most recent numbers, so served timestamps stay close to collection time and memory use stays bounded.
A value is never served twice. With pool_file set that holds across restarts too: the value is
recorded on disk as served before it is written to the response, so a crash between the two can
only lose it, never hand it out again. Without pool_file the pool is memory only, and a restart
starts it empty and refills from the far side's next cycle.
If the pool cannot be written, the request fails with 503 and nothing is consumed — the value
is still there once the disk is. The failure direction is always towards losing a value rather than
serving one twice.
Because a diode carries nothing back to the sending side, the collector can never learn that the
server is running low. Watch /api/status or run stats:
wj-diode-quantum stats -c config.json
Server: http://127.0.0.1:8099/api/status
Available: 638 of 100000 (0.6% full)
Served total: 2
Pool: encrypted on disk, survives a restart
Newest value: 2026-08-02T04:14:58Z (12 minutes ago)
Oldest value: 2026-08-02T04:14:58Z (12 minutes ago)
Last ingest: 2026-08-02T04:15:12Z (11 minutes ago)
stats asks the running server, so it needs serve up — there is no file to read behind its back.
Using a continuously running receiver
diodetool stream-recv emits whatever is queued and exits, which is why serve runs it over and
over. If your build has auto-stream-recv — which polls continuously and never exits — it works
through the identical code path with no change:
"receive_command": ["diodetool", "auto-stream-recv", "--channel", "quantum", "--no-colour"]
serve simply never gets to restart it. Either is fine; the parser does not care where one
invocation ends and the next begins.
Under systemd
[Unit]
Description=wj-diode-quantum
After=network-online.target
[Service]
ExecStart=/usr/local/bin/wj-diode-quantum serve -c /etc/wj-diode-quantum/config.json
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Use collect in place of serve on the sending machine.
What crosses the diode
JSON Lines — one value per line, newline terminated, about 330 bytes each:
{"value":"74b033a4...","timestamp":"2026-08-02T04:14:58Z","source":"quantumnumbers"}
{"value":"3c44fd1d...","timestamp":"2026-08-02T04:14:58Z","source":"quantumnumbers"}
Every value is exactly 256 lowercase hex characters.
There is no framing above the line, deliberately. The transport chops the stream into chunks at arbitrary offsets and the receiving tool hands them over in batches that begin and end wherever they happen to, so a value can be split across two of them. A newline is the only synchronisation point that survives that, and it bounds the damage: a line lost or corrupted in transit costs exactly one value, and parsing resumes at the next newline.
Each line is validated on arrival — value length, lowercase hex, and a parseable timestamp. A line that fails is logged and skipped. A value that has already been seen is ignored, which is what stops a replayed chunk reintroducing numbers that were already served.
Troubleshooting
pool empty — nothing has arrived yet, or the pool has been drained. Check stats on this
machine and the collector's log on the other.
every ANU source tried failed: legacy — usually the legacy endpoint's one-request-per-minute
limit. It returns HTTP 500 with a plain-text explanation, which appears in the log.
gave up after N attempts — the send command is failing and those values have been discarded.
Its output is relayed into the log. Nothing is kept: the next cycle collects fresh values.
skipped a damaged line — a value arrived corrupted. A diode has no retransmission, so it
cannot be recovered; the stream resynchronises at the next newline and the rest arrives normally.
receive command returned in Xms with no data — the receive tool is not blocking the way it is
expected to. Check the channel name and password: a mismatch on either can look like an idle link.
The pool is empty after a restart — expected unless pool_file is set. Without it the pool is
memory only; it refills on the far side's next cycle, which at the default schedules is within the
hour.
server.pool_file is set but WJ_POOL_KEY is empty — the passphrase is not in the server's
environment. Under systemd it needs an Environment= or EnvironmentFile= line; under Docker an
environment: or env_file: entry. The server refuses to start rather than run without it,
because starting would overwrite the pool on the first value served.
could not be decrypted — the key has changed, or the file has been altered. The files are left
untouched, so the original key will still open them if it can be found. If it cannot, delete both
pool_file and pool_file.hot and start again with an empty pool; there is no other way back.
pool could not be committed to disk — the volume is full, read-only, or gone. Requests fail
with 503 and nothing is consumed, so no values are lost while it lasts.