27 Commits

Author SHA1 Message Date
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
Razvan Dimescu
1f6bdff8f8 chore: bump version to 0.10.2 2026-04-09 22:59:10 +03:00
Razvan Dimescu
643d6b01e1 fix(linux): consult resolvectl when resolv.conf only shows the stub (#52)
On modern Arch / Ubuntu 22.04+ / Fedora desktops, NetworkManager +
systemd-resolved symlink /etc/resolv.conf to stub-resolv.conf, which
contains only:

  nameserver 127.0.0.53

The real upstream servers (router, ISP, configured DoT providers) live
inside systemd-resolved's per-link state, exposed via 'resolvectl status'.

discover_linux() was parsing /etc/resolv.conf, correctly filtering the
stub address, and then falling through to the Quad9 DoH fallback because
detect_dhcp_dns() is macOS-only on Linux. Net effect: on a large chunk of
Linux installs, numa silently defaulted to Quad9 instead of the user's
actual DNS — visible in Casey's AUR test banner (#33) as
'Upstream https://9.9.9.9/dns-query' despite his machine having working
router DNS the entire time.

resolvectl_dns_server() already exists — it was introduced for cloud VPC
forwarding-rule discovery and knows how to ask systemd-resolved for the
real active DNS server. This commit wires it into the default-upstream
fallback chain, between the primary resolv.conf parse and the
~/.numa/original-resolv.conf backup.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:32:57 +03:00
Razvan Dimescu
17c8e70aa3 fix(ci): skip prepare() in publish-aur metadata container (#51)
Follow-up to #49 and #50. With ownership and quoting fixed, the next run
([24199871832](https://github.com/razvandimescu/numa/actions/runs/24199871832))
reached makepkg and failed with:

  /pkg/PKGBUILD: line 34: cargo: command not found
  ==> ERROR: A failure occurred in prepare().

The publish job only installs 'binutils git sudo' since its sole purpose
is to regenerate .SRCINFO. 'makepkg -od' still runs prepare(), which
calls cargo. The sibling validate job avoids this by passing --noprepare
(and installs rust anyway).

Mirror that pattern: add --noprepare to the metadata-generation invocation.
pkgver() runs before prepare() in makepkg's pipeline, so .SRCINFO still
captures the computed version. Keeps the container minimal (no rust toolchain).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:39:28 +03:00
Razvan Dimescu
389ac09907 fix(ci): repair broken quoting in publish-aur docker heredoc (#50)
The docker block runs as '/bin/bash -c "<multi-line script>"'. A comment
inside the script contained embedded double quotes:

  # "makepkg -od" fetches the source first so pkgver() can calculate the version.

The first embedded '"' prematurely closes the outer string. Bash then
parses the remainder into a second argument to 'bash -c' which becomes
$0 inside the container and is silently discarded. Net effect: the
in-container script stops at 'git config --add safe.directory', neither
'makepkg -od' nor 'makepkg --printsrcinfo > .SRCINFO' ever run, and the
host-side 'git add PKGBUILD .SRCINFO' fails with:

  fatal: pathspec '.SRCINFO' did not match any files

This bug was masked by the earlier ownership bug fixed in #49 — once
that permission error was removed, this one surfaced.

Fix: drop the embedded double quotes from the comment.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:55:03 +03:00
Razvan Dimescu
5308e9648c fix(ci): reclaim aur-repo ownership after docker chown (#49)
The 'Push to AUR' step failed on run 24195384571 with:
  error: could not lock config file .git/config: Permission denied

Inside the docker block we 'chown -R builduser:builduser /pkg', which
propagates through the bind mount and transfers ownership of aur-repo/
(including .git/) to the container's builduser UID. When control returns
to the runner user, 'git config user.name' can no longer write .git/config
and the step exits 255.

Chown the directory back to the runner's UID/GID before resuming host-side
git operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:24:30 +03:00
Casey Labs
819614fa7d [Feature] Add GitHub Action Workflow for Arch Linux AUR Package publishing (#33)
* Feature: add GitHub Actions workflow for publishing Arch Linux AUR package

* Fix issues in Arch Linux AUR publishing process

* Add patch to fix default Arch Linux binary path location issues

* fix: PKGBUILD compatibility with numa v0.10.1, fix QEMU action SHA pin

Three small bug fixes that make this PR mergeable end-to-end against
current main, without changing the package design (still numa-git,
still pushed on every main commit, still tracking HEAD via pkgver()):

1. Simplified prepare() — drop the obsolete sed patching for
   /usr/local/bin/numa. That literal only appears in a comment
   in current main; the actual binary path is determined at
   runtime via std::env::current_exe(). Additionally, numa
   v0.10.1 ships PR #43 which makes numa FHS-compliant on Linux
   out of the box (/var/lib/numa for data dir), so no source
   patching is needed at all on Arch.

2. Fixed package() sed for the systemd unit. The previous sed
   targeted "ExecStart=/usr/local/bin/numa" but numa.service
   actually uses "{{exe_path}}" as a templating placeholder
   that's substituted at runtime by replace_exe_path() when
   `numa install` runs. The sed silently did nothing, and the
   AUR-installed unit file would have a literal "{{exe_path}}"
   that systemd cannot start. Fixed sed:

     sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' \
       numa.service > numa.service.patched

3. Fixed broken docker/setup-qemu-action SHA pin in
   publish-aur.yml. The pinned SHA
   6882732593b27c7f95a044d559b586a46371a68e doesn't exist as
   a commit in upstream docker/setup-qemu-action. Verified
   v3.0.0 SHA is 68827325e0b33c7199eb31dd4e31fbe9023e06e3.
   Without this fix the aarch64 validate job would fail to
   load the action at workflow start.

Also refreshed the stale pkgver placeholder in PKGBUILD and
.SRCINFO from 0.9.1.r0.g1234abc to 0.10.1.r0.g0000000 — purely
cosmetic since pkgver() auto-overrides on every makepkg run,
but at least the in-VC value reflects the current era.

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

* fix: make AUR packaging x86_64-only and stabilize local validation

Turns out Arch Linux doesn't officially support aarch64 architecture, so we will drop if from this AUR build process.

Changes:

- drop aarch64 from PKGBUILD, .SRCINFO, and AUR validation workflow
- keep AUR process aligned with official Arch Linux x86_64 support
- install rust directly in CI to avoid Arch cargo provider prompts
- fetch sources before running cargo audit and audit inside the
fetched repo
- disable makepkg LTO for this package to avoid Arch packaging link
failures
- mark /etc/numa.toml as a backup file
- Add local AUR build scratch directory exclusion to .gitignore

* Add temporary AUR test workflow

* Update github actions checkout workflow version

* remove temporary AUR test workflow

* fix: correct AUR SSH host key fingerprint

The previously pinned ed25519 key was truncated (52 chars) and did not
match the actual aur.archlinux.org host key. Verified via ssh-keyscan.

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-09 17:22:38 +03:00
Razvan Dimescu
fab8b698d8 fix: human-readable advisories for TLS data_dir + port-53 EACCES (#48)
* fix: human-readable advisory when TLS data_dir is not writable

When numa runs as non-root on a system with a privileged default
data_dir (e.g. /usr/local/var/numa on macOS), TLS CA setup fails with
a raw "Permission denied (os error 13)" and HTTPS proxy is silently
disabled. The user sees a cryptic warning with no path forward.

Detect std::io::ErrorKind::PermissionDenied on the tls error, print a
diagnostic naming the data_dir and offering two fixes (install as
system resolver, or point data_dir at a writable path), and keep the
graceful-degradation behavior — DNS resolution and plain-HTTP proxy
continue to work without HTTPS.

All other TLS setup errors fall through to the existing log::warn!.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: port-53 advisory also handles EACCES (non-root privileged bind)

The original port-53 match arm only caught EADDRINUSE, so a fresh
non-root user on macOS/Linux hitting EACCES when trying to bind a
privileged port saw the raw OS error instead of the advisory.

Collapse the scoping helper and the advisory into a single
`try_port53_advisory(bind_addr, &io::Error) -> Option<String>` that
returns the formatted diagnostic when both the port is 53 and the
error kind is one we can speak to (AddrInUse or PermissionDenied),
and `None` otherwise. The two failure modes share one body with a
cause-sentence variant — no duplicated fix text.

Caller becomes a plain if-let: no match guard, no separate is_port_53
helper exposed on the public API. is_port_53 goes back to private.

Unit tests cover all branches: AddrInUse, PermissionDenied, non-53
bind_addr, unrelated ErrorKind, and malformed bind_addr.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move TLS error classification into tls module

main.rs no longer downcasts a boxed error to figure out whether it's
a permission-denied case. tls::try_data_dir_advisory(&err, &dir)
encapsulates the downcast + kind match and returns Some(advisory) or
None, mirroring system_dns::try_port53_advisory. main.rs becomes a
plain if-let, symmetric with the port-53 path.

Trim the docstrings on both advisory functions: they were narrating
the implementation (errno mapping) instead of stating the contract.

Add unit tests for try_data_dir_advisory covering PermissionDenied,
other io::ErrorKind, and non-io errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:27:08 +03:00
Razvan Dimescu
a6f23a5ddb fix: advisory + exit(1) when port 53 is already in use (#45) (#47)
* fix: advisory + exit(1) when port 53 is already in use (#45)

Detect AddrInUse on bind, print a human-readable diagnostic explaining
systemd-resolved / Dnscache as the likely cause and offer two concrete
fixes (sudo numa install, or bind_addr on a non-privileged port), then
exit(1) instead of surfacing a raw OS error.

Adds tests/docker/smoke-port53.sh: end-to-end Docker test that
pre-binds port 53 with a Python UDP socket and asserts the advisory +
exit code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: collapse port53 advisory to single flat path

The per-platform cause sentences were cosmetic — they didn't change
the user's actions (install, or bind_addr on a non-privileged port),
but they introduced duplicated "another process..." strings, a
dead-from-CI branch (is_systemd_resolved_active() == true is never
reached by any test), and a pub visibility bump on
is_systemd_resolved_active for a single caller.

Replace with one flat format! whose cause line mentions both
systemd-resolved and the Windows DNS Client inline. The existing
smoke test now exercises 100% of the function.

is_systemd_resolved_active reverts to private.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:03:58 +03:00
Razvan Dimescu
27dfaab360 ci: pass PAT to action-gh-release so release events propagate (#44)
GitHub Actions deliberately does not propagate workflow events triggered
by the default GITHUB_TOKEN — a safety feature against infinite loops.
softprops/action-gh-release falls back to GITHUB_TOKEN when no `token`
is supplied, so the resulting `release: published` event was silently
swallowed and never reached homebrew-bump.yml.

Discovered shipping v0.10.1: tag pushed cleanly, crates.io published
cleanly, GitHub release page created cleanly, but the brew tap never
auto-bumped. Had to trigger homebrew-bump.yml manually via
workflow_dispatch.

Fix: pass HOMEBREW_TAP_GITHUB_TOKEN explicitly. This is already a PAT
(used by homebrew-bump.yml to push cross-repo to razvandimescu/
homebrew-tap), so reusing it keeps the secret surface flat. PAT-authored
release events are the documented escape hatch from the GITHUB_TOKEN
no-propagation rule.

Applies to v0.10.2+. v0.10.1 was bumped manually.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:26:21 +03:00
19 changed files with 1042 additions and 207 deletions

19
.SRCINFO Normal file
View File

@@ -0,0 +1,19 @@
pkgbase = numa-git
pkgdesc = Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS
pkgver = 0.10.1.r0.g0000000
pkgrel = 1
url = https://github.com/razvandimescu/numa
arch = x86_64
license = MIT
options = !lto
makedepends = cargo
makedepends = git
depends = gcc-libs
depends = glibc
provides = numa
conflicts = numa
backup = etc/numa.toml
source = numa::git+https://github.com/razvandimescu/numa.git
sha256sums = SKIP
pkgname = numa-git

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

159
.github/workflows/publish-aur.yml vendored Normal file
View File

@@ -0,0 +1,159 @@
# `publish-aur.yml` - Arch Linux AUR Package Workflow
# --------------------
# This workflow automates the validation and publishing of the 'numa-git' package to the
# Arch User Repository (AUR). The AUR is a community-driven repository for Arch Linux users.
#
# Workflow Overview:
# 1. Validate: Builds and tests the package for Arch Linux x86_64 using a clean
# Arch Linux container.
# 2. Audit: Checks Rust dependencies for known security vulnerabilities using
# 'cargo-audit'.
# 3. Publish: If on the 'main' branch, it pushes the updated PKGBUILD and
# .SRCINFO to the AUR.
#
# Security Best Practices:
# - SHA Pinning: All GitHub Actions are pinned to a full-length commit SHA (e.g., v6.0.2 @ SHA)
# to ensure the code is immutable and protects against supply-chain attacks where a tag
# might be maliciously moved to a compromised commit.
# - SSH Hygiene: Uses ssh-agent to keep the private key in memory rather than on disk.
# - Audit: Runs 'cargo audit' to prevent publishing known vulnerable dependencies.
name: Publish - Arch Linux AUR Package
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
# The 'validate' job ensures that the PKGBUILD is correct and the software builds/tests
# successfully on Arch Linux before we attempt to publish it.
validate:
name: Validate PKGBUILD (${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch: [x86_64]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build and Test Package
timeout-minutes: 60
env:
AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }}
run: |
# We use a temporary directory to avoid Docker permission issues with the workspace.
mkdir -p build-dir
cp PKGBUILD build-dir/
docker run --rm -v $PWD/build-dir:/pkg -w /pkg archlinux:latest /bin/bash -c "
# ARCH LINUX SECURITY REQUIREMENT:
# 'makepkg' (the tool that builds Arch packages) refuses to run as root for safety.
# We must create a standard user and give them sudo access.
# Install build-time dependencies.
# 'base-devel' includes essential tools like gcc, make, and binutils.
# Install 'rust' directly to avoid the interactive virtual-package
# prompt for 'cargo' on current Arch images.
pacman -Syu --noconfirm --needed base-devel rust git sudo cargo-audit
useradd -m builduser
chown -R builduser:builduser /pkg
# Allow the build user to install dependencies during the build process.
echo 'builduser ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builduser
# Fetch the source tree first so pkgver() and cargo-audit have a
# real Cargo.lock to inspect.
sudo -u builduser makepkg -o --nobuild --nocheck --nodeps --noprepare
# SECURITY AUDIT:
# Fail early if any dependencies have known security vulnerabilities.
sudo -u builduser sh -lc 'cd /pkg/src/numa && cargo audit'
# BUILD & TEST:
# 'makepkg -s' will:
# 1. Download source files (cloning this repo)
# 2. Run prepare(), build(), and check() (running cargo test)
# 3. Create the final .pkg.tar.zst package
sudo -u builduser makepkg -s --noconfirm
"
# The 'publish' job updates the AUR repository with our latest PKGBUILD and .SRCINFO.
publish:
name: Publish to AUR
needs: validate
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Securely configure SSH for AUR access.
- name: Configure SSH
run: |
mkdir -p ~/.ssh
# Official AUR Ed25519 fingerprint (prevents Man-in-the-Middle attacks).
echo "aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN" >> ~/.ssh/known_hosts
# Use ssh-agent to keep the private key in memory rather than writing it to disk.
eval $(ssh-agent -s)
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
# Export the agent socket so subsequent 'git' commands can use it.
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> $GITHUB_ENV
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> $GITHUB_ENV
- name: Push to AUR
env:
AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }}
AUR_EMAIL: ${{ secrets.AUR_EMAIL }}
AUR_USER: ${{ secrets.AUR_USERNAME }}
run: |
# AUR repos are managed via Git. Each package has its own repo at:
# ssh://aur@aur.archlinux.org/<package-name>.git
git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo
cp PKGBUILD aur-repo/
cd aur-repo
# METADATA GENERATION:
# '.SRCINFO' is a machine-readable version of the PKGBUILD.
# We must run this as a non-root user ('builduser') inside the container.
docker run --rm -v $(pwd):/pkg archlinux:latest /bin/bash -c "
pacman -Syu --noconfirm --needed binutils git sudo
useradd -m builduser
chown -R builduser:builduser /pkg
cd /pkg
sudo -u builduser git config --global --add safe.directory '*'
# makepkg -od fetches the source first so pkgver() can calculate the version.
# --noprepare skips the prepare() function, which invokes cargo and would
# otherwise require a full rust toolchain in this metadata-only container.
# pkgver() runs before prepare(), so .SRCINFO still gets the correct version.
sudo -u builduser makepkg -od --noprepare && sudo -u builduser makepkg --printsrcinfo > .SRCINFO
"
# Reclaim ownership: the in-container 'chown -R builduser:builduser /pkg'
# propagates through the bind mount, leaving .git/ owned by the container's
# builduser UID. Without this, subsequent 'git config' on the host fails with
# "could not lock config file .git/config: Permission denied".
sudo chown -R "$(id -u):$(id -g)" .
# Set the commit identity using secrets for security and auditability.
git config user.name "$AUR_USER"
git config user.email "$AUR_EMAIL"
# Stage and commit both the human-readable PKGBUILD and machine-readable .SRCINFO.
git add PKGBUILD .SRCINFO
if ! git diff --cached --quiet; then
git commit -m "chore: update PKGBUILD to ${{ github.sha }}"
git push origin master
else
echo "No changes to commit (metadata and PKGBUILD are already up-to-date)."
fi

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,7 +96,7 @@ jobs:
needs: [build, publish]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
merge-multiple: true
@@ -108,3 +108,10 @@ jobs:
*.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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/target
/build-dir
CLAUDE.md
docs/
site/blog/posts/

241
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"
@@ -75,18 +84,18 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[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",
@@ -702,12 +710,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"
@@ -755,9 +757,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -770,7 +772,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -810,7 +811,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2",
"tokio",
"tower-service",
"tracing",
@@ -944,17 +945,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 +953,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 +1133,7 @@ dependencies = [
[[package]]
name = "numa"
version = "0.10.1"
version = "0.10.3"
dependencies = [
"arc-swap",
"axum",
@@ -1162,7 +1152,7 @@ dependencies = [
"rustls-pemfile",
"serde",
"serde_json",
"socket2 0.5.10",
"socket2",
"time",
"tokio",
"tokio-rustls",
@@ -1172,9 +1162,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 +1187,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"
@@ -1219,12 +1219,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "plotters"
version = "0.3.7"
@@ -1314,8 +1308,8 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
@@ -1336,7 +1330,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"thiserror",
"tinyvec",
"tracing",
"web-time",
@@ -1351,7 +1345,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
@@ -1422,9 +1416,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 +1649,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]]
@@ -1698,16 +1692,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 +1745,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]]
@@ -1869,24 +1833,24 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.3",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -1918,44 +1882,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 +2150,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 +2175,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 +2345,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 +2363,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 +2375,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.1"
version = "0.10.3"
authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021"
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
@@ -14,7 +14,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time",
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,8 +22,8 @@ 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"
@@ -32,7 +32,7 @@ ring = "0.17"
rustls-pemfile = "2.2.0"
[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"]

62
PKGBUILD Normal file
View File

@@ -0,0 +1,62 @@
# Maintainer: razvandimescu <razvan@dimescu.com>
pkgname=numa-git
_pkgname=numa
pkgver=0.10.1.r0.g0000000 # Placeholder — pkgver() rewrites this on each makepkg run
pkgrel=1
pkgdesc="Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
arch=('x86_64')
url="https://github.com/razvandimescu/numa"
license=('MIT')
options=('!lto')
depends=('gcc-libs' 'glibc')
makedepends=('cargo' 'git')
provides=("$_pkgname")
conflicts=("$_pkgname")
backup=('etc/numa.toml')
source=("$_pkgname::git+$url.git")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/$_pkgname"
( set -o pipefail
git describe --long --tags 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' ||
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
) | sed 's/^v//'
}
prepare() {
cd "$srcdir/$_pkgname"
# numa v0.10.1+ uses FHS-compliant paths on Linux by default
# (/var/lib/numa for data, journalctl for logs), so no source
# patching is needed. The earlier sed targeted /usr/local/bin/numa,
# which only appears in a comment in current main.
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked
}
build() {
cd "$srcdir/$_pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo build --frozen --release
}
check() {
cd "$srcdir/$_pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo test --frozen
}
package() {
cd "$srcdir/$_pkgname"
install -Dm755 "target/release/$_pkgname" "$pkgdir/usr/bin/$_pkgname"
# numa.service uses {{exe_path}} as a placeholder substituted by
# `numa install` at runtime via replace_exe_path(). For an AUR
# package install (no `numa install` step), we substitute it
# statically here so systemd gets a real ExecStart path.
sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' numa.service > numa.service.patched
install -Dm644 "numa.service.patched" "$pkgdir/usr/lib/systemd/system/numa.service"
install -Dm644 "numa.toml" "$pkgdir/etc/numa.toml"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

View File

@@ -21,6 +21,9 @@ brew install razvandimescu/tap/numa
# Linux
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
# Arch Linux (AUR)
yay -S numa-git
# Windows — download from GitHub Releases
# All platforms
cargo install numa

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

@@ -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

@@ -223,7 +223,11 @@ async fn main() -> numa::Result<()> {
) {
Ok(tls_config) => Some(ArcSwap::from(tls_config)),
Err(e) => {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) {
eprint!("{}", advisory);
} else {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
}
None
}
}
@@ -231,8 +235,21 @@ async fn main() -> numa::Result<()> {
None
};
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
Ok(s) => s,
Err(e) => {
if let Some(advisory) =
numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e)
{
eprint!("{}", advisory);
std::process::exit(1);
}
return Err(e.into());
}
};
let ctx = Arc::new(ServerCtx {
socket: UdpSocket::bind(&config.server.bind_addr).await?,
socket,
zone_map: build_zone_map(&config.zones)?,
cache: RwLock::new(DnsCache::new(
config.cache.max_entries,

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" | "")
}
@@ -46,6 +57,60 @@ pub fn discover_system_dns() -> SystemDnsInfo {
}
}
/// Advisory for port-53 bind failures (EADDRINUSE or EACCES); `None`
/// if not applicable so the caller can fall back to the raw error.
pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<String> {
if !is_port_53(bind_addr) {
return None;
}
let (title, cause) = match err.kind() {
std::io::ErrorKind::AddrInUse => (
"port 53 is already in use",
"Another process is already bound to port 53. On Linux this is\n \
typically systemd-resolved; on Windows, the DNS Client service.",
),
std::io::ErrorKind::PermissionDenied => (
"permission denied",
"Port 53 is privileged — binding it requires root on Linux/macOS\n \
or Administrator on Windows.",
),
_ => return None,
};
let o = "\x1b[1;38;2;192;98;58m"; // bold orange
let r = "\x1b[0m";
Some(format!(
"
{o}Numa{r} — cannot bind to {bind_addr}: {title}.
{cause}
Fix — pick one:
1. Install Numa as the system resolver (frees port 53):
sudo numa install (on Windows, run as Administrator)
2. Run on a non-privileged port for testing.
Create ~/.config/numa/numa.toml with:
[server]
bind_addr = \"127.0.0.1:5354\"
api_port = 5380
Then run: numa
Test with: dig @127.0.0.1 -p 5354 example.com
"
))
}
fn is_port_53(bind_addr: &str) -> bool {
bind_addr
.parse::<SocketAddr>()
.map(|s| s.port() == 53)
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn discover_macos() -> SystemDnsInfo {
use log::{debug, warn};
@@ -174,6 +239,9 @@ fn discover_linux() -> SystemDnsInfo {
let default_upstream = if let Some(ns) = upstream {
info!("detected system upstream: {}", ns);
Some(ns)
} else if let Some(ns) = resolvectl_dns_server() {
info!("detected system upstream via resolvectl: {}", ns);
Some(ns)
} else {
// Fallback to backup from a previous `numa install`
let backup = {
@@ -631,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(())
}
@@ -1124,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(())
}
@@ -1331,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(())
}
@@ -1753,4 +1815,43 @@ Wireless LAN adapter Wi-Fi:
assert_eq!(result.len(), 1);
assert!(result.contains_key("Wi-Fi"));
}
#[test]
fn try_port53_advisory_addr_in_use() {
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53");
assert!(msg.contains("cannot bind to"));
assert!(msg.contains("already in use"));
assert!(msg.contains("numa install"));
assert!(msg.contains("bind_addr"));
}
#[test]
fn try_port53_advisory_permission_denied() {
let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53");
assert!(msg.contains("cannot bind to"));
assert!(msg.contains("permission denied"));
assert!(msg.contains("numa install"));
assert!(msg.contains("bind_addr"));
}
#[test]
fn try_port53_advisory_skips_non_53_ports() {
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
assert!(try_port53_advisory("127.0.0.1:5354", &err).is_none());
assert!(try_port53_advisory("[::]:853", &err).is_none());
}
#[test]
fn try_port53_advisory_skips_unrelated_error_kinds() {
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
assert!(try_port53_advisory("0.0.0.0:53", &err).is_none());
}
#[test]
fn try_port53_advisory_skips_malformed_bind_addr() {
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
assert!(try_port53_advisory("not-an-address", &err).is_none());
}
}

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};
@@ -40,6 +42,40 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
}
}
/// Advisory for TLS-setup failures caused by a non-writable data dir;
/// `None` if not applicable so the caller can fall back to the raw error.
pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option<String> {
let io_err = err.downcast_ref::<std::io::Error>()?;
if io_err.kind() != std::io::ErrorKind::PermissionDenied {
return None;
}
let o = "\x1b[1;38;2;192;98;58m";
let r = "\x1b[0m";
Some(format!(
"
{o}Numa{r} — HTTPS proxy disabled: cannot write TLS CA to {}.
The data directory is not writable by the current user. Numa needs
to persist a local Certificate Authority there to serve .numa over
HTTPS. DNS resolution and plain-HTTP proxy continue to work.
Fix — pick one:
1. Install Numa as the system resolver (sets up a writable data dir):
sudo numa install (on Windows, run as Administrator)
2. Point data_dir at a path you can write.
Create ~/.config/numa/numa.toml with:
[server]
data_dir = \"/path/you/can/write\"
",
data_dir.display()
))
}
/// Build a TLS config with a cert covering all provided service names.
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
/// so we list each service explicitly as a SAN.
@@ -53,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();
@@ -72,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);
@@ -80,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
@@ -111,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>)> {
@@ -153,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: {}",
@@ -164,9 +204,39 @@ 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)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn try_data_dir_advisory_permission_denied() {
let err: crate::Error =
Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
let path = PathBuf::from("/usr/local/var/numa");
let msg = try_data_dir_advisory(&err, &path).expect("should advise");
assert!(msg.contains("HTTPS proxy disabled"));
assert!(msg.contains("/usr/local/var/numa"));
assert!(msg.contains("numa install"));
assert!(msg.contains("data_dir"));
}
#[test]
fn try_data_dir_advisory_skips_other_io_kinds() {
let err: crate::Error = Box::new(std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none());
}
#[test]
fn try_data_dir_advisory_skips_non_io_errors() {
let err: crate::Error = "rcgen failure".into();
assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none());
}
}

138
tests/docker/smoke-port53.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# Port-53 conflict advisory integration test.
#
# Builds numa from source inside a debian:bookworm container, pre-binds
# port 53 with a UDP socket, then runs numa bare (default bind_addr
# 0.0.0.0:53). Verifies:
# - process exits with code 1
# - stderr contains the advisory ("cannot bind to")
# - stderr contains both fix suggestions ("numa install", "bind_addr")
#
# This is the end-to-end test for the fix in:
# src/main.rs — AddrInUse match arm → eprint advisory + process::exit(1)
#
# No systemd-resolved needed — the conflict is simulated by a Python
# UDP socket held open before numa starts.
#
# Requirements: docker
# Usage: ./tests/docker/smoke-port53.sh
set -euo pipefail
cd "$(dirname "$0")/../.."
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
pass() { printf " ${GREEN}${RESET} %s\n" "$1"; }
fail() { printf " ${RED}${RESET} %s\n" "$1"; printf " %s\n" "$2"; FAILED=$((FAILED+1)); }
FAILED=0
echo "── smoke-port53: building + testing numa on debian:bookworm ──"
echo " (first run is slow: image pull + cold cargo build, ~5-8 min)"
echo
OUTPUT=$(docker run --rm \
--platform linux/amd64 \
-v "$PWD:/src:ro" \
-v numa-port53-cargo:/root/.cargo \
-v numa-port53-target:/work/target \
debian:bookworm bash -c '
set -e
apt-get update -qq && apt-get install -y -qq curl build-essential python3 2>&1 | tail -3
# Install rustup if not already in the cargo cache volume
if ! command -v cargo &>/dev/null; then
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet
fi
. "$HOME/.cargo/env"
# Copy source to a writable workdir
mkdir -p /work
tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf -
cd /work
echo "── cargo build --release --locked ──"
cargo build --release --locked 2>&1 | tail -5
echo
# Write the holder script to a file to avoid quoting hell.
# Holds port 53 until killed — no sleep race.
cat > /tmp/hold53.py << '"'"'PYEOF'"'"'
import socket, signal
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
s.bind(("", 53))
signal.pause()
PYEOF
python3 /tmp/hold53.py &
HOLDER_PID=$!
# Verify the holder is actually up before proceeding
sleep 0.3
if ! kill -0 $HOLDER_PID 2>/dev/null; then
echo "holder_failed=1"
exit 1
fi
echo "── running numa with port 53 already bound ──"
# timeout 5: guards against numa not exiting (advisory not fired, bug present)
# Capture stderr to a file so the exit code is not clobbered by || or $()
set +e
timeout 5 ./target/release/numa > /tmp/numa-stderr.txt 2>&1
EXIT_CODE=$?
set -e
STDERR=$(cat /tmp/numa-stderr.txt)
kill $HOLDER_PID 2>/dev/null || true
echo "exit_code=$EXIT_CODE"
printf "%s" "$STDERR" | sed "s/^/ numa: /"
' 2>&1)
echo "$OUTPUT"
echo
echo "── assertions ──"
if echo "$OUTPUT" | grep -q "holder_failed=1"; then
echo " SETUP FAILED: could not pre-bind port 53 inside container"
exit 1
fi
EXIT_CODE=$(echo "$OUTPUT" | grep '^exit_code=' | cut -d= -f2)
if [ "${EXIT_CODE:-}" = "1" ]; then
pass "exits with code 1"
else
fail "exits with code 1" "got: exit_code=${EXIT_CODE:-<missing>}"
fi
if echo "$OUTPUT" | grep -q "cannot bind to"; then
pass "advisory printed to stderr"
else
fail "advisory printed to stderr" "stderr did not contain 'cannot bind to'"
fi
if echo "$OUTPUT" | grep -q "numa install"; then
pass "advisory offers 'sudo numa install'"
else
fail "advisory offers 'sudo numa install'" "not found in output"
fi
if echo "$OUTPUT" | grep -q "bind_addr"; then
pass "advisory offers non-privileged port alternative"
else
fail "advisory offers non-privileged port alternative" "'bind_addr' not found in output"
fi
echo
if [ "$FAILED" -eq 0 ]; then
printf "${GREEN}── smoke-port53 passed ──${RESET}\n"
exit 0
else
printf "${RED}── smoke-port53 failed ($FAILED assertion(s)) ──${RESET}\n"
exit 1
fi