21 Commits

Author SHA1 Message Date
Razvan Dimescu
652fca5b80 chore: bump version to 0.11.0 2026-04-10 19:10:58 +03:00
Razvan Dimescu
de15b32325 feat: numa setup-phone — QR-based mobile DoT onboarding (#38)
* feat: numa setup-phone — QR-based mobile DoT onboarding

Adds a CLI subcommand that generates a one-time mobileconfig profile
containing both the Numa local CA (as a com.apple.security.root payload)
and the DoT DNS settings, then serves it via a temporary HTTP server
and prints a scannable QR code in the terminal.

Flow:
  1. User runs `numa setup-phone` (no sudo needed)
  2. Detects current LAN IP, reads CA from /usr/local/var/numa/ca.pem
  3. Builds combined mobileconfig (CA trust + DoT)
  4. Renders QR code with qrcode crate (Unicode block characters)
  5. Serves the profile on port 8765, stays open until Ctrl+C
  6. Counts successful downloads (multi-device households)

Important caveat documented in instructions: even with the CA bundled
in the profile, iOS still requires the user to manually enable trust
in Settings → General → About → Certificate Trust Settings. Verified
on a real iPhone.

Stable PayloadIdentifiers/UUIDs ensure re-running replaces the
existing profile on iOS rather than accumulating duplicates.

- New module: src/setup_phone.rs (~270 lines)
- New CLI subcommand: `numa setup-phone`
- New dependency: qrcode = "0.14" (default-features = false)
- tokio "signal" feature added for Ctrl+C handling
- 3 unit tests: PEM stripping, mobileconfig generation, QR rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: mobile API, enriched /health, mobileconfig module

Adds a persistent read-only HTTP listener (default port 8765, LAN-bound)
serving a dedicated subset of Numa's API for iOS/Android companion apps
and as a replacement for the one-shot server setup_phone used to spin up:

  GET /health           — enriched JSON with version, hostname, LAN IP,
                          SNI, DoT config, mobile API port, CA
                          fingerprint, features (shared handler with
                          the main API on port 5380)
  GET /ca.pem           — public CA certificate (shared handler)
  GET /mobileconfig     — full iOS profile (CA trust + DNS settings
                          pinned to current LAN IP)
  GET /ca.mobileconfig  — CA-only iOS profile (trust anchor without
                          DNS override — for the iOS companion app's
                          programmatic DNS flow via NEDNSSettingsManager)

All routes are idempotent GETs. The mobile API never serves the
state-mutating routes that live on the main API (overrides, blocking
toggle, service CRUD, cache flush), so it is safe to expose on the LAN
regardless of the main API's bind address. The CA private key is never
served by any route.

Opt-in via `[mobile] enabled = true`. Default is false so new installs
do not silently expose a LAN listener after upgrading; our committed
numa.toml template enables it explicitly for spike testing.

New modules:

- src/mobileconfig.rs — ProfileMode::{Full, CaOnly} enum with plist
  builder lifted from setup_phone.rs. Full and CaOnly share the CA
  payload UUID (same trust anchor) but have distinct top-level UUIDs
  so they coexist as separate installable profiles on iOS.

- src/health.rs — HealthMeta cached metadata built once at startup
  from config + CA fingerprint (SHA-256 of the PEM via ring), and the
  HealthResponse JSON shape shared between the main and mobile APIs.

- src/mobile_api.rs — axum Router for the persistent listener. Reuses
  api::health and api::serve_ca from the main API; owns the two
  mobileconfig handlers.

Modified:

- src/api.rs — health() returns the enriched HealthResponse, now pub.
  serve_ca is now pub so mobile_api can reuse it.
- src/config.rs — MobileConfig section (enabled, port, bind_addr).
- src/ctx.rs — health_meta: HealthMeta field on ServerCtx.
- src/main.rs — builds HealthMeta at startup, spawns mobile API
  listener if enabled.
- src/lan.rs — build_announcement takes &HealthMeta and writes
  enriched TXT records (version, api_port, proto, dot_port, ca_fp).
  SRV port now reports the mobile API port; peer discovery still
  reads TXT `services=` so this is backwards compatible. Always
  announces even when no .numa services are registered, so the iOS
  companion app can discover Numa via mDNS regardless of service
  state.
- src/setup_phone.rs — reduced from 267 to 100 lines. The CLI is now
  a thin QR wrapper over the persistent /mobileconfig endpoint; the
  hand-rolled one-shot HTTP server (accept_loop, RUST_OK_HEADERS,
  RUST_NOT_FOUND, download counter) is gone.
- src/dot.rs — test fixture updated with HealthMeta::test_fixture().
- numa.toml — commented [mobile] section, enabled = true for spike.

Tests: 136 unit tests passing (5 new in mobileconfig, 3 new in health).
cargo clippy clean. Integration sanity check: curl'd /health, /ca.pem,
/mobileconfig, /ca.mobileconfig against a running numa — all return
200 with correct content types and valid response bodies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: setup-phone probe, unknown command error, query source in dashboard

- setup-phone now probes the mobile API before printing the QR code
  and shows an actionable error if [mobile] is not enabled
- Unknown CLI subcommands print an error instead of silently
  attempting to start a full server
- Dashboard query log shows source IP under timestamp (localhost
  for loopback, full IP for LAN devices) with full addr on hover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:08:56 +03:00
Razvan Dimescu
6f961c5ec2 fix: push only specific tag when releaseing a new version 2026-04-10 09:03:03 +03:00
Razvan Dimescu
20bf14e91c chore: bump version to 0.10.3 2026-04-10 08:59:46 +03:00
Razvan Dimescu
e860731c01 fix: escape DNS label text per RFC 1035 §5.1 (closes #36) (#54)
* fix: escape dots and special characters in DNS label text per RFC 1035 §5.1

Closes #36

read_qname was pushing raw label bytes directly into the output string,
producing ambiguous text for labels containing dots, backslashes, or
non-printable bytes. fanf2 spotted this on HN: wire format
`[8]exa.mple[3]com[0]` (two labels, first containing a literal 0x2E)
was rendered as `exa.mple.com`, indistinguishable from three labels.

Fix both sides of the text representation per RFC 1035 §5.1:

read_qname — when rendering wire bytes to text:
- literal `.` within a label → `\.`
- literal `\` → `\\`
- bytes outside 0x21..=0x7E → `\DDD` (3-digit decimal)
- printable ASCII passes through unchanged

write_qname — when parsing text back to wire:
- `\.` produces a literal 0x2E inside the current label (not a separator)
- `\\` produces a literal 0x5C
- `\DDD` produces the byte with that decimal value (0..=255)
- unescaped `.` still separates labels, empty labels still skipped
- rejects trailing `\`, short `\DD`, and `\DDD` > 255

Impact in practice is low — real-world domains don't contain dots in
labels — but it's a correctness bug in the wire format parser that
could cause round-trip failures with adversarial input. The parser is
the core of the project, so correctness bugs take priority over
practical impact.

Adds 16 unit tests in a new `#[cfg(test)] mod tests` block covering:
plain domain read/write, literal-dot escaping on both sides, backslash
escaping, non-printable + space decimal escapes, full round-trip
preservation, and the three rejection cases for malformed escapes.

Credit: fanf2 (https://news.ycombinator.com/item?id=47612321)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: stream label writes directly into buffer (review feedback)

The first cut of this fix delegated write_qname to a helper
(parse_escaped_labels) that built Vec<Vec<u8>> up-front, then iterated
to emit the wire bytes. On a plain-ASCII domain like "www.google.com"
that's ~4 heap allocations per write_qname call, and record.rs calls
write_qname ~6 times per response — so this PR would regress
bench_buffer_serialize by roughly 24 extra allocations per response
vs. main, where the old non-escaping code had zero.

Rewrite write_qname as a streaming byte-level loop that reserves the
length byte up-front, writes the label body directly into the buffer,
then backpatches the length via set(). Zero intermediate allocations
on the common path, and the 63-byte label cap is now checked
incrementally so oversized labels fail fast.

Byte-level scanning is safe for UTF-8 input: continuation bytes are
always in 0x80..=0xBF, so they can never collide with the ASCII `.`
(0x2E) or `\` (0x5C) that drive label splitting and escape parsing.

Also inline the \DDD rendering in read_qname to avoid the per-byte
format!() allocation on non-printable input. Plain-ASCII reads hit
the unchanged push(c as char) fast path, so the common case has zero
regression.

The parse_escaped_labels helper is deleted — no remaining callers.

All 158 tests pass, clippy + fmt clean. Collapses three review
findings (HIGH allocation regression, MEDIUM format! allocation,
MEDIUM .unwrap() after digit guard) in one pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: route dnssec::name_to_wire through write_qname for escape handling

Closes #55.

dnssec::name_to_wire was a parallel implementation of the old
write_qname's splitting loop — it iterated qname.split('.') and pushed
raw bytes. It predated and duplicated the buffer.rs logic, and it did
not understand RFC 1035 §5.1 text escapes. After the read_qname fix in
this PR, names that come out of read_qname can contain \., \\, or
\DDD sequences; feeding those back into the old name_to_wire would
split on the literal '.' inside a \. sequence and produce corrupt
RRSIG signed-data blobs.

The underlying bug predates this PR — the old read_qname was broken
too, so both sides of the DNSSEC canonical form pipeline were
silently wrong in the same way. Making read_qname correct exposed the
divergence, so it's fixed here in the same PR that introduced the
exposure.

Reimplement name_to_wire on top of BytePacketBuffer::write_qname:
reserve a scratch buffer, let write_qname handle the escape parsing
and length-byte framing, copy the emitted bytes into a Vec, then
walk the wire once more to lowercase label bodies (length bytes stay
untouched). Canonical form per RFC 4034 §6.2 requires the
lowercasing, and it has to happen post-escape-resolution — a
decimal escape like \065 produces 0x41 ('A'), which must be
lowercased to 'a' in the final wire bytes.

Call sites in build_signed_data, record_to_canonical_wire,
record_rdata_canonical, and nsec3_hash are unchanged — the public
signature stays the same, infallible Vec<u8> return.

Tests:
- name_to_wire_escaped_dot_in_label_is_not_a_separator — verifies
  the fanf2 example round-trips correctly through canonical form
- name_to_wire_decimal_escape_is_lowercased — verifies post-escape
  lowercasing (the subtle correctness requirement)
- existing name_to_wire_root, name_to_wire_domain, ds_verification
  tests still pass unchanged

Test count: 158 → 160.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: tighten name_to_wire per review feedback

- Replace hand-rolled per-byte lowercase loop with stdlib
  [u8]::make_ascii_lowercase(). Shorter and idiomatic.
- Tighten the .expect() message to state the actual invariant
  (parseable DNS name) instead of vague "well-formed" language.
- Replace the doc comment's "see #55" with the real invariant —
  issue numbers rot, and by merge time #55 is closed anyway. The
  comment now explains WHY the lowercase pass has to happen
  post-escape-resolution (\065 → 'A' → 'a') instead of during
  write_qname.
- Drop the redundant `\065` test comment (the one-liner version
  is enough with the assertion showing the transform).

No behavior change; 160 tests still pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: cover label cap and empty-label rollback; trim doc comments

Closes coverage gaps left by PR #54:

- write_rejects_label_over_63_bytes: pins the incremental 63-byte cap
  inside write_qname's inner loop (boundary at 63 vs 64).
- write_skips_empty_labels: pins the rollback branch (pos = len_pos)
  triggered by leading or consecutive dots.

Doc comments tightened:

- write_qname: drop the streaming-impl walkthrough and the escape-grammar
  restatement (already documented on read_qname).
- name_to_wire: drop the implementation explanation; keep the
  post-escape lowercasing rationale, which pins behavior a future
  refactor could silently break.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:53:46 +03:00
Razvan Dimescu
f556b60ce4 fix: suppress recursive hint in install when already configured (#71)
`sudo numa install` unconditionally printed the "Want full DNS
sovereignty?" hint even when numa.toml already has mode = "recursive".
Now loads the config first and skips the message if recursive is
already set.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:32:51 +03:00
Razvan Dimescu
422726f1c8 chore(deps): bump rcgen from 0.13 to 0.14 (#70)
rcgen 0.14 replaced the separate Certificate + KeyPair args with a
unified Issuer type. Migrates ensure_ca and generate_service_cert:

- Load path: Issuer::from_ca_cert_der replaces the old
  CertificateParams::from_ca_cert_pem + self_signed round-trip.
- Generate path: Issuer::new(params, key_pair) constructs directly
  from the params used for self_signed (no DER re-parse).
- signed_by takes (&key_pair, &issuer) instead of (&key_pair, &cert, &key).

Also drops thiserror v1 from the dep tree (rcgen 0.14 uses v2).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:28:07 +03:00
dependabot[bot]
dd021d8642 chore(deps)(deps): bump socket2 from 0.5.10 to 0.6.3 (#67)
Bumps [socket2](https://github.com/rust-lang/socket2) from 0.5.10 to 0.6.3.
- [Release notes](https://github.com/rust-lang/socket2/releases)
- [Changelog](https://github.com/rust-lang/socket2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/socket2/commits/v0.6.3)

---
updated-dependencies:
- dependency-name: socket2
  dependency-version: 0.6.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:58:07 +03:00
dependabot[bot]
f20c72a829 chore(deps)(deps): bump toml from 0.8.23 to 1.1.2+spec-1.1.0 (#65)
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.23 to 1.1.2+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v1.1.2)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.2+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:57:55 +03:00
dependabot[bot]
44cd17cf84 chore(deps)(deps): bump criterion from 0.5.1 to 0.8.2 (#64)
Bumps [criterion](https://github.com/criterion-rs/criterion.rs) from 0.5.1 to 0.8.2.
- [Release notes](https://github.com/criterion-rs/criterion.rs/releases)
- [Changelog](https://github.com/criterion-rs/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/criterion-rs/criterion.rs/compare/0.5.1...criterion-v0.8.2)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.8.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:56:50 +03:00
dependabot[bot]
fb0a21e5e6 chore(deps)(deps): bump the minor-and-patch group with 3 updates (#63)
Bumps the minor-and-patch group with 3 updates: [tokio](https://github.com/tokio-rs/tokio), [hyper](https://github.com/hyperium/hyper) and [arc-swap](https://github.com/vorner/arc-swap).


Updates `tokio` from 1.50.0 to 1.51.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.51.1)

Updates `hyper` from 1.8.1 to 1.9.0
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.8.1...v1.9.0)

Updates `arc-swap` from 1.9.0 to 1.9.1
- [Changelog](https://github.com/vorner/arc-swap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vorner/arc-swap/compare/v1.9.0...v1.9.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.51.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: hyper
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: arc-swap
  dependency-version: 1.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:55:24 +03:00
dependabot[bot]
66b937f710 chore(deps): bump actions/download-artifact from 4 to 8 (#69)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:54:58 +03:00
dependabot[bot]
524aed7fa1 chore(deps)(deps): bump actions/checkout from 4 to 6 (#60)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:51:33 +03:00
dependabot[bot]
11e3fdeae6 chore(deps)(deps): bump actions/upload-artifact from 4 to 7 (#58)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:50:51 +03:00
dependabot[bot]
636c45b3d7 chore(deps)(deps): bump actions/upload-pages-artifact from 3 to 4 (#59)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:50:38 +03:00
dependabot[bot]
f602687d93 chore(deps)(deps): bump actions/configure-pages from 5 to 6 (#61)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:50:22 +03:00
dependabot[bot]
b8b0fda1e0 chore(deps)(deps): bump actions/deploy-pages from 4 to 5 (#62)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:50:08 +03:00
Razvan Dimescu
9a3fae9a0c fix: drop include:scope from dependabot commit-message config (#68)
The combination of `prefix: "chore(deps)"` and `include: "scope"`
produced `chore(deps)(deps):` — double scope. Removing `include`
keeps the prefix as-is.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:49:46 +03:00
dependabot[bot]
a31ac36957 chore(deps)(deps): bump the minor-and-patch group with 2 updates (#57)
Bumps the minor-and-patch group with 2 updates: rust and alpine.


Updates `rust` from 1.88-alpine to 1.94-alpine

Updates `alpine` from 3.20 to 3.23

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.94-alpine
  dependency-type: direct:production
  dependency-group: minor-and-patch
- dependency-name: alpine
  dependency-version: '3.23'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:48:46 +03:00
Casey Labs
9001b14fed [Feature] Add GitHub Dependabot scanning (runs once a month) (#46)
* Add GitHub Dependabot scanning (runs once a month)

* chore: group dependabot updates and use conventional commit prefix

Bundle all minor/patch bumps per ecosystem into a single PR to keep
noise manageable (~3 PRs/month instead of 10+). Major bumps still
get individual PRs since they may break APIs.

Commit messages now use the `chore(deps)` conventional-commit prefix
to match the repo's existing style.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Razvan Dimescu <ssaricu@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:40:49 +03:00
Razvan Dimescu
63ac69a222 ci: call homebrew-bump as reusable workflow instead of PAT event propagation (#53)
Reverts PR #44's approach of swapping GITHUB_TOKEN for a PAT on
action-gh-release. That approach worked in principle but failed in
practice during the v0.10.2 cut: HOMEBREW_TAP_GITHUB_TOKEN is a
fine-grained PAT scoped only to razvandimescu/homebrew-tap, so when
action-gh-release tried to create a release on razvandimescu/numa it
got 403 Resource not accessible. v0.10.2 had to be recovered manually
via `gh release create` from a user PAT.

Root cause of the original bug (from #44): GitHub Actions deliberately
does not propagate workflow events triggered by GITHUB_TOKEN, so a
release created by GITHUB_TOKEN silently failed to fire homebrew-bump's
`release: published` trigger.

Fix: sidestep the event-propagation rule entirely by invoking
homebrew-bump.yml directly as a reusable workflow via `workflow_call`.

- release.yml: drop the `token:` override on action-gh-release (reverts
  to GITHUB_TOKEN default, which v0.10.0 and v0.10.1 used successfully)
  and add a new `bump-homebrew` job that `needs: release` and `uses:`
  homebrew-bump.yml with `secrets: inherit`.
- homebrew-bump.yml: add `workflow_call` trigger with a `version` input,
  remove the `release: published` trigger (no longer needed), keep
  `workflow_dispatch` for manual recovery, and collapse the version
  determination step to a single `inputs.version` read.

Each token now does exactly what its scope permits:
- GITHUB_TOKEN creates the release on numa (contents: write, default)
- HOMEBREW_TAP_GITHUB_TOKEN pushes to homebrew-tap (unchanged)

The tap update becomes a child job in the release run, so failures are
visible in one place instead of "why didn't the release event fire?"
mysteries.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:33:48 +03:00
26 changed files with 1524 additions and 232 deletions

34
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
patterns: ["*"]
update-types: ["minor", "patch"]

View File

@@ -13,7 +13,7 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
@@ -30,7 +30,7 @@ jobs:
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: clippy
@@ -41,7 +41,7 @@ jobs:
check-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: build
@@ -51,7 +51,7 @@ jobs:
- name: test
run: cargo test
- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: numa-windows-x86_64
path: target/debug/numa.exe

View File

@@ -1,8 +1,12 @@
name: Bump Homebrew Tap
on:
release:
types: [published]
workflow_call:
inputs:
version:
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
type: string
required: true
workflow_dispatch:
inputs:
version:
@@ -16,17 +20,14 @@ jobs:
bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Determine version
id: ver
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
V="${{ github.event.release.tag_name }}"
else
V="${{ github.event.inputs.version }}"
fi
V="${V#v}"
V="${INPUT_VERSION#v}"
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Fetch sha256 checksums from release assets

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -70,7 +70,7 @@ jobs:
(Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.name }}
path: |
@@ -82,7 +82,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -96,23 +96,22 @@ jobs:
needs: [build, publish]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
merge-multiple: true
- name: Create Release
uses: softprops/action-gh-release@v2
with:
# Use a PAT (not the default GITHUB_TOKEN) so the resulting
# `release: published` event propagates to downstream workflows
# like homebrew-bump.yml. Events triggered by GITHUB_TOKEN are
# deliberately not propagated by GitHub Actions to prevent
# infinite loops; PAT-authored events are the documented escape
# hatch. Reusing HOMEBREW_TAP_GITHUB_TOKEN (already a PAT used
# by homebrew-bump.yml itself) keeps the secret surface flat.
token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
generate_release_notes: true
files: |
*.tar.gz
*.zip
*.sha256
bump-homebrew:
needs: release
uses: ./.github/workflows/homebrew-bump.yml
with:
version: ${{ github.ref_name }}
secrets: inherit

View File

@@ -30,18 +30,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install pandoc
run: sudo apt-get install -y pandoc
- name: Generate blog HTML
run: make blog
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v4
with:
# Upload entire repository
path: './site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5

246
Cargo.lock generated
View File

@@ -17,6 +17,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "anes"
version = "0.1.6"
@@ -84,9 +93,9 @@ dependencies = [
[[package]]
name = "asn1-rs"
version = "0.6.2"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
@@ -94,15 +103,15 @@ dependencies = [
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
@@ -368,25 +377,24 @@ dependencies = [
[[package]]
name = "criterion"
version = "0.5.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
@@ -394,9 +402,9 @@ dependencies = [
[[package]]
name = "criterion-plot"
version = "0.5.0"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
@@ -441,9 +449,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "der-parser"
version = "9.0.0"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
@@ -514,6 +522,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -702,12 +720,6 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "http"
version = "1.4.0"
@@ -810,7 +822,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2",
"tokio",
"tower-service",
"tracing",
@@ -944,17 +956,6 @@ dependencies = [
"serde",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -963,9 +964,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
@@ -1143,7 +1144,7 @@ dependencies = [
[[package]]
name = "numa"
version = "0.10.2"
version = "0.11.0"
dependencies = [
"arc-swap",
"axum",
@@ -1155,6 +1156,7 @@ dependencies = [
"hyper",
"hyper-util",
"log",
"qrcode",
"rcgen",
"reqwest",
"ring",
@@ -1162,7 +1164,7 @@ dependencies = [
"rustls-pemfile",
"serde",
"serde_json",
"socket2 0.5.10",
"socket2",
"time",
"tokio",
"tokio-rustls",
@@ -1172,9 +1174,9 @@ dependencies = [
[[package]]
name = "oid-registry"
version = "0.7.1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
@@ -1197,6 +1199,16 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "pem"
version = "3.0.6"
@@ -1301,6 +1313,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1314,8 +1332,8 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
@@ -1336,7 +1354,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"thiserror",
"tinyvec",
"tracing",
"web-time",
@@ -1351,7 +1369,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
@@ -1422,9 +1440,9 @@ dependencies = [
[[package]]
name = "rcgen"
version = "0.13.2"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
dependencies = [
"pem",
"ring",
@@ -1655,11 +1673,11 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "0.6.9"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde",
"serde_core",
]
[[package]]
@@ -1680,6 +1698,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -1698,16 +1726,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -1761,33 +1779,13 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"thiserror-impl",
]
[[package]]
@@ -1877,7 +1875,8 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.3",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -1918,44 +1917,42 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.23"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_write",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@@ -2188,6 +2185,22 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2197,6 +2210,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -2361,12 +2380,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
[[package]]
name = "wit-bindgen"
@@ -2382,9 +2398,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "x509-parser"
version = "0.16.0"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
@@ -2394,7 +2410,7 @@ dependencies = [
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 1.0.69",
"thiserror",
"time",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "numa"
version = "0.10.2"
version = "0.11.0"
authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021"
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
@@ -10,11 +10,11 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool
categories = ["network-programming", "development-tools"]
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
toml = "1.1"
log = "0.4"
env_logger = "0.11"
reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false }
@@ -22,17 +22,18 @@ hyper = { version = "1", features = ["client", "http1", "server"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
futures = "0.3"
socket2 = { version = "0.5", features = ["all"] }
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
socket2 = { version = "0.6", features = ["all"] }
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
time = "0.3"
rustls = "0.23"
tokio-rustls = "0.26"
arc-swap = "1"
ring = "0.17"
rustls-pemfile = "2.2.0"
qrcode = { version = "0.14", default-features = false }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
criterion = { version = "0.8", features = ["html_reports"] }
tower = { version = "0.5", features = ["util"] }
http = "1"

View File

@@ -1,4 +1,4 @@
FROM rust:1.88-alpine AS builder
FROM rust:1.94-alpine AS builder
RUN apk add --no-cache musl-dev cmake make perl
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
@@ -11,7 +11,7 @@ COPY numa.toml com.numa.dns.plist numa.service ./
RUN touch src/main.rs src/lib.rs
RUN cargo build --release
FROM alpine:3.20
FROM alpine:3.23
COPY --from=builder /app/target/release/numa /usr/local/bin/numa
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp
ENTRYPOINT ["numa"]

View File

@@ -102,3 +102,22 @@ tld = "numa"
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
# broadcast_interval_secs = 30
# peer_timeout_secs = 90
# Mobile API — persistent HTTP listener serving read-only routes
# (/health, /ca.pem, /mobileconfig, /ca.mobileconfig) on a LAN-reachable
# port. Consumed by the iOS/Android companion apps for discovery and
# profile fetching, and by `numa setup-phone` for QR-based onboarding.
#
# Opt-in because the listener binds to the LAN by default. None of the
# exposed routes are cryptographically sensitive (no private keys, no
# state mutations, all idempotent GETs), but enabling it does add a new
# listener to any device on the LAN that scans port 8765.
#
# Safe for home LANs. Think twice before enabling on untrusted LANs
# (office Wi-Fi, coffee shops, etc.) — an attacker on the same network
# could run a competing Numa instance that shadows yours via mDNS and
# trick companion apps into installing their profile instead of yours.
[mobile]
enabled = true # opt-in to the mobile API listener
# port = 8765 # default; matches Discovery.swift defaultAPIPort
# bind_addr = "0.0.0.0" # default; set to "127.0.0.1" for localhost-only

View File

@@ -37,7 +37,7 @@ cargo update --workspace
git add Cargo.toml Cargo.lock
git commit -m "chore: bump version to $VERSION"
git tag "$TAG"
git push origin main --tags
git push origin main "$TAG"
echo
echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release."

View File

@@ -288,6 +288,7 @@ body {
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
/* Sidebar panels */
.sidebar {
@@ -787,6 +788,13 @@ function formatTime(epoch) {
return d.toLocaleTimeString([], { hour12: false });
}
function shortSrc(addr) {
if (!addr) return '';
const ip = addr.replace(/:\d+$/, '');
if (ip === '127.0.0.1' || ip === '::1') return 'localhost';
return ip;
}
function formatRemaining(secs) {
if (secs == null) return 'permanent';
if (secs < 60) return `${secs}s left`;
@@ -912,8 +920,8 @@ function applyLogFilter() {
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
: '';
return `
<tr>
<td>${formatTime(e.timestamp_epoch)}</td>
<tr title="Source: ${e.src || 'unknown'}">
<td>${formatTime(e.timestamp_epoch)}<br><span class="src-tag">${shortSrc(e.src)}</span></td>
<td>${e.query_type}</td>
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
<td><span class="path-tag ${e.path}">${e.path}</span></td>

View File

@@ -592,8 +592,19 @@ async fn flush_cache_domain(
StatusCode::NO_CONTENT
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
/// Enriched `/health` handler shared between the main API and the mobile API.
///
/// Returns the cached `HealthMeta` assembled with live fields (LAN IP,
/// uptime). Backward compatible with the previous minimal response in
/// that `status` is still the first field and `"ok"` is still the value.
/// The iOS companion app's `HealthInfo` Swift struct decodes the full
/// response; any HTTP client asserting only on `"status"` keeps working.
pub async fn health(State(ctx): State<Arc<ServerCtx>>) -> Json<crate::health::HealthResponse> {
let lan_ip = Some(*ctx.lan_ip.lock().unwrap());
Json(crate::health::HealthResponse::build(
&ctx.health_meta,
lan_ip,
))
}
// --- Blocking handlers ---
@@ -905,12 +916,8 @@ async fn remove_route(
}
}
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME);
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|_| StatusCode::NOT_FOUND)?;
pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
Ok((
[
(header::CONTENT_TYPE, "application/x-pem-file"),
@@ -920,7 +927,7 @@ async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse
),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
bytes,
pem.to_string(),
))
}
@@ -996,6 +1003,8 @@ mod tests {
inflight: Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
})
}

View File

@@ -84,6 +84,11 @@ impl BytePacketBuffer {
/// Read a qname, handling label compression (pointer jumps).
/// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com".
///
/// Label bytes are escaped per RFC 1035 §5.1:
/// - literal `.` within a label → `\.`
/// - literal `\` → `\\`
/// - bytes outside `0x21..=0x7E` (excluding `.` and `\`) → `\DDD` (3-digit decimal)
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
let mut pos = self.pos();
let mut jumped = false;
@@ -121,7 +126,18 @@ impl BytePacketBuffer {
let str_buffer = self.get_range(pos, len as usize)?;
for &b in str_buffer {
outstr.push(b.to_ascii_lowercase() as char);
let c = b.to_ascii_lowercase();
match c {
b'.' => outstr.push_str("\\."),
b'\\' => outstr.push_str("\\\\"),
0x21..=0x7E => outstr.push(c as char),
_ => {
outstr.push('\\');
outstr.push((b'0' + c / 100) as char);
outstr.push((b'0' + (c / 10) % 10) as char);
outstr.push((b'0' + c % 10) as char);
}
}
}
delim = ".";
@@ -163,24 +179,68 @@ impl BytePacketBuffer {
Ok(())
}
/// Write a qname in wire format, parsing RFC 1035 §5.1 text escapes.
/// See `read_qname` for the escape grammar.
pub fn write_qname(&mut self, qname: &str) -> Result<()> {
if qname.is_empty() || qname == "." {
self.write_u8(0)?;
return Ok(());
}
for label in qname.split('.') {
let len = label.len();
if len == 0 {
continue; // skip empty labels from trailing dot
}
if len > 0x3f {
return Err("Single label exceeds 63 characters of length".into());
let bytes = qname.as_bytes();
let mut i = 0;
while i < bytes.len() {
let len_pos = self.pos;
self.write_u8(0)?; // placeholder length byte, backpatched below
let body_start = self.pos;
while i < bytes.len() && bytes[i] != b'.' {
let b = bytes[i];
if b == b'\\' {
i += 1;
let c1 = *bytes.get(i).ok_or("trailing backslash in qname")?;
if c1.is_ascii_digit() {
let c2 = *bytes
.get(i + 1)
.ok_or("invalid \\DDD escape: expected 3 digits")?;
let c3 = *bytes
.get(i + 2)
.ok_or("invalid \\DDD escape: expected 3 digits")?;
if !c2.is_ascii_digit() || !c3.is_ascii_digit() {
return Err("invalid \\DDD escape: expected 3 digits".into());
}
let val =
(c1 - b'0') as u16 * 100 + (c2 - b'0') as u16 * 10 + (c3 - b'0') as u16;
if val > 255 {
return Err(format!("\\DDD escape out of range: {}", val).into());
}
self.write_u8(val as u8)?;
i += 3;
} else {
// \. \\ and any other \X → literal next byte
self.write_u8(c1)?;
i += 1;
}
} else {
self.write_u8(b)?;
i += 1;
}
if self.pos - body_start > 0x3f {
return Err("Single label exceeds 63 characters of length".into());
}
}
self.write_u8(len as u8)?;
for b in label.as_bytes() {
self.write_u8(*b)?;
let label_len = self.pos - body_start;
if label_len == 0 && i < bytes.len() {
// Empty label from leading/consecutive dots — roll back the placeholder.
self.pos = len_pos;
} else {
self.set(len_pos, label_len as u8)?;
}
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
}
}
@@ -212,3 +272,160 @@ impl BytePacketBuffer {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(wire: &[u8]) -> String {
let mut buf = BytePacketBuffer::from_bytes(wire);
let mut out = String::new();
buf.read_qname(&mut out).unwrap();
out
}
fn write_then_read(text: &str) -> String {
let mut buf = BytePacketBuffer::new();
buf.write_qname(text).unwrap();
let wire_end = buf.pos();
buf.seek(0).unwrap();
let mut out = String::new();
buf.read_qname(&mut out).unwrap();
assert_eq!(
buf.pos(),
wire_end,
"reader should consume exactly what writer wrote"
);
out
}
#[test]
fn read_plain_domain() {
// [3]www[6]google[3]com[0]
let wire = b"\x03www\x06google\x03com\x00";
assert_eq!(roundtrip(wire), "www.google.com");
}
#[test]
fn read_label_with_literal_dot_is_escaped() {
// fanf2's example: [8]exa.mple[3]com[0] — two labels, first contains 0x2E
let wire = b"\x08exa.mple\x03com\x00";
assert_eq!(roundtrip(wire), "exa\\.mple.com");
}
#[test]
fn read_label_with_backslash_is_escaped() {
// [4]a\bc[3]com[0]
let wire = b"\x04a\\bc\x03com\x00";
assert_eq!(roundtrip(wire), "a\\\\bc.com");
}
#[test]
fn read_label_with_nonprintable_byte_uses_decimal_escape() {
// [4]\x00foo[3]com[0] — null byte at label start
let wire = b"\x04\x00foo\x03com\x00";
assert_eq!(roundtrip(wire), "\\000foo.com");
}
#[test]
fn read_label_with_space_uses_decimal_escape() {
// Space (0x20) is outside 0x21..=0x7E, so it must be decimal-escaped.
let wire = b"\x05a b c\x00";
assert_eq!(roundtrip(wire), "a\\032b\\032c");
}
#[test]
fn write_plain_domain() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("www.google.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03www\x06google\x03com\x00");
}
#[test]
fn write_escaped_dot_does_not_split_label() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("exa\\.mple.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x08exa.mple\x03com\x00");
}
#[test]
fn write_escaped_backslash() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("a\\\\bc.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x04a\\bc\x03com\x00");
}
#[test]
fn write_decimal_escape_yields_raw_byte() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("\\000foo.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x04\x00foo\x03com\x00");
}
#[test]
fn write_skips_empty_labels() {
// Leading dot — first (empty) label is rolled back.
let mut buf = BytePacketBuffer::new();
buf.write_qname(".foo.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
// Consecutive dots — middle empty label is rolled back.
let mut buf = BytePacketBuffer::new();
buf.write_qname("foo..com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
}
#[test]
fn write_rejects_out_of_range_decimal_escape() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("\\999foo.com").is_err());
}
#[test]
fn write_rejects_trailing_backslash() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("foo\\").is_err());
}
#[test]
fn write_rejects_short_decimal_escape() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("\\1").is_err());
}
#[test]
fn write_rejects_label_over_63_bytes() {
// 64 bytes exceeds the wire-format label cap.
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname(&"a".repeat(64)).is_err());
// 63 bytes is the maximum permitted label length.
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname(&"a".repeat(63)).is_ok());
}
#[test]
fn roundtrip_preserves_dot_in_label() {
assert_eq!(write_then_read("exa\\.mple.com"), "exa\\.mple.com");
}
#[test]
fn roundtrip_preserves_backslash_in_label() {
assert_eq!(write_then_read("a\\\\b.com"), "a\\\\b.com");
}
#[test]
fn roundtrip_preserves_nonprintable_byte() {
assert_eq!(write_then_read("\\000foo.com"), "\\000foo.com");
}
#[test]
fn root_name_empty_and_dot_both_produce_single_zero() {
let mut a = BytePacketBuffer::new();
a.write_qname("").unwrap();
let mut b = BytePacketBuffer::new();
b.write_qname(".").unwrap();
assert_eq!(&a.buf[..a.pos], b"\x00");
assert_eq!(&b.buf[..b.pos], b"\x00");
}
}

View File

@@ -31,6 +31,8 @@ pub struct Config {
pub dnssec: DnssecConfig,
#[serde(default)]
pub dot: DotConfig,
#[serde(default)]
pub mobile: MobileConfig,
}
#[derive(Deserialize)]
@@ -412,6 +414,53 @@ fn default_dot_bind_addr() -> String {
"0.0.0.0".to_string()
}
/// Configuration for the mobile API — a persistent HTTP listener that
/// serves a read-only subset of routes (`/health`, `/ca.pem`,
/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for
/// consumption by the iOS/Android companion apps.
///
/// Unlike the main API (port 5380, localhost-only by default, supports
/// state-mutating routes), the mobile API is safe to expose on the LAN
/// because every route is idempotent and read-only.
#[derive(Deserialize, Clone)]
pub struct MobileConfig {
/// If true, spawn the mobile API listener at startup. **Default false.**
/// Opt-in because the listener binds to the LAN by default and exposes
/// a few read-only endpoints to any device on the same network (`/health`,
/// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are
/// cryptographically sensitive (the CA private key is never served),
/// but users should enable this explicitly rather than have a new
/// LAN-reachable port appear after an upgrade.
#[serde(default)]
pub enabled: bool,
/// Port for the mobile API. Default 8765.
#[serde(default = "default_mobile_port")]
pub port: u16,
/// Bind address for the mobile API. Default "0.0.0.0" (all interfaces)
/// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict
/// to localhost — useful if you're running behind another front-end.
#[serde(default = "default_mobile_bind_addr")]
pub bind_addr: String,
}
impl Default for MobileConfig {
fn default() -> Self {
MobileConfig {
enabled: false,
port: default_mobile_port(),
bind_addr: default_mobile_bind_addr(),
}
}
}
fn default_mobile_port() -> u16 {
8765
}
fn default_mobile_bind_addr() -> String {
"0.0.0.0".to_string()
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -18,6 +18,7 @@ use crate::cache::{DnsCache, DnssecStatus};
use crate::config::{UpstreamMode, ZoneMap};
use crate::forward::{forward_query, Upstream};
use crate::header::ResultCode;
use crate::health::HealthMeta;
use crate::lan::PeerStore;
use crate::override_store::OverrideStore;
use crate::packet::DnsPacket;
@@ -60,6 +61,15 @@ pub struct ServerCtx {
pub inflight: Mutex<InflightMap>,
pub dnssec_enabled: bool,
pub dnssec_strict: bool,
/// Cached health metadata (version, hostname, DoT config, CA
/// fingerprint, features). Shared between the main and mobile
/// API `/health` handlers. Built once at startup in `main.rs`.
pub health_meta: HealthMeta,
/// CA certificate in PEM form, cached at startup. `None` if no
/// TLS-using feature is enabled and the CA hasn't been generated.
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
/// handlers to avoid per-request disk I/O on the hot path.
pub ca_pem: Option<String>,
}
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,

View File

@@ -5,6 +5,7 @@ use log::{debug, trace};
use ring::digest;
use ring::signature;
use crate::buffer::BytePacketBuffer;
use crate::cache::{DnsCache, DnssecStatus};
use crate::packet::DnsPacket;
use crate::question::QueryType;
@@ -720,22 +721,29 @@ pub fn verify_ds(ds: &DnsRecord, dnskey: &DnsRecord, owner: &str) -> bool {
// -- Canonical wire format --
/// Encode a DNS name in canonical wire form per RFC 4034 §6.2:
/// uncompressed, with ASCII letters lowercased.
///
/// Lowercasing happens *after* escape resolution because `\065` yields
/// `'A'`, which canonical form must convert to `'a'`.
pub fn name_to_wire(name: &str) -> Vec<u8> {
let mut wire = Vec::with_capacity(name.len() + 2);
if name == "." || name.is_empty() {
wire.push(0);
return wire;
}
for label in name.split('.') {
if label.is_empty() {
continue;
}
wire.push(label.len() as u8);
for &b in label.as_bytes() {
wire.push(b.to_ascii_lowercase());
let mut buf = BytePacketBuffer::new();
buf.write_qname(name)
.expect("name_to_wire: input must parse as a valid DNS name");
let mut wire = buf.filled().to_vec();
let mut i = 0;
while i < wire.len() {
let label_len = wire[i] as usize;
if label_len == 0 {
break;
}
i += 1;
let end = i + label_len;
wire[i..end].make_ascii_lowercase();
i = end;
}
wire.push(0);
wire
}
@@ -1475,6 +1483,23 @@ mod tests {
);
}
#[test]
fn name_to_wire_escaped_dot_in_label_is_not_a_separator() {
// `exa\.mple.com` is two labels: `exa.mple` (8 bytes including the 0x2E) and `com`.
let wire = name_to_wire("exa\\.mple.com");
assert_eq!(
wire,
vec![8, b'e', b'x', b'a', b'.', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0]
);
}
#[test]
fn name_to_wire_decimal_escape_is_lowercased() {
// \065 = 'A', must become 'a' in canonical form.
let wire = name_to_wire("\\065bc.com");
assert_eq!(wire, vec![3, b'a', b'b', b'c', 3, b'c', b'o', b'm', 0]);
}
#[test]
fn parent_zone_cases() {
assert_eq!(parent_zone("example.com"), "com");

View File

@@ -381,6 +381,8 @@ mod tests {
inflight: Mutex::new(HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();

254
src/health.rs Normal file
View File

@@ -0,0 +1,254 @@
//! Health metadata and `/health` response shape, shared between the main
//! HTTP API and the mobile API.
//!
//! The static fields (version, hostname, DoT config, CA fingerprint,
//! feature list) are computed once at startup and stored in [`HealthMeta`]
//! on `ServerCtx`. Per-request fields (uptime, LAN IP) are computed live.
//! Both handlers call [`HealthResponse::build`] to assemble the JSON
//! response from `HealthMeta` + live inputs.
//!
//! JSON schema is documented in `docs/implementation/ios-companion-app.md`
//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical
//! consumer; any change to this response must keep that struct decoding
//! cleanly (all consumed fields are optional on the Swift side, but
//! `lan_ip` is load-bearing for the pipeline).
use std::net::Ipv4Addr;
use std::path::Path;
use std::time::Instant;
use ring::digest::{digest, SHA256};
use serde::Serialize;
/// Immutable health metadata cached on `ServerCtx`. Built once at startup
/// from config + file-system state (CA cert).
#[derive(Clone)]
pub struct HealthMeta {
pub version: &'static str,
pub hostname: String,
pub sni: String,
pub dot_enabled: bool,
pub dot_port: u16,
pub api_port: u16,
pub ca_fingerprint_sha256: Option<String>,
pub features: Vec<String>,
pub started_at: Instant,
}
impl HealthMeta {
/// Minimal `HealthMeta` for unit tests that construct a `ServerCtx`
/// without needing the real startup flow (CA file reads, hostname
/// detection, etc.). Deterministic values so test JSON assertions
/// stay stable.
#[cfg(test)]
pub fn test_fixture() -> Self {
HealthMeta {
version: env!("CARGO_PKG_VERSION"),
hostname: "test-host".to_string(),
sni: "numa.numa".to_string(),
dot_enabled: false,
dot_port: 853,
api_port: 8765,
ca_fingerprint_sha256: None,
features: vec![],
started_at: Instant::now(),
}
}
/// Build a new HealthMeta from config + startup-time environment.
/// Call once at server boot; the returned value is cheap to clone
/// (small number of short strings) and lives on `ServerCtx`.
///
/// The argument count is deliberate — each flag corresponds to a
/// specific config value and is clearly named at the call site.
/// Collapsing into a struct hides nothing meaningful for a one-call
/// initializer.
#[allow(clippy::too_many_arguments)]
pub fn build(
data_dir: &Path,
dot_enabled: bool,
dot_port: u16,
api_port: u16,
dnssec_enabled: bool,
recursive_enabled: bool,
mdns_enabled: bool,
blocking_enabled: bool,
) -> Self {
let ca_path = data_dir.join("ca.pem");
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
let mut features = Vec::new();
if dot_enabled {
features.push("dot".to_string());
}
if recursive_enabled {
features.push("recursive".to_string());
}
if blocking_enabled {
features.push("blocking".to_string());
}
if mdns_enabled {
features.push("mdns".to_string());
}
if dnssec_enabled {
features.push("dnssec".to_string());
}
HealthMeta {
version: env!("CARGO_PKG_VERSION"),
hostname: crate::hostname(),
sni: "numa.numa".to_string(),
dot_enabled,
dot_port,
api_port,
ca_fingerprint_sha256,
features,
started_at: Instant::now(),
}
}
}
/// JSON response shape returned by `GET /health` on both main and mobile APIs.
///
/// Fields are organized to match the iOS companion app's
/// `HealthInfo` Swift struct — see `ios-companion-app.md` §4.2.
#[derive(Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub version: &'static str,
pub uptime_secs: u64,
pub hostname: String,
pub lan_ip: Option<String>,
pub sni: String,
pub dot: DotBlock,
pub api: ApiBlock,
pub ca: CaBlock,
pub features: Vec<String>,
}
#[derive(Serialize)]
pub struct DotBlock {
pub enabled: bool,
pub port: Option<u16>,
}
#[derive(Serialize)]
pub struct ApiBlock {
pub port: u16,
}
#[derive(Serialize)]
pub struct CaBlock {
pub present: bool,
pub fingerprint_sha256: Option<String>,
}
impl HealthResponse {
/// Assemble a fresh `HealthResponse` from the cached metadata and
/// the current LAN IP (which may change across network transitions).
/// Pass `None` for `lan_ip` if detection fails — the response still
/// returns 200 OK, just without the LAN address.
pub fn build(meta: &HealthMeta, lan_ip: Option<Ipv4Addr>) -> Self {
HealthResponse {
status: "ok",
version: meta.version,
uptime_secs: meta.started_at.elapsed().as_secs(),
hostname: meta.hostname.clone(),
lan_ip: lan_ip.map(|ip| ip.to_string()),
sni: meta.sni.clone(),
dot: DotBlock {
enabled: meta.dot_enabled,
port: if meta.dot_enabled {
Some(meta.dot_port)
} else {
None
},
},
api: ApiBlock {
port: meta.api_port,
},
ca: CaBlock {
present: meta.ca_fingerprint_sha256.is_some(),
fingerprint_sha256: meta.ca_fingerprint_sha256.clone(),
},
features: meta.features.clone(),
}
}
}
/// Read the CA cert at `ca_path` and return its SHA-256 fingerprint as a
/// lowercase hex string, or None if the file doesn't exist or can't be read.
///
/// Hashes the raw PEM bytes for simplicity. A more canonical SPKI-based
/// fingerprint would require parsing the PEM → DER → extracting
/// SubjectPublicKeyInfo, which adds complexity without meaningful benefit
/// for our use case (the iOS app uses the fingerprint only for display
/// and to detect rotation).
fn compute_ca_fingerprint(ca_path: &Path) -> Option<String> {
let pem = std::fs::read(ca_path).ok()?;
let hash = digest(&SHA256, &pem);
let hex: String = hash.as_ref().iter().map(|b| format!("{:02x}", b)).collect();
Some(hex)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_response_contains_required_fields() {
let meta = HealthMeta {
version: "0.10.0",
hostname: "test-host".to_string(),
sni: "numa.numa".to_string(),
dot_enabled: true,
dot_port: 853,
api_port: 8765,
ca_fingerprint_sha256: Some("abcd1234".to_string()),
features: vec!["dot".to_string(), "dnssec".to_string()],
started_at: Instant::now(),
};
let response = HealthResponse::build(&meta, Some(Ipv4Addr::new(192, 168, 1, 50)));
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"status\":\"ok\""));
assert!(json.contains("\"version\":\"0.10.0\""));
assert!(json.contains("\"hostname\":\"test-host\""));
assert!(json.contains("\"lan_ip\":\"192.168.1.50\""));
assert!(json.contains("\"sni\":\"numa.numa\""));
assert!(json.contains("\"port\":853"));
assert!(json.contains("\"port\":8765"));
assert!(json.contains("\"fingerprint_sha256\":\"abcd1234\""));
assert!(json.contains("\"features\":[\"dot\",\"dnssec\"]"));
}
#[test]
fn health_response_omits_dot_port_when_disabled() {
let meta = HealthMeta {
version: "0.10.0",
hostname: "t".to_string(),
sni: "numa.numa".to_string(),
dot_enabled: false,
dot_port: 853,
api_port: 8765,
ca_fingerprint_sha256: None,
features: vec![],
started_at: Instant::now(),
};
let response = HealthResponse::build(&meta, None);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"enabled\":false"));
assert!(json.contains("\"dot\":{\"enabled\":false,\"port\":null}"));
assert!(json.contains("\"present\":false"));
assert!(json.contains("\"lan_ip\":null"));
}
#[test]
fn ca_fingerprint_returns_none_for_missing_file() {
let fp = compute_ca_fingerprint(Path::new("/nonexistent/ca.pem"));
assert!(fp.is_none());
}
}

View File

@@ -9,6 +9,7 @@ use crate::buffer::BytePacketBuffer;
use crate::config::LanConfig;
use crate::ctx::ServerCtx;
use crate::header::DnsHeader;
use crate::health::HealthMeta;
use crate::question::{DnsQuestion, QueryType};
// --- Constants ---
@@ -18,6 +19,18 @@ const MDNS_PORT: u16 = 5353;
const SERVICE_TYPE: &str = "_numa._tcp.local";
const MDNS_TTL: u32 = 120;
// TXT record key prefixes (including the trailing `=`). Shared between
// the sender (`build_announcement`) and the receiver (`parse_mdns_response`)
// to prevent drift — both sides match on the same literal, not on two
// independent string constants that could diverge.
const TXT_SERVICES: &str = "services=";
const TXT_ID: &str = "id=";
const TXT_VERSION: &str = "version=";
const TXT_API_PORT: &str = "api_port=";
const TXT_PROTO: &str = "proto=";
const TXT_DOT_PORT: &str = "dot_port=";
const TXT_CA_FP: &str = "ca_fp=";
// --- Peer Store ---
pub struct PeerStore {
@@ -97,14 +110,16 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
}
}
/// Short hostname for mDNS instance names (`<short>._numa._tcp.local`).
/// Truncates at the first `.` so `macbook-pro.local` becomes `macbook-pro`.
/// Uses the shared `crate::hostname()` helper as the source.
fn get_hostname() -> String {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|h| h.trim().split('.').next().unwrap_or("numa").to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "numa".to_string())
crate::hostname()
.split('.')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("numa")
.to_string()
}
/// Generate a per-process instance ID for self-filtering on multi-instance hosts
@@ -168,13 +183,22 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
.map(|e| (e.name.clone(), e.target_port))
.collect()
};
if services.is_empty() {
continue;
}
// Note: we always announce ourselves, even when the
// services list is empty. The announcement still carries
// the mobile API port + version + CA fingerprint in TXT,
// which is what the iOS companion app browses for via
// NWBrowser on `_numa._tcp.local`. Other Numa peers
// receive these empty-services announcements too and
// correctly ignore them in parse_mdns_response (the
// receiver only processes when services is non-empty).
let current_ip = *sender_ctx.lan_ip.lock().unwrap();
if let Ok(pkt) =
build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id)
{
if let Ok(pkt) = build_announcement(
&sender_hostname,
current_ip,
&services,
&sender_instance_id,
&sender_ctx.health_meta,
) {
let _ = sender_socket.send_to(pkt.filled(), dest).await;
}
}
@@ -240,6 +264,7 @@ fn build_announcement(
ip: Ipv4Addr,
services: &[(String, u16)],
inst_id: &str,
meta: &HealthMeta,
) -> crate::Result<BytePacketBuffer> {
let mut buf = BytePacketBuffer::new();
let instance_name = format!("{}._numa._tcp.local", hostname);
@@ -260,7 +285,11 @@ fn build_announcement(
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// SRV: <instance>._numa._tcp.local → <hostname>.local
// Port in SRV is informational; actual service ports are in TXT
// Port = mobile API port, which is what the iOS companion app resolves
// the SRV record for. Legacy Numa peers don't read the SRV port (see
// parse_mdns_response — it only uses TXT services= for peer discovery),
// so changing the SRV port from "first service's port" to the mobile
// API port is backwards compatible.
write_record_header(
&mut buf,
&instance_name,
@@ -273,11 +302,13 @@ fn build_announcement(
let rdata_start = buf.pos();
buf.write_u16(0)?; // priority
buf.write_u16(0)?; // weight
buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display
buf.write_u16(meta.api_port)?; // mobile API port, for iOS companion app
buf.write_qname(&host_local)?;
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// TXT: services + instance ID for self-filtering
// TXT: legacy peer-discovery entries (services, id) + enriched entries
// for the iOS companion app (version, api_port, proto, dot_port, ca_fp).
// All in one TXT RRset per mDNS convention.
write_record_header(
&mut buf,
&instance_name,
@@ -293,8 +324,21 @@ fn build_announcement(
.map(|(name, port)| format!("{}:{}", name, port))
.collect::<Vec<_>>()
.join(",");
write_txt_string(&mut buf, &format!("services={}", svc_str))?;
write_txt_string(&mut buf, &format!("id={}", inst_id))?;
// Legacy peer-discovery entries (consumed by parse_mdns_response)
write_txt_string(&mut buf, &format!("{}{}", TXT_SERVICES, svc_str))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_ID, inst_id))?;
// Enriched entries (consumed by the iOS/Android companion apps)
write_txt_string(&mut buf, &format!("{}{}", TXT_VERSION, meta.version))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_API_PORT, meta.api_port))?;
if meta.dot_enabled {
write_txt_string(&mut buf, &format!("{}dot", TXT_PROTO))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_DOT_PORT, meta.dot_port))?;
} else {
write_txt_string(&mut buf, &format!("{}plain", TXT_PROTO))?;
}
if let Some(fp) = &meta.ca_fingerprint_sha256 {
write_txt_string(&mut buf, &format!("{}{}", TXT_CA_FP, fp))?;
}
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// A: <hostname>.local → IP
@@ -408,7 +452,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
break;
}
if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) {
if let Some(val) = txt.strip_prefix("services=") {
if let Some(val) = txt.strip_prefix(TXT_SERVICES) {
let svcs: Vec<(String, u16)> = val
.split(',')
.filter_map(|s| {
@@ -421,7 +465,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
if !svcs.is_empty() {
txt_services = Some(svcs);
}
} else if let Some(id) = txt.strip_prefix("id=") {
} else if let Some(id) = txt.strip_prefix(TXT_ID) {
peer_instance_id = Some(id.to_string());
}
}

View File

@@ -8,7 +8,10 @@ pub mod dnssec;
pub mod dot;
pub mod forward;
pub mod header;
pub mod health;
pub mod lan;
pub mod mobile_api;
pub mod mobileconfig;
pub mod override_store;
pub mod packet;
pub mod proxy;
@@ -17,6 +20,7 @@ pub mod question;
pub mod record;
pub mod recursive;
pub mod service_store;
pub mod setup_phone;
pub mod srtt;
pub mod stats;
pub mod system_dns;
@@ -25,6 +29,20 @@ pub mod tls;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
/// Detect the machine hostname via the `hostname` command. Returns the
/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command
/// fails. Call sites that need the short form (e.g., mDNS instance
/// names) should truncate at the first `.`.
pub fn hostname() -> String {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "numa".to_string())
}
/// Shared config directory for persistent data (services.json, etc).
/// Unix users: ~/.config/numa/
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa

View File

@@ -54,6 +54,9 @@ async fn main() -> numa::Result<()> {
}
};
}
"setup-phone" => {
return numa::setup_phone::run().await.map_err(|e| e.into());
}
"lan" => {
let sub = std::env::args().nth(2).unwrap_or_default();
let config_path = std::env::args()
@@ -85,12 +88,27 @@ async fn main() -> numa::Result<()> {
eprintln!(" service status Check if the service is running");
eprintln!(" lan on Enable LAN service discovery (mDNS)");
eprintln!(" lan off Disable LAN service discovery");
eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone");
eprintln!(" help Show this help");
eprintln!();
eprintln!("Config path defaults to numa.toml");
return Ok(());
}
_ => {}
_ => {
if !arg1.is_empty()
&& arg1 != "run"
&& !arg1.contains('/')
&& !arg1.contains('\\')
&& !arg1.ends_with(".toml")
{
eprintln!(
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
arg1
);
eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands.");
std::process::exit(1);
}
}
}
let config_path = if arg1.is_empty() || arg1 == "run" {
@@ -235,6 +253,19 @@ async fn main() -> numa::Result<()> {
None
};
let health_meta = numa::health::HealthMeta::build(
&resolved_data_dir,
config.dot.enabled,
config.dot.port,
config.mobile.port,
config.dnssec.enabled,
resolved_mode == numa::config::UpstreamMode::Recursive,
config.lan.enabled,
config.blocking.enabled,
);
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
Ok(s) => s,
Err(e) => {
@@ -286,6 +317,8 @@ async fn main() -> numa::Result<()> {
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: config.dnssec.enabled,
dnssec_strict: config.dnssec.strict,
health_meta,
ca_pem,
});
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
@@ -469,6 +502,21 @@ async fn main() -> numa::Result<()> {
axum::serve(listener, app).await.unwrap();
});
// Spawn Mobile API listener (read-only subset for iOS/Android companion
// apps, LAN-bound by default so phones can reach it). Only idempotent
// GETs; no state-mutating routes are exposed here regardless of
// the main API's bind address.
if config.mobile.enabled {
let mobile_ctx = Arc::clone(&ctx);
let mobile_bind = config.mobile.bind_addr.clone();
let mobile_port = config.mobile.port;
tokio::spawn(async move {
if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await {
log::warn!("Mobile API listener failed: {}", e);
}
});
}
let proxy_bind: std::net::Ipv4Addr = config
.proxy
.bind_addr

107
src/mobile_api.rs Normal file
View File

@@ -0,0 +1,107 @@
//! Mobile API — persistent HTTP listener for iOS/Android companion apps.
//!
//! Read-only subset of Numa's HTTP surface served on a separate port
//! (default 8765) bound to the LAN. Unlike the main API on port 5380
//! (which defaults to `127.0.0.1` and serves mutating routes like
//! `DELETE /services/{name}` or `PUT /blocking/toggle`), this listener
//! is safe to expose on the LAN because every route is idempotent and
//! read-only.
//!
//! Routes (all GET):
//!
//! - `/health` — enriched status + metadata, shares the handler with the
//! main API via `crate::api::health`
//! - `/ca.pem` — Numa local CA in PEM form, shares the handler with the
//! main API via `crate::api::serve_ca`
//! - `/mobileconfig` — combined CA + DNS settings profile (Full mode)
//! - `/ca.mobileconfig` — CA-only trust profile (no DNS override)
//!
//! The mobile API does NOT include the mutating routes (overrides, cache
//! flush, blocking toggle, service CRUD, etc.). Even if a user sets
//! `api_bind_addr` to `0.0.0.0` for the main API, those routes stay on
//! port 5380; the mobile API on port 8765 never serves them. This is the
//! primary security boundary: anything exposed to the LAN is read-only.
use std::net::Ipv4Addr;
use std::sync::Arc;
use axum::extract::State;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use log::info;
use crate::ctx::ServerCtx;
use crate::mobileconfig::{build_mobileconfig, ProfileMode};
/// Content-Disposition for the full CA + DNS profile download.
const FULL_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa.mobileconfig\"";
/// Content-Disposition for the CA-only profile download.
const CA_ONLY_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa-ca.mobileconfig\"";
/// Build the axum router for the mobile API.
///
/// Shares handler functions with the main API where possible (`health`,
/// `serve_ca`) so the response shapes are identical across both ports.
pub fn router(ctx: Arc<ServerCtx>) -> Router {
Router::new()
.route("/health", get(crate::api::health))
.route("/ca.pem", get(crate::api::serve_ca))
.route("/mobileconfig", get(serve_full_mobileconfig))
.route("/ca.mobileconfig", get(serve_ca_only_mobileconfig))
.with_state(ctx)
}
/// Start the mobile API listener on `bind_addr:port`. Runs until the
/// caller cancels the spawned task. Logs the URL on successful bind.
pub async fn start(ctx: Arc<ServerCtx>, bind_addr: String, port: u16) -> crate::Result<()> {
let addr: std::net::SocketAddr = format!("{}:{}", bind_addr, port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Mobile API listening on http://{}", addr);
let app = router(ctx);
axum::serve(listener, app).await?;
Ok(())
}
/// Serve the full mobileconfig profile (CA + DNS settings), with the
/// DNS payload pointing at the current LAN IP. Each request reads the
/// fresh LAN IP from `ctx.lan_ip` so the profile always reflects the
/// laptop's current network state.
async fn serve_full_mobileconfig(
State(ctx): State<Arc<ServerCtx>>,
) -> Result<impl IntoResponse, StatusCode> {
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
let lan_ip: Ipv4Addr = *ctx.lan_ip.lock().unwrap();
let profile = build_mobileconfig(ProfileMode::Full { lan_ip }, ca_pem);
Ok(profile_response(profile, FULL_PROFILE_DISPOSITION))
}
/// Serve the CA-only mobileconfig profile. Trusts the Numa local CA but
/// does NOT change the device's DNS settings. Used by the iOS companion
/// app's DoT mode, where the app configures DNS via `NEDNSSettingsManager`
/// and only needs the system trust store to accept Numa's self-signed cert.
async fn serve_ca_only_mobileconfig(
State(ctx): State<Arc<ServerCtx>>,
) -> Result<impl IntoResponse, StatusCode> {
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
let profile = build_mobileconfig(ProfileMode::CaOnly, ca_pem);
Ok(profile_response(profile, CA_ONLY_PROFILE_DISPOSITION))
}
/// Shared response constructor for both mobileconfig variants.
/// Identical headers; only the Content-Disposition filename differs.
fn profile_response(profile: String, disposition: &'static str) -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
(header::CONTENT_DISPOSITION, disposition),
(header::CACHE_CONTROL, "no-store"),
],
profile,
)
}

294
src/mobileconfig.rs Normal file
View File

@@ -0,0 +1,294 @@
//! Apple `.mobileconfig` profile generator.
//!
//! Builds iOS Configuration Profiles that Numa serves to phones for one-tap
//! CA trust and DNS-over-TLS setup. The plist structure is hand-rendered
//! via `format!` — no plist crate dependency, deterministic output, small
//! binary footprint.
//!
//! Two modes:
//!
//! - [`ProfileMode::Full`]: CA trust payload + DNS settings payload pointing
//! at a specific LAN IP over DoT. This is what `numa setup-phone` has
//! always produced — the user scans a QR, installs this profile, and the
//! phone is configured for DoT through Numa in a single step (after the
//! iOS Certificate Trust Settings toggle, which is a separate system
//! gate we can't bypass).
//!
//! - [`ProfileMode::CaOnly`]: CA trust payload only, no DNS settings. Used
//! by the future iOS companion app flow where `NEDNSSettingsManager`
//! configures DNS programmatically and we only need the system trust
//! store to accept Numa's DoT cert. Installing this profile does NOT
//! change the user's DNS at all.
//!
//! Payload identifiers and UUIDs are fixed (not randomized) so iOS replaces
//! the existing profile on re-install rather than accumulating duplicates.
//! The `Full` and `CaOnly` profiles have distinct top-level UUIDs so they
//! can coexist as separate installed profiles, but they share the same CA
//! payload UUID since the CA itself is the same trust anchor in both.
use std::net::Ipv4Addr;
/// Top-level UUID and PayloadIdentifier for the full profile (CA + DNS).
/// Changing this breaks in-place replacement on existing iOS installs.
const FULL_PROFILE_UUID: &str = "F1E2D3C4-B5A6-7890-1234-567890ABCDEF";
const FULL_PROFILE_ID: &str = "com.numa.dns.profile";
/// Top-level UUID and PayloadIdentifier for the CA-only profile.
/// Distinct from `FULL_PROFILE_UUID` so a user can install one, the other,
/// or both without the latest install silently replacing a different mode.
const CA_ONLY_PROFILE_UUID: &str = "F2E3D4C5-B6A7-8901-2345-67890ABCDEF0";
const CA_ONLY_PROFILE_ID: &str = "com.numa.dns.ca.profile";
/// CA trust payload UUID. Same in both modes — iOS will see "the same CA
/// trust anchor" regardless of which wrapping profile contains it.
const CA_PAYLOAD_UUID: &str = "B2C3D4E5-F6A7-8901-BCDE-F12345678901";
const CA_PAYLOAD_ID: &str = "com.numa.dns.ca";
/// DNS settings payload UUID (Full mode only).
const DNS_PAYLOAD_UUID: &str = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890";
const DNS_PAYLOAD_ID: &str = "com.numa.dns.dot";
/// Profile mode determines which payloads are included in the generated
/// `.mobileconfig`.
#[derive(Debug, Clone)]
pub enum ProfileMode {
/// Full profile: CA trust anchor + managed DNS settings payload
/// pointing at the given LAN IP over DoT. This is what the classic
/// `numa setup-phone` QR flow serves.
Full { lan_ip: Ipv4Addr },
/// CA-only profile: just the trust anchor, no DNS settings. For use
/// with the iOS companion app which manages DNS programmatically via
/// `NEDNSSettingsManager` and only needs the system trust store to
/// accept Numa's self-signed DoT cert.
CaOnly,
}
/// Build a full `.mobileconfig` profile as an XML plist string.
pub fn build_mobileconfig(mode: ProfileMode, ca_pem: &str) -> String {
let ca_payload = build_ca_payload(ca_pem);
match mode {
ProfileMode::Full { lan_ip } => {
let dns_payload = build_dns_payload(lan_ip);
let payloads = format!("{}\n{}", ca_payload, dns_payload);
let description = format!(
"Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({lan_ip})"
);
wrap_plist(
&payloads,
FULL_PROFILE_UUID,
FULL_PROFILE_ID,
&description,
"Numa DNS",
)
}
ProfileMode::CaOnly => wrap_plist(
&ca_payload,
CA_ONLY_PROFILE_UUID,
CA_ONLY_PROFILE_ID,
"Trusts the Numa local Certificate Authority. Does not change your DNS settings.",
"Numa CA",
),
}
}
/// Strip the PEM header/footer and newlines from a CA cert, leaving raw
/// base64 for embedding in a plist `<data>` block.
fn pem_to_base64(pem: &str) -> String {
pem.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<String>()
}
/// Wrap the base64 CA cert at 52 chars per line for plist readability
/// (matches Apple convention in hand-written profiles).
fn chunk_base64(base64: &str) -> String {
base64
.chars()
.collect::<Vec<_>>()
.chunks(52)
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
.collect::<Vec<_>>()
.join("\n")
}
/// Render the `com.apple.security.root` payload dict containing the CA cert.
fn build_ca_payload(ca_pem: &str) -> String {
let ca_wrapped = chunk_base64(&pem_to_base64(ca_pem));
format!(
r#" <dict>
<key>PayloadCertificateFileName</key>
<string>numa-ca.pem</string>
<key>PayloadContent</key>
<data>
{ca}
</data>
<key>PayloadDescription</key>
<string>Numa local Certificate Authority — required for DoT trust</string>
<key>PayloadDisplayName</key>
<string>Numa Local CA</string>
<key>PayloadIdentifier</key>
<string>{ca_id}</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>{ca_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>"#,
ca = ca_wrapped,
ca_id = CA_PAYLOAD_ID,
ca_uuid = CA_PAYLOAD_UUID,
)
}
/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode.
/// Pins the device to Numa as its system resolver over DoT with
/// `ServerName = "numa.numa"` (must match the DoT cert SAN).
fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
format!(
r#" <dict>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>TLS</string>
<key>ServerAddresses</key>
<array>
<string>{ip}</string>
</array>
<key>ServerName</key>
<string>numa.numa</string>
</dict>
<key>PayloadDescription</key>
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
<key>PayloadDisplayName</key>
<string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key>
<string>{dns_id}</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>{dns_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>"#,
ip = lan_ip,
dns_id = DNS_PAYLOAD_ID,
dns_uuid = DNS_PAYLOAD_UUID,
)
}
/// Wrap one or more payload dicts in the top-level plist structure
/// with Configuration type, PayloadContent array, and profile metadata.
fn wrap_plist(
payloads: &str,
top_uuid: &str,
top_id: &str,
description: &str,
display_name: &str,
) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
{payloads}
</array>
<key>PayloadDescription</key>
<string>{description}</string>
<key>PayloadDisplayName</key>
<string>{display_name}</string>
<key>PayloadIdentifier</key>
<string>{top_id}</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>{top_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
"#,
payloads = payloads,
description = description,
display_name = display_name,
top_id = top_id,
top_uuid = top_uuid,
)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PEM: &str =
"-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n";
#[test]
fn pem_to_base64_strips_headers() {
let pem = "-----BEGIN CERTIFICATE-----\nABCDEF\nGHIJKL\n-----END CERTIFICATE-----\n";
assert_eq!(pem_to_base64(pem), "ABCDEFGHIJKL");
}
#[test]
fn full_profile_contains_ip_and_ca() {
let config = build_mobileconfig(
ProfileMode::Full {
lan_ip: Ipv4Addr::new(192, 168, 1, 100),
},
SAMPLE_PEM,
);
assert!(config.contains("192.168.1.100"));
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
assert!(config.contains("com.apple.security.root"));
assert!(config.contains("com.apple.dnsSettings.managed"));
assert!(config.contains("DNSProtocol"));
assert!(config.contains(FULL_PROFILE_UUID));
assert!(config.contains(FULL_PROFILE_ID));
}
#[test]
fn ca_only_profile_contains_ca_but_not_dns() {
let config = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
assert!(config.contains("com.apple.security.root"));
assert!(!config.contains("com.apple.dnsSettings.managed"));
assert!(!config.contains("DNSProtocol"));
assert!(!config.contains("ServerAddresses"));
assert!(config.contains(CA_ONLY_PROFILE_UUID));
assert!(config.contains(CA_ONLY_PROFILE_ID));
}
#[test]
fn full_and_ca_only_have_distinct_top_uuids() {
let full = build_mobileconfig(
ProfileMode::Full {
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
},
SAMPLE_PEM,
);
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
assert!(full.contains(FULL_PROFILE_UUID));
assert!(!full.contains(CA_ONLY_PROFILE_UUID));
assert!(ca_only.contains(CA_ONLY_PROFILE_UUID));
assert!(!ca_only.contains(FULL_PROFILE_UUID));
}
#[test]
fn both_modes_share_ca_payload_uuid() {
let full = build_mobileconfig(
ProfileMode::Full {
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
},
SAMPLE_PEM,
);
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
assert!(full.contains(CA_PAYLOAD_UUID));
assert!(ca_only.contains(CA_PAYLOAD_UUID));
}
}

126
src/setup_phone.rs Normal file
View File

@@ -0,0 +1,126 @@
//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API.
//!
//! Before the mobile API existed, this command spawned its own one-shot
//! HTTP server on port 8765 to serve a freshly-generated mobileconfig
//! for a single download. That role now belongs to
//! [`crate::mobile_api`], which runs persistently alongside the main
//! API and serves `/mobileconfig` at the same port whenever Numa is
//! running.
//!
//! This command is now a thin terminal-side wrapper:
//!
//! 1. Detect the current LAN IP
//! 2. Render a terminal QR code pointing at
//! `http://<lan_ip>:8765/mobileconfig`
//! 3. Print install instructions and exit
//!
//! The user scans the QR, iOS fetches the profile from the mobile API
//! (which is always up as long as `numa` is running), installs, and the
//! user walks through Settings → Certificate Trust Settings to enable
//! trust.
//!
//! Numa must be running for the profile download to succeed; if the
//! mobile API is not listening on port 8765, the download will fail
//! and the user will see Safari's "Cannot Connect to Server" error.
//! The CLI prints a reminder about this at the bottom of the output.
use qrcode::render::unicode;
use qrcode::QrCode;
/// Default port where the persistent mobile API serves `/mobileconfig`.
/// Matches `MobileConfig::default().port` in `config.rs`. If the user
/// has overridden `[mobile] port = N` in `numa.toml`, they'll need to
/// adjust the URL manually — this CLI uses the default without parsing
/// `numa.toml`.
const SETUP_PORT: u16 = 8765;
fn render_qr(url: &str) -> Result<String, String> {
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
Ok(code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build())
}
/// Run the `numa setup-phone` flow.
pub async fn run() -> Result<(), String> {
let lan_ip = crate::lan::detect_lan_ip()
.ok_or("could not detect LAN IP — are you connected to a network?")?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT));
let api_reachable = tokio::time::timeout(
std::time::Duration::from_millis(500),
tokio::net::TcpStream::connect(addr),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
if !api_reachable {
eprintln!();
eprintln!(
" \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.",
SETUP_PORT
);
eprintln!();
eprintln!(" The phone won't be able to download the profile until the mobile");
eprintln!(" API is running. Add this to your numa.toml and restart Numa:");
eprintln!();
eprintln!(" [mobile]");
eprintln!(" enabled = true");
eprintln!();
return Err("mobile API not running".into());
}
let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT);
let qr = render_qr(&url)?;
eprintln!();
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
eprintln!();
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
eprintln!();
for line in qr.lines() {
eprintln!(" {}", line);
}
eprintln!();
eprintln!(" \x1b[1mOn your iPhone:\x1b[0m");
eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner");
eprintln!(" 2. Allow the download when Safari asks");
eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top");
eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)");
eprintln!(" 4. Tap Install (top right), enter passcode, Install again");
eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m");
eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work");
eprintln!();
eprintln!(
" \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your",
lan_ip
);
eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the");
eprintln!(" existing profile automatically (fixed UUID).");
eprintln!();
eprintln!(
" \x1b[90mThe profile is served by Numa's persistent mobile API on port {}.\x1b[0m",
SETUP_PORT
);
eprintln!(" \x1b[90mMake sure `numa` is running before scanning. If it's not,\x1b[0m");
eprintln!(" \x1b[90mstart it with `sudo numa install` or run it interactively.\x1b[0m");
eprintln!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_qr_produces_unicode() {
let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap();
assert!(!qr.is_empty());
// Dense1x2 uses these block characters
assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' ')));
}
}

View File

@@ -2,6 +2,17 @@ use std::net::SocketAddr;
use log::info;
fn print_recursive_hint() {
let is_recursive = crate::config::load_config("numa.toml")
.map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive)
.unwrap_or(false);
if !is_recursive {
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
}
}
fn is_loopback_or_stub(addr: &str) -> bool {
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
}
@@ -688,9 +699,7 @@ fn install_windows() -> Result<(), String> {
} else {
eprintln!(" Numa will start automatically on next boot.\n");
}
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
print_recursive_hint();
Ok(())
}
@@ -1181,9 +1190,7 @@ fn install_service_macos() -> Result<(), String> {
eprintln!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: /usr/local/var/log/numa.log");
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
print_recursive_hint();
Ok(())
}
@@ -1388,9 +1395,7 @@ fn install_service_linux() -> Result<(), String> {
eprintln!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: journalctl -u numa -f");
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
print_recursive_hint();
Ok(())
}

View File

@@ -5,7 +5,9 @@ use std::sync::Arc;
use log::{info, warn};
use crate::ctx::ServerCtx;
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::ServerConfig;
use time::{Duration, OffsetDateTime};
@@ -87,8 +89,8 @@ pub fn build_tls_config(
alpn: Vec<Vec<u8>>,
data_dir: &Path,
) -> crate::Result<Arc<ServerConfig>> {
let (ca_cert, ca_key) = ensure_ca(data_dir)?;
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;
let (ca_der, issuer) = ensure_ca(data_dir)?;
let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, tld, service_names)?;
// Ensure a crypto provider is installed (rustls needs one)
let _ = rustls::crypto::ring::default_provider().install_default();
@@ -106,7 +108,7 @@ pub fn build_tls_config(
Ok(Arc::new(config))
}
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'static, KeyPair>)> {
let ca_key_path = dir.join("ca.key");
let ca_cert_path = dir.join(CA_FILE_NAME);
@@ -114,10 +116,12 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
let key_pem = std::fs::read_to_string(&ca_key_path)?;
let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
let key_pair = KeyPair::from_pem(&key_pem)?;
let params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
let cert = params.self_signed(&key_pair)?;
let ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
.next()
.ok_or("empty CA PEM file")??;
let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?;
info!("loaded CA from {:?}", ca_cert_path);
return Ok((cert, key_pair));
return Ok((ca_der, issuer));
}
// Generate new CA
@@ -145,14 +149,16 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
}
info!("generated CA at {:?}", ca_cert_path);
Ok((cert, key_pair))
let ca_der = cert.der().clone();
let issuer = Issuer::new(params, key_pair);
Ok((ca_der, issuer))
}
/// Generate a cert with explicit SANs for each service name.
/// Always regenerated at startup (~5ms) — no disk caching needed.
fn generate_service_cert(
ca_cert: &rcgen::Certificate,
ca_key: &KeyPair,
ca_der: &CertificateDer<'static>,
issuer: &Issuer<'_, KeyPair>,
tld: &str,
service_names: &[String],
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
@@ -187,7 +193,7 @@ fn generate_service_cert(
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
let cert = params.signed_by(&key_pair, ca_cert, ca_key)?;
let cert = params.signed_by(&key_pair, issuer)?;
info!(
"generated TLS cert for: {}",
@@ -198,11 +204,11 @@ fn generate_service_cert(
.join(", ")
);
let cert_der = CertificateDer::from(cert.der().to_vec());
let ca_der = CertificateDer::from(ca_cert.der().to_vec());
let cert_der = cert.der().clone();
let ca_cert_der = ca_der.clone();
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
Ok((vec![cert_der, ca_der], key_der))
Ok((vec![cert_der, ca_cert_der], key_der))
}
#[cfg(test)]