Status of every item in the Phase 2 specification. A box is ticked only if the behaviour is implemented and covered by a test that would fail if it broke. Unticked boxes are honest gaps, each with a note on why.
Verified on WSL2 (kernel 6.6.87.2, x86_64, 12th Gen Intel Core i5-12450H, 12 cores, glibc 2.39) with Rust 1.97.1.
cargo test --all 145 passed, 0 failed
cargo clippy --all-targets -- -D warnings clean
cargo fmt --check clean
cargo doc --no-deps (RUSTDOCFLAGS=-D …) clean
-
Cargo.tomlwith[package] name="ion" edition="2024" rust-version="1.85" -
src/main.rs— thin CLI binary -
src/lib.rs—pub modre-exports plus crate docs -
src/abi.rs— zero-copyvx_task_header_t/vx_result_header_tcodec -
src/config.rs— env-driven config (VX_*), nounwrapon user input -
src/dns/mod.rs -
src/dns/name.rs— RFC 1035 label encoding and validation -
src/dns/message.rs— header/section encoder and decoder -
src/dns/update.rs— RFC 2136UPDATEbuilder -
src/dns/tsig.rs— RFC 8945 TSIG HMAC-SHA256 signing -
src/registrar.rs— register on boot, delete onSIGTERM -
src/scrape.rs— tokio HTTP/1.1 + HTTP/2 fetch and HTML extraction -
src/runtime.rs— task dispatch loop, timing, result emission -
tests/dns_wire.rs— golden byte-vector tests -
tests/abi_roundtrip.rs -
README.md -
REQUIREMENTS.md -
LICENSE -
.gitignore -
.github/workflows/ci.yml
No files were added beyond this list. The MSRV declaration is real and was
checked, not assumed: the code avoids let-chains (Rust 1.88) and is_multiple_of
(1.87), and cargo +1.85.0 check --lib --bins compiles cleanly against
rustc 1.85.0 (4d91de4e4 2025-02-17) even though the toolchain used for
development is 1.97.1. CI enforces it in the msrv job.
- Parse and emit the 93-byte packed
vx_task_header_t - Exact documented offsets:
magic@0,task_id@4,tenant_id[64]@12,engine@76,memory_limit_mb@77,cpu_quota_us@81,payload_len@85,payload@93— asserted individually, and asserted to tile the struct with no padding hole (task_offsets_match_the_c_header) - Little-endian, proved against a hand-assembled golden header
(
encoding_matches_the_hand_assembled_golden_header,every_multi_byte_field_is_little_endian) - Reject bad magic (
0x58575601) —bad_magic_is_rejected, four corrupt values including the byte-swapped one - Reject oversized payload (> 16 MiB) —
an_oversized_payload_len_is_rejected, including that exactly 16 MiB is accepted - Reject truncated buffers —
a_short_header_is_rejectedsweeps all 93 prefixes;a_truncated_payload_is_rejected - Typed errors, never a panic —
AbiErrorwith 9 variants, each mapped ontovx_status_tbyAbiError::status();no_byte_pattern_can_panic_the_decoderruns 4,096 randomised buffers through all six decode entry points - Emit the 29-byte
vx_result_header_t—result_frame_round_trips,result_offsets_tile_the_struct_exactly, andexit_codeproved to survive as a signedi32down toi32::MIN - No
unsafetransmutes —#![forbid(unsafe_code)]at the crate root -
u32::from_le_byteson bounds-checked slices —slice_atreturnsResult, so no indexing operation in the module can panic
Zero-copy is real, not nominal: Task::parse borrows the payload, and
task_frame_round_trips_and_borrows_its_payload asserts pointer identity with
std::ptr::eq rather than byte equality. peek_magic / peek_task_id /
peek_payload_len / peek_tenant_id_raw read individual fields at their offsets
with no struct built at all.
- No subprocess. No
nsupdate, nodig, nostd::process::Commandanywhere in the crate; no third-party DNS crate in the dependency graph - Header: id, flags, opcode 5 (UPDATE)
- RFC 2136 section counts
ZOCOUNT/PRCOUNT/UPCOUNT/ADCOUNT, exposed under both spellings onHeader - Zone section:
QNAME=zone,QTYPE=SOA (6),QCLASS=IN (1) - Add
A: name / A(1) / IN(1) / ttl / rdlength=4 / 4-byte rdata - Delete one RR: class NONE (254), ttl 0, rdlength 4 + rdata
- Delete an entire RRset: class ANY (255), ttl 0, rdlength 0
- Prerequisite "name is not in use": class NONE, type ANY(255), ttl 0, rdlen 0
-
AAAA(28) andCNAME(5) - RFC 1035 limits: label ≤ 63, name ≤ 255, empty and oversized labels rejected
- Response decoder reading the
RCODEand reporting a typed error
Beyond the required set, also implemented and tested:
- Delete every RRset at a name (§2.5.3):
TYPE=ANY,CLASS=ANY - The other three prerequisites: name in use (§2.4.4), RRset exists (§2.4.1), RRset does not exist (§2.4.3)
-
TXTrecords with length-prefixed character strings - Compression-pointer decoding with backward-only enforcement, a 64-jump limit, and a 255-byte expansion ceiling
-
RCODEcoverage for the RFC 2136 additions (YXDOMAIN,YXRRSET,NXRRSET,NOTAUTH,NOTZONE) and the RFC 8945 extended codes (BADSIG,BADKEY,BADTIME, …) - Out-of-zone names refused in the builder rather than round-tripped to a
NOTZONE
Deliberately not implemented: compression on output. RFC 1035 §4.1.4 makes
it optional and RFC 2136 does not require it; omitting it makes every packet a
deterministic function of the message, which is what TSIG signing needs, and
avoids the RFC 3597 §4 hazard around names inside RDATA. Cost: 11 bytes on a
typical registration. Cross-checked with dnspython, which does compress, and
which confirmed the two encodings parse to equal messages.
Also not implemented: EDNS0 OPT. ion's largest packet is 153 bytes
signed, far inside the 512-byte floor, so there is nothing to negotiate.
- Sign
UPDATErequests with HMAC-SHA256 per RFC 8945 - Canonical digest over the request plus the TSIG variables: algorithm
name, time-signed, fudge, error, other-len — with
MAC Size,MAC, andOriginal IDcorrectly excluded - Append the TSIG RR to the additional section and bump
ADCOUNT - Uses the
hmac+sha2crates - Test that signing is deterministic for a fixed key and time
(
tsig_signing_is_deterministic_for_a_fixed_key_and_time) - Test that the TSIG RR round-trips through the message decoder
(
the_tsig_rr_round_trips_through_the_message_decoder, field by field)
Beyond the required set:
- Verified against dnspython 2.8.0.
ion's signed packet was accepted and its MAC validated by dnspython's owndns.tsigverifier, with a bit-flip negative control confirming the check is discriminating - RFC 4231 test case 2 pins the HMAC primitive itself
- HMAC-SHA512 as a second algorithm
- Response MAC verification, chained off the request MAC, digesting the
received bytes as received with
ARCOUNTpatched down — not a re-encode, so server-side name compression cannot break it - Fudge-window enforcement and
TsigRemoteErrorfor a peer-set error code - Constant-time MAC comparison
-
sign_response, so the verification path is testable end to end - Hand-rolled base64 with round-trip and malformed-input tests
-
TsigKey/TsigSettingsredact the secret inDebug, with tests
- Detect the local IP by
connect()ing a UDP socket to the resolver and reading backlocal_addr()— no external service call, no packet sent (local_address_detection_sends_nothing_and_finds_a_loopback_source) - Build
<task_id>.<tenant>.vxcloud.io→Arecord (base domain configurable viaVX_DNS_BASE_DOMAIN, defaultvxcloud.io.) - Send to the CloudDNS server over
tokio::net::UdpSocket - Retry with a per-attempt timeout
- On
SIGTERM/SIGINTviatokio::signal::unix, send the DELETE and exit - The graceful-shutdown path is a testable function —
graceful_shutdown(&Registrar), withwait_for_termination()split out so no test has to raise a signal
Beyond the required set:
- Registration clears the address RRset before adding, so a recycled task id cannot inherit a dead worker's address
- De-registration deletes only this worker's RR (
CLASS=NONE), so a shared round-robin name keeps its other members - Tenant slugs are folded into one legal DNS label (lower-cased, non-LDH
mapped to
-, runs collapsed, trimmed, truncated at 63) so an arbitrary tenant string cannot produce an illegal name -
Registrar::with_addressskips detection, for NAT'd containers where the local address is not the address peers should use - Response id echo and QR-bit checked before the
RCODEis trusted - A bare
NOTAUTHwith no TSIG RR reports theRCODErather than a misleading "no TSIG" - IPv6:
AAAAregistration and an IPv6 server address both work
-
[profile.release]withopt-level="z",lto="fat",codegen-units=1,panic="abort",strip=true - Current-thread tokio by default; multi-thread opt-in via
VX_RUNTIME_MULTI_THREAD - Cold start and peak RSS actually measured in WSL and reported in the README with the real numbers
Ranges below are the spread across three full benchmark sessions on the same binaries. This is a laptop under WSL2, so single-run digits are not stable and are not presented as if they were.
Worst of 7 runs per session, /usr/bin/time -v:
| Workload | glibc dynamic | musl static |
|---|---|---|
--version |
3.88–4.00 MiB | 2.13 MiB |
selftest |
4.13–4.25 MiB | 2.25–2.38 MiB |
run, 1 task (tokio + live rustls client) |
4.75–4.88 MiB | 2.63 MiB |
run, 1,000 tasks streamed |
4.63–4.75 MiB | 2.75 MiB |
run, 5,000 tasks streamed |
— | 2.75 MiB (unchanged: bounded by the frame) |
Worst figure anywhere for the static build on the streaming workloads: 2.75 MiB, roughly a third of the budget.
Honestly reported caveats:
- The current-thread default did not measurably beat multi-thread on this 12-core host: 2,688 KiB versus 2,816 KiB (musl), and identical at 4,864–4,992 KiB (glibc). Thread stacks are lazily faulted, so 11 idle workers cost little resident memory on a workload this short. Current-thread stays the default for startup latency, but the honest RSS win is ~128 KiB, not dramatic.
- RSS scales with the inline payload, so the ABI's 16 MiB
payload_lencap and the 8 MiB RSS budget do not compose.ionnow enforcesVX_MAX_INLINE_PAYLOAD_BYTES(default 1 MiB, clamped to the ABI cap — a larger value is a startupConfigError, not a licence to exceed the ABI) at dispatch time, before the payload is allocated - An over-ceiling frame is answered with a typed
VX_STATE_FAILED/VX_ERR_PAYLOAD_TOO_LARGEresult naming both the actual size and the limit; its bytes are drained through a fixed 64 KiB scratch buffer — never allocated — and the stream continues, because the header was valid ABI so the framing is intact, and aborting would forfeit every task queued behind the oversized one (one_byte_over_the_ceiling_is_rejected_and_the_stream_continues,a_payload_exactly_at_the_ceiling_is_accepted,a_rejected_payload_larger_than_the_drain_buffer_is_fully_skipped,an_over_ceiling_frame_truncated_mid_payload_is_still_a_stream_error,execute_rejects_an_over_ceiling_payload_without_running_it,inline_payload_ceiling_overrides_and_refuses_to_exceed_the_abi_cap)
Measured (one padded-noop task, /usr/bin/time -v, worst of 7 runs; "accepted"
= ceiling raised to the ABI max so the payload is really read and parsed,
"rejected" = the same frame against the default 1 MiB ceiling):
| Payload | accepted glibc | accepted musl | rejected glibc | rejected musl |
|---|---|---|---|---|
| 13 B | 4.88 MiB | 2.50 MiB | — | — |
| 4 KiB | 4.75 MiB | 2.50 MiB | — | — |
| 256 KiB | 5.01 MiB | 3.00 MiB | — | — |
| 1 MiB | 6.50 MiB | 4.50 MiB | at the limit: accepted | at the limit: accepted |
| 2 MiB | 8.63 MiB | 6.50 MiB | 5.00 MiB | 2.63 MiB |
| 4 MiB | 10.50 MiB | 8.63 MiB | 5.00 MiB | 2.63 MiB |
| 8 MiB | 14.50 MiB | 12.63 MiB | 4.88 MiB | 2.63 MiB |
| 16 MiB | 22.50 MiB | 20.63 MiB | 4.88 MiB | 2.63 MiB |
The relationship is piecewise linear, not linear: ~2 MiB of RSS per 1 MiB
of payload up to 2 MiB (payload buffer + tokio::fs's blocking-pool copy,
whose buffer grows with the read until its own 2 MiB cap), settling to
~1× + 2 MiB beyond. The default is 1 MiB because that is the largest
power-of-two at which the worst build stays inside the budget: 6.50 MiB at
1 MiB, 8.63 MiB — a breach — at 2 MiB. Rejection is flat: a 16 MiB frame
against the default ceiling costs baseline + the 64 KiB drain buffer
(2.63 MiB musl), so the budget now holds regardless of what arrives on the
wire.
In-process (main entry → exit, via ion --timing, 60 runs per session, p50):
| p50 across sessions | |
|---|---|
glibc --version |
49–79 µs |
glibc selftest |
96–145 µs |
musl --version |
86–123 µs |
musl selftest |
141–199 µs |
Worst p50 anywhere: 199 µs, five times inside the target, on the workload that does the most real work.
Full process (fork + execve + loader + run + exit, 500 iterations per session):
| per process | verdict | |
|---|---|---|
/bin/true (reference floor on this host) |
715–833 µs | — |
musl static ion --version |
825–846 µs | MET, 26–110 µs above the floor |
musl static ion selftest |
906–1,244 µs | borderline, doing real work |
glibc dynamic ion --version |
1,288–1,713 µs | MISSED |
glibc dynamic ion selftest |
1,322–1,861 µs | MISSED |
The dynamic build misses the target because ld.so resolution of
libc/libm/libgcc_s costs 500–900 µs on this WSL2 host, not because of
anything ion does. Ship the static build where cold start matters. WSL2 process
creation is unusually expensive; the same rows on bare metal would shift down with
the /bin/true floor, typically 200–400 µs.
Measured, then optimised, then re-measured: 170–280 µs/task initially, 35–65 µs/task after collapsing the two blocking reads per frame into one buffered read. Method and the before/after figures are in the README.
-
ion run— read a task header from stdin or a file, execute, write the result (--input/--output, any combination, plusion run <file>as shorthand; a conflicting or surplus path is a hard error rather than a silent fallback to an empty stdin) -
ion scrape <url> [--select CSS]— plus--mode,--links,--timeout-ms,--max-bytes,--json -
ion dns register|deletewith flags for zone / name / ip / server / key (--zone --name --ip --server --ttl --key-name --key-secret --algorithm --rrset --dry-run) -
ion selftest— encodes packets and prints hex, no network -
ion --version - Lean: argument parsing is hand-rolled, ~90 lines, no
clap, 9 unit tests
Also present: ion help, -h, -V, --key=value form, -- terminator, and
--timing (prints ion's own main-entry-to-exit time, which is how the
cold-start figure above is measured).
-
cargo test --allgreen — 145 passed, 0 failed - Golden hex vectors for a full
UPDATEadd and delete, asserting exact bytes — and five more: delete-RRset, delete-every-RRset, prerequisite+add,AAAA,CNAME - Name-encoding edge cases: 63-byte label OK, 64 rejected, 255-byte name boundary OK, 256 rejected, plus empty / doubled-dot / leading-dot / non-ASCII / root / underscore / wildcard
- ABI round trip and every rejection path
- TSIG determinism
- A live loopback UDP test that binds an ephemeral socket, sends a real
UPDATE, and asserts the server side received the exact expected bytes (live_loopback_send_update_puts_the_exact_golden_bytes_on_the_wire)
Six live-UDP tests in total: exact-bytes-on-the-wire, a full register +
graceful-shutdown round trip with byte-exact packet comparison, a NOTAUTH
refusal becoming a typed error, a signed exchange verified end to end including
the response MAC, a silent server producing Timeout after the configured
attempts with elapsed time proving the retries happened, and local-address
detection.
Test count by target:
| Target | Tests |
|---|---|
src/ unit tests |
71 |
src/main.rs CLI parser and path resolution |
11 |
tests/abi_roundtrip.rs |
30 |
tests/dns_wire.rs |
28 |
| doc-tests | 5 |
| total | 145 |
-
cargo clippy --all-targets -- -D warningsclean -
cargo fmt --checkclean
-
README.mdwith architecture - Substrate matrix (Lambda / ephemeral VM / K8s, plus micro-container and systemd)
- Real measured benchmarks, including the two that miss their target
- Usage examples with real captured output — every code block in the usage section is copied from an actual run against the built binary
- Wire-format hex diagram of an
UPDATEpacket, annotated offset by offset -
REQUIREMENTS.mdrestating the spec with honest checkboxes
-
.github/workflows/ci.ymlonubuntu-latestwith stable Rust -
cargo build --release -
cargo test -
cargo clippy -
cargo fmt
The workflow also builds the static musl target and runs the doc-tests, since both are load-bearing claims in the README.
- No credential, API key, token, password, or private IP from any local file appears in this repository
- Environment variables and placeholders only
Addresses used in tests and examples are RFC 5737 documentation ranges
(192.0.2.0/24), RFC 3849 (2001:db8::/32), loopback, or RFC 1918 literals
invented for the tests. The TSIG key in selftest and the test suite is a
published demonstration key whose base64 decodes to
if:you-can-read-this-it-is-not-a-secret.
Additional hardening, none of it required by the spec:
-
TsigKeyandTsigSettingshand-writeDebugto redact the secret, so a key cannot escape through a stray{:?}; two tests assert it - Constant-time MAC comparison
- Bounded compression-pointer decoding (backward-only, jump limit, output ceiling) so a decompression bomb is a typed error
-
#![forbid(unsafe_code)] - rustls with bundled webpki roots — no OpenSSL, no
native-tls, no system certificate store in the image - The help text explains why
--key-secretis worse thanVX_TSIG_SECRET(/proc/<pid>/cmdline)
Things a reader might reasonably expect that are not here:
- DNS name compression on output. Deliberate; see item 3.
- EDNS0 /
OPT. Not needed at 153 bytes; would be required for DNSSEC or larger payloads. - TCP fallback for DNS.
UPDATEover TCP (RFC 2136 §6) is unimplemented. Only relevant for a packet over the UDP limit, whichioncannot produce. - TSIG multi-message / TKEY / GSS-TSIG. Single-message signing only.
- Payloads above the inline ceiling are refused, not processed. The
budget hole this used to be — RSS scales with the inline payload, so a
legal 16 MiB frame breached the 8 MiB target — is closed: frames above
VX_MAX_INLINE_PAYLOAD_BYTES(default 1 MiB, measured justification in item 6) now get a typedVX_STATE_FAILEDresult and are drained without allocation. What remains true is thationcannot execute a payload bigger than the ceiling: the shared-memory handle path the ABI header alludes to for large bodies is not in the v1 header, so raising the ceiling (at most to the 16 MiB ABI cap, trading budget for capacity) is the only inline option. - Windows and macOS are unverified. The code compiles conditionally for
non-Unix (signal handling falls back to
ctrl_c()), but nothing was run there. - No benchmark harness in-tree. The numbers above were produced by shell
scripts using
/usr/bin/time -vanddate +%s%N, plusion --timing, and are reproducible with the commands given in the README, but there is nocriterionsuite committed. scraperis a heavy dependency (html5ever+ servo'sselectors). It is the reason the binary is 3.4 MiB rather than well under 1 MiB. The trade was made knowingly: a real CSS selector engine beats a hand-rolled approximation.