Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .github/workflows/manual-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.version }}
name: Release ${{ github.event.inputs.version }}
name: ${{ github.event.inputs.version }}
prerelease: ${{ github.event.inputs.prerelease }}
files: |
bindizr_*.deb
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ DNS Synchronization Service for BIND9

- **Secondary DNS Servers**: Standard BIND9 (or any RFC-compliant DNS server) instances configured as secondaries. They automatically discover zones through the catalog zone, pull zone updates from Bindizr's XFR server via zone transfer, and respond to DNS queries from clients.

- **nsupdate (Dynamic Update)**: Supports RFC 2136-style DNS dynamic updates via nsupdate.

<br>

&nbsp;<img src="public/concepts.png" width="462px">
Expand Down Expand Up @@ -178,9 +180,10 @@ file_path = "bindizr.db" # SQLite database file path
[database.postgresql]
server_url = "postgresql://user:password@hostname:port/database" # PostgreSQL server configuration

[xfr]
listen_port = 53 # XFR server listen port (TCP)
secondary_addrs = "" # Comma-separated secondary DNS server addresses for NOTIFY (e.g., "192.168.1.2:53,192.168.1.3:53")
[dns]
listen_port = 53 # DNS server listen port (UDP and TCP)
secondary_addrs = "" # Comma-separated secondary DNS server addresses for NOTIFY (e.g., "192.168.1.2:53,192.168.1.3:53")
nsupdate_allowed_ips = "" # Comma-separated list of IPs allowed to perform nsupdate (e.g., "192.168.1.2,192.168.1.3")

[logging]
log_level = "debug" # Log level: error, warn, info, debug, trace
Expand Down Expand Up @@ -220,6 +223,19 @@ $ bindizr notify zone <ZONE_NAME>
$ bindizr --help
```

### nsupdate (Dynamic Update)

Bindizr supports RFC 2136-style dynamic updates through the DNS listener.

```bash
$ nsupdate <<EOF
server 127.0.0.1 53
zone example.com
update add sub.example.com. 300 A 1.2.3.4
send
EOF
```

### Token Management

Bindizr uses API tokens for authentication. You can manage these tokens using the following commands:
Expand Down
17 changes: 9 additions & 8 deletions bindizr.conf.toml
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
listen_addr = "127.0.0.1" # Common listen address for all services
listen_addr = "127.0.0.1" # Common listen address for all services

[api]
listen_port = 3000 # HTTP API listen port
require_authentication = true # Enable API authentication (true/false)
listen_port = 3000 # HTTP API listen port
require_authentication = true # Enable API authentication (true/false)

[database]
type = "mysql" # Database type: mysql, sqlite, postgresql
type = "mysql" # Database type: mysql, sqlite, postgresql

[database.mysql]
server_url = "mysql://user:password@hostname:port/database" # Mysql server configuration

[database.sqlite]
file_path = "bindizr.db" # SQLite database file path
file_path = "bindizr.db" # SQLite database file path

[database.postgresql]
server_url = "postgresql://user:password@hostname:port/database" # PostgreSQL server configuration

[xfr]
listen_port = 53 # XFR server listen port (TCP)
secondary_addrs = "" # Comma-separated secondary DNS server addresses (e.g., "192.168.1.2:53,192.168.1.3:53") for NOTIFY
[dns]
listen_port = 53 # DNS server listen port (UDP and TCP)
secondary_addrs = "" # Comma-separated secondary DNS server addresses for NOTIFY (e.g., "192.168.1.2:53,192.168.1.3:53")
nsupdate_allowed_ips = "" # Comma-separated list of IPs allowed to perform nsupdate (e.g., "192.168.1.2,192.168.1.3")

[logging]
log_level = "debug" # Log level: error, warn, info, debug, trace
Binary file modified public/concepts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions src/api/service/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
zone_snapshot::ZoneSnapshot,
},
},
log_error, log_info, log_warn, xfr,
dns, log_error, log_info, log_warn,
};
use chrono::Utc;

Expand Down Expand Up @@ -351,7 +351,7 @@ impl RecordService {
save_zone_snapshot(&zone, new_serial).await?;

// Send NOTIFY to secondary servers
if let Err(e) = xfr::notify::send_notify(Some(&zone.name)).await {
if let Err(e) = dns::xfr::notify::send_notify(Some(&zone.name)).await {
log_warn!("Failed to send NOTIFY for zone {}: {}", zone.name, e);
}

Expand Down Expand Up @@ -614,7 +614,7 @@ impl RecordService {
save_zone_snapshot(&zone, new_serial).await?;

// Send NOTIFY to secondary servers
if let Err(e) = xfr::notify::send_notify(Some(&zone.name)).await {
if let Err(e) = dns::xfr::notify::send_notify(Some(&zone.name)).await {
log_warn!("Failed to send NOTIFY for zone {}: {}", zone.name, e);
}

Expand Down Expand Up @@ -778,7 +778,7 @@ impl RecordService {
save_zone_snapshot(&zone, new_serial).await?;

// Send NOTIFY to secondary servers
if let Err(e) = xfr::notify::send_notify(Some(&zone.name)).await {
if let Err(e) = dns::xfr::notify::send_notify(Some(&zone.name)).await {
log_warn!("Failed to send NOTIFY for zone {}: {}", zone.name, e);
}

Expand Down
4 changes: 2 additions & 2 deletions src/api/service/zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
zone_snapshot::ZoneSnapshot,
},
},
log_error, log_info, log_warn, xfr,
dns, log_error, log_info, log_warn,
};
use chrono::Utc;

Expand Down Expand Up @@ -410,7 +410,7 @@ impl ZoneService {
save_zone_snapshot(&updated_zone, new_serial).await?;

// Send NOTIFY to secondary servers
if let Err(e) = xfr::notify::send_notify(Some(&updated_zone.name)).await {
if let Err(e) = dns::xfr::notify::send_notify(Some(&updated_zone.name)).await {
log_warn!(
"Failed to send NOTIFY for zone {}: {}",
updated_zone.name,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ mod output;
use crate::{
api,
cli::commands::{notify::NotifyCommand, token::TokenCommand},
config, database, log_info, logger, socket, xfr,
config, database, dns, log_info, logger, socket,
};
use clap::{Parser, Subcommand};

Expand Down Expand Up @@ -64,7 +64,7 @@ pub async fn bootstrap(config_file: Option<&str>) -> Result<(), String> {

logger::initialize();
database::initialize().await;
xfr::initialize().await;
dns::initialize().await;

log_info!("Bindizr is running in foreground mode.");
log_info!("For production use, please run bindizr as a systemd service:");
Expand Down
36 changes: 36 additions & 0 deletions src/dns/acl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use crate::{config, log_warn};
use std::net::{IpAddr, SocketAddr};

pub fn secondary_servers_from_config() -> Vec<IpAddr> {
parse_ip_list_with_socket_fallback(&config::get_config::<String>("dns.secondary_addrs"))
}

pub fn nsupdate_allowed_ips_from_config() -> Vec<IpAddr> {
parse_ip_list_with_socket_fallback(&config::get_config::<String>("dns.nsupdate_allowed_ips"))
}

pub fn is_client_allowed(client_ip: IpAddr, allowed_ips: &[IpAddr]) -> bool {
allowed_ips.is_empty() || allowed_ips.contains(&client_ip)
}

fn parse_ip_list_with_socket_fallback(raw: &str) -> Vec<IpAddr> {
raw.split(',')
.filter_map(|item| {
let trimmed = item.trim();
if trimmed.is_empty() {
return None;
}

match trimmed.parse::<SocketAddr>() {
Ok(addr) => Some(addr.ip()),
Err(_) => match trimmed.parse::<IpAddr>() {
Ok(ip) => Some(ip),
Err(_) => {
log_warn!("Ignoring invalid IP address in DNS ACL config: {}", trimmed);
None
}
},
}
})
.collect()
}
176 changes: 176 additions & 0 deletions src/dns/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
pub mod acl;
pub mod nsupdate;
pub mod soa;
pub mod xfr;

use crate::{config, log_error, log_info, log_warn};
use domain::base::iana::Rtype;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use tokio::net::{TcpListener, TcpStream, UdpSocket};

enum QueryRoute {
Nsupdate,
Soa,
Xfr,
Other(Rtype),
}

pub async fn initialize() {
xfr::initialize().await;

let listen_addr_str = config::get_config::<String>("listen_addr");
let listen_port = config::get_config::<u16>("dns.listen_port");
let listen_addr = SocketAddr::new(
IpAddr::from_str(&listen_addr_str).expect("Invalid DNS listen address"),
listen_port,
);

let secondary_servers = acl::secondary_servers_from_config();
let tcp_secondary_servers = secondary_servers.clone();

tokio::spawn(async move {
if let Err(e) = run_tcp_server(listen_addr, tcp_secondary_servers).await {
log_error!("DNS TCP server error: {}", e);
}
});

tokio::spawn(async move {
if let Err(e) = run_udp_server(listen_addr, secondary_servers).await {
log_error!("DNS UDP server error: {}", e);
}
});
}

async fn run_tcp_server(
listen_addr: SocketAddr,
secondary_servers: Vec<IpAddr>,
) -> Result<(), String> {
let listener = TcpListener::bind(listen_addr)
.await
.map_err(|e| format!("Failed to bind DNS TCP listener on {}: {}", listen_addr, e))?;

log_info!("DNS TCP server listening on {}", listen_addr);

loop {
match listener.accept().await {
Ok((stream, client_addr)) => {
let allowed = secondary_servers.clone();
tokio::spawn(async move {
if let Err(e) = handle_tcp_connection(stream, client_addr, allowed).await {
log_error!("DNS TCP connection error from {}: {}", client_addr, e);
}
});
}
Err(e) => {
log_error!("Failed to accept DNS TCP connection: {}", e);
}
}
}
}

async fn handle_tcp_connection(
mut stream: TcpStream,
client_addr: SocketAddr,
secondary_servers: Vec<IpAddr>,
) -> Result<(), String> {
let query_data = xfr::wire::read_tcp_message(&mut stream)
.await
.map_err(|e| format!("Failed to read DNS TCP message: {}", e))?;

match classify_query_route(&query_data) {
Ok(QueryRoute::Nsupdate) => {
nsupdate::handle_tcp_nsupdate(&mut stream, &query_data, client_addr).await?;
}
Ok(QueryRoute::Soa) => {
soa::handle_tcp_soa(&mut stream, client_addr, &secondary_servers, &query_data)
.await
.map_err(|e| format!("Failed to handle SOA TCP query: {}", e))?;
}
Ok(QueryRoute::Xfr) => {
xfr::handle_tcp_query(&mut stream, client_addr, &secondary_servers, &query_data)
.await
.map_err(|e| format!("Failed to handle XFR TCP query: {}", e))?;
}
Ok(QueryRoute::Other(qtype)) => {
log_info!(
"Ignoring non-XFR DNS TCP query from {} (qtype={:?})",
client_addr,
qtype
);
}
Err(e) => {
log_warn!("Failed to parse DNS TCP query from {}: {}", client_addr, e);
}
}

Ok(())
}

async fn run_udp_server(
listen_addr: SocketAddr,
secondary_servers: Vec<IpAddr>,
) -> Result<(), String> {
let socket = UdpSocket::bind(listen_addr)
.await
.map_err(|e| format!("Failed to bind DNS UDP socket on {}: {}", listen_addr, e))?;

log_info!("DNS UDP server listening on {}", listen_addr);

let mut buf = [0u8; 65535];

loop {
let (len, client_addr) = match socket.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
log_error!("Failed to receive DNS UDP packet: {}", e);
continue;
}
};

let query_data = &buf[..len];

match classify_query_route(query_data) {
Ok(QueryRoute::Nsupdate) => {
if let Err(e) =
nsupdate::handle_udp_nsupdate(&socket, query_data, client_addr).await
{
log_error!("NSUPDATE UDP handler failed for {}: {}", client_addr, e);
}
}
Ok(QueryRoute::Soa) => {
if let Err(e) =
soa::handle_udp_soa(&socket, client_addr, &secondary_servers, query_data).await
{
log_warn!("Failed to handle SOA UDP query from {}: {}", client_addr, e);
}
}
Ok(QueryRoute::Xfr) => {
if let Err(e) =
xfr::handle_udp_query(client_addr, &secondary_servers, query_data).await
{
log_warn!("Failed to handle XFR UDP query from {}: {}", client_addr, e);
}
}
Ok(QueryRoute::Other(_)) => {}
Err(_) => {}
}
Comment on lines +122 to +157
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UDP server loop awaits the full request handling (including DB work for nsupdate) inline. A slow update or transfer check will block processing of all subsequent UDP packets, which can cause packet loss/timeouts under load. Consider spawning a task per packet (or using a bounded work queue) so the receive loop stays responsive.

Copilot uses AI. Check for mistakes.
}
}

fn classify_query_route(query_data: &[u8]) -> Result<QueryRoute, String> {
if nsupdate::is_nsupdate(query_data) {
return Ok(QueryRoute::Nsupdate);
}

Comment on lines +161 to +165
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NSUPDATE handling is routed purely by opcode and doesn’t apply any IP-based ACL before accepting/processing the request. With the default dns.nsupdate_tsig_key = "" (which disables TSIG checks), this makes dynamic updates unauthenticated and reachable from anywhere the DNS port is exposed. Consider enforcing TSIG by default, or adding a separate dns.nsupdate_allowed_ips/reusing the existing secondary ACL to restrict who can send updates when TSIG is disabled.

Copilot uses AI. Check for mistakes.
let (_zone_name, qtype, _client_serial, _query_id) =
xfr::wire::parse_query(query_data).map_err(|e| e.to_string())?;

if qtype == Rtype::SOA {
Ok(QueryRoute::Soa)
} else if xfr::is_xfr_query_type(qtype) {
Ok(QueryRoute::Xfr)
} else {
Ok(QueryRoute::Other(qtype))
}
}
Loading
Loading