Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:

env:
CARGO_TERM_COLOR: always

jobs:
test:
name: Build, test, lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2

- name: Check formatting
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

- name: Build
run: cargo build --workspace --all-targets

- name: Test
run: cargo test --workspace

- name: Doc build
run: cargo doc --workspace --no-deps
33 changes: 33 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Coverage

on:
push:
branches: [main]
pull_request:

jobs:
coverage:
name: Code coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
components: llvm-tools-preview

- uses: Swatinem/rust-cache@v2

- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Generate coverage (lcov)
run: cargo llvm-cov --workspace --lcov --output-path lcov.info

- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# binda (bind10)

[![CI](https://github.com/gptenv/binda/actions/workflows/ci.yml/badge.svg)](https://github.com/gptenv/binda/actions/workflows/ci.yml)
[![Coverage](https://codecov.io/gh/gptenv/binda/branch/main/graph/badge.svg)](https://codecov.io/gh/gptenv/binda)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust: nightly](https://img.shields.io/badge/rust-nightly-orange.svg)](rust-toolchain.toml)

> BINDA is an independent FOSS DNS resolver project. The name is derived
> from hexadecimal notation for "bind10" and is unrelated to any
> commercial brands. It is not affiliated with, endorsed by, or derived
Expand Down Expand Up @@ -37,8 +42,15 @@ within a 1-minute window.
until they agree, and that agreed rule decides the winner.
- **Gossip propagation** — nodes exchange registration state via
epidemic/anti-entropy gossip. No node is marked trusted or distrusted;
each message is judged on whether it is well-formed BINDA protocol, and
malformed messages are simply ignored for that exchange.
every information pull bundles a small behavioural test (a
[`ConformanceChallenge`](crates/binda-core/src/gossip.rs) — two
synthetic tokens and a win condition), answerable only by actually
running BINDA's own deterministic collision-resolution logic, and a
peer's rumors are adopted only if it answers that correctly. Getting it
wrong, or being malformed at all, means the same thing either way: for
that exchange, we assume we're not talking to a real BINDA node and
ignore everything it sent — with no memory of the failure carried into
the next exchange.
- **TOML zone files** — the same information a BIND9 zone file carries
(SOA, records), expressed as TOML.

Expand All @@ -61,6 +73,21 @@ cargo test --workspace
cargo doc --workspace --no-deps --open
```

### Test coverage

Coverage is measured with [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov)
and reported to [Codecov](https://codecov.io/gh/gptenv/binda) on every push
via [`.github/workflows/coverage.yml`](.github/workflows/coverage.yml) (the
badge above needs a `CODECOV_TOKEN` repository secret to actually upload —
add one from codecov.io's project settings for it to go live). To check
locally:

```bash
cargo install cargo-llvm-cov # once
cargo llvm-cov --workspace --summary-only # terminal summary
cargo llvm-cov --workspace --html --open # browsable per-line report
```

## Running a node

```bash
Expand Down Expand Up @@ -109,7 +136,13 @@ unit-tested, and networking is wired up end-to-end: nodes gossip via UDP
anti-entropy digests, and clients can probe/register/publish records over
the client API and resolve via either BINDA's own JSON resolver protocol
or its native DNS-shaped protocol — both fully Unicode-native, with no
Punycode/ASCII-compatibility step anywhere in BINDA.
Punycode/ASCII-compatibility step anywhere in BINDA. Overall test coverage
sits at ~94% (line/region), including real end-to-end integration tests
for the gossip conformance-challenge protocol (a rogue peer answering
incorrectly, a peer answering correctly, and full convergence between two
real nodes over UDP) and local fake-server tests exercising the actual
network code paths of NTP sync and FCrDNS verification without needing
real internet access.

Still missing/simplified, in rough priority order:

Expand All @@ -135,3 +168,9 @@ Still missing/simplified, in rough priority order:
(65,507 bytes), not a policy limit. Source addresses are still
spoofable, so this doesn't stop a distributed flood; it's a first line
of defense, not the whole story.
- The last significant coverage gaps are `main()`'s own async
orchestration (spawning the four listener tasks and joining them —
thin glue, exercised in practice by every manual run but not under a
test harness) and the small fraction of `ntp`/`fcrdns` that only
triggers against a real, uncontrolled remote server's exact reply
timing rather than the local fake servers the test suite uses instead.
152 changes: 149 additions & 3 deletions crates/binda-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ pub fn handle_client_request(
let now = time.now_millis();
match request {
ClientRequest::Probe(envelope) => handle_probe(store, time, now, envelope),
ClientRequest::Register(envelope) => handle_register(store, time, now, envelope, rdns_verified),
ClientRequest::Register(envelope) => {
handle_register(store, time, now, envelope, rdns_verified)
}
ClientRequest::SetRecords(envelope) => handle_set_records(store, now, envelope),
}
}
Expand Down Expand Up @@ -81,7 +83,11 @@ fn handle_set_records(
now: u64,
envelope: SignedEnvelope<SetRecordsBody>,
) -> ClientResponse {
let message = set_records_message(&envelope.body.domain, &envelope.body.records, envelope.timestamp_millis);
let message = set_records_message(
&envelope.body.domain,
&envelope.body.records,
envelope.timestamp_millis,
);
match authenticate(&envelope, &message, now) {
Ok(identity) => {
if store.set_records(&envelope.body.domain, &identity, envelope.body.records) {
Expand All @@ -107,7 +113,11 @@ mod tests {
use ed25519_dalek::{Signer, SigningKey};
use rand::rngs::OsRng;

fn envelope_register(signing_key: &SigningKey, domain: DomainName, timestamp: u64) -> ClientRequest {
fn envelope_register(
signing_key: &SigningKey,
domain: DomainName,
timestamp: u64,
) -> ClientRequest {
let message = register_message(&domain, timestamp);
let signature = signing_key.sign(&message);
ClientRequest::Register(SignedEnvelope {
Expand Down Expand Up @@ -184,4 +194,140 @@ mod tests {
other => panic!("expected Error, got {other:?}"),
}
}

fn probe(signing_key: &SigningKey, timestamp: u64) -> ClientRequest {
let msg = crate::client_api::probe_message(timestamp);
let sig = signing_key.sign(&msg);
ClientRequest::Probe(SignedEnvelope {
verifying_key: signing_key.verifying_key().to_bytes().to_vec(),
rdns: "host.example.net".into(),
timestamp_millis: timestamp,
signature: sig.to_bytes().to_vec(),
body: ProbeBody,
})
}

#[test]
fn probe_with_bad_signature_is_refused() {
let time = MockTimeSource::new(1_000);
let mut store = RegistryStore::new();
let signing_key = SigningKey::generate(&mut OsRng);
let mut req = probe(&signing_key, 1_000);
if let ClientRequest::Probe(envelope) = &mut req {
envelope.signature = vec![0u8; 64]; // wrong signature
}
match handle_client_request(&mut store, &time, req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
}

#[test]
fn registering_an_already_taken_domain_is_refused() {
let time = MockTimeSource::new(1_000);
let mut store = RegistryStore::new();
let owner_key = SigningKey::generate(&mut OsRng);
let other_key = SigningKey::generate(&mut OsRng);
handle_client_request(&mut store, &time, probe(&owner_key, 1_000), true);
handle_client_request(&mut store, &time, probe(&other_key, 1_000), true);

let domain = DomainName::new("example.binda").unwrap();
handle_client_request(
&mut store,
&time,
envelope_register(&owner_key, domain.clone(), 1_000),
true,
);
match handle_client_request(
&mut store,
&time,
envelope_register(&other_key, domain, 1_000),
true,
) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
}

fn envelope_set_records(
signing_key: &SigningKey,
domain: DomainName,
records: Vec<crate::zone::Record>,
timestamp: u64,
) -> ClientRequest {
let message = crate::client_api::set_records_message(&domain, &records, timestamp);
let signature = signing_key.sign(&message);
ClientRequest::SetRecords(SignedEnvelope {
verifying_key: signing_key.verifying_key().to_bytes().to_vec(),
rdns: "host.example.net".into(),
timestamp_millis: timestamp,
signature: signature.to_bytes().to_vec(),
body: crate::client_api::SetRecordsBody { domain, records },
})
}

#[test]
fn set_records_succeeds_for_domain_owner() {
let time = MockTimeSource::new(1_000);
let mut store = RegistryStore::new();
let signing_key = SigningKey::generate(&mut OsRng);
handle_client_request(&mut store, &time, probe(&signing_key, 1_000), true);
let domain = DomainName::new("example.binda").unwrap();
handle_client_request(
&mut store,
&time,
envelope_register(&signing_key, domain.clone(), 1_000),
true,
);

let records = vec![crate::zone::Record {
name: "@".into(),
record_type: crate::zone::RecordType::A,
ttl_secs: 300,
value: "203.0.113.1".into(),
}];
let req = envelope_set_records(&signing_key, domain, records, 1_000);
assert_eq!(
handle_client_request(&mut store, &time, req, true),
ClientResponse::RecordsSet
);
}

#[test]
fn set_records_fails_for_non_owner() {
let time = MockTimeSource::new(1_000);
let mut store = RegistryStore::new();
let owner_key = SigningKey::generate(&mut OsRng);
let stranger_key = SigningKey::generate(&mut OsRng);
handle_client_request(&mut store, &time, probe(&owner_key, 1_000), true);
let domain = DomainName::new("example.binda").unwrap();
handle_client_request(
&mut store,
&time,
envelope_register(&owner_key, domain.clone(), 1_000),
true,
);

let req = envelope_set_records(&stranger_key, domain, Vec::new(), 1_000);
match handle_client_request(&mut store, &time, req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
}

#[test]
fn set_records_with_bad_signature_is_refused() {
let time = MockTimeSource::new(1_000);
let mut store = RegistryStore::new();
let signing_key = SigningKey::generate(&mut OsRng);
let domain = DomainName::new("example.binda").unwrap();
let mut req = envelope_set_records(&signing_key, domain, Vec::new(), 1_000);
if let ClientRequest::SetRecords(envelope) = &mut req {
envelope.signature = vec![0u8; 64];
}
match handle_client_request(&mut store, &time, req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
}
}
Loading
Loading