Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ sqlx = { version = "0.8", features = [
domain = "0.11"
rand = "0.10"
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"
hex = "0.4"
thiserror = "2.0.18"

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_tsig_key = "" # Shared TSIG secret for nsupdate authentication (empty to disable, base64 recommended)

[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_tsig_key = "" # Shared TSIG secret for nsupdate authentication (empty to disable, base64 recommended)
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 sample config sets nsupdate_tsig_key = "" (disables TSIG validation). If listen_addr is ever changed off-loopback, this makes dynamic updates unauthenticated by default. Consider changing the shipped example to require a non-empty TSIG key (or clearly warning that leaving it empty enables unauthenticated updates) to avoid insecure deployments.

Suggested change
nsupdate_tsig_key = "" # Shared TSIG secret for nsupdate authentication (empty to disable, base64 recommended)
nsupdate_tsig_key = "CHANGE_ME_TSIG_BASE64" # REQUIRED: per-deployment TSIG secret for nsupdate authentication (base64 recommended). Setting this to "" DISABLES AUTH and is insecure if listen_addr is not 127.0.0.1.

Copilot uses AI. Check for mistakes.

[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.
103 changes: 0 additions & 103 deletions src/api/controller/dns.rs

This file was deleted.

13 changes: 13 additions & 0 deletions src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,16 @@ impl IntoResponse for ApiError {
(status, body).into_response()
}
}

impl From<crate::service::error::ServiceError> for ApiError {
fn from(value: crate::service::error::ServiceError) -> Self {
match value {
crate::service::error::ServiceError::BadRequest(msg) => ApiError::BadRequest(msg),
crate::service::error::ServiceError::NotFound(msg) => ApiError::NotFound(msg),
crate::service::error::ServiceError::Unauthorized(msg) => ApiError::Unauthorized(msg),
crate::service::error::ServiceError::Internal(msg) => {
ApiError::InternalServerError(msg)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::api::service::auth::AuthService;
use crate::log_debug;
use crate::service::auth::AuthService;
use axum::Json;
use axum::body::Body;
use axum::http::header::AUTHORIZATION;
Expand All @@ -11,15 +11,13 @@ use axum::{
use serde_json::json;

pub async fn auth_middleware(mut req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
// Extract Authorization header
let auth_header = match req.headers().get(AUTHORIZATION) {
Some(header) => header,
None => {
return Ok(unauthorized("No authorization header"));
}
};

// Extract Bearer token
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(_) => return Ok(unauthorized("Invalid authorization header")),
Expand All @@ -31,7 +29,6 @@ pub async fn auth_middleware(mut req: Request<Body>, next: Next) -> Result<Respo

let token = &auth_str[7..];

// Validate token
match AuthService::validate_token(token).await {
Ok(api_token) => {
req.extensions_mut().insert(api_token);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,16 @@ use serde_json::json;

use crate::log_error;

// Custom extractor for JSON body with error handling
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct JsonBody<T>(pub T);

// Custom API error type for handling JSON extraction errors
#[derive(Debug)]
pub struct ApiError {
code: StatusCode,
message: String,
}

// Implement conversion from JsonRejection to ApiError
impl From<JsonRejection> for ApiError {
fn from(rejection: JsonRejection) -> Self {
let code = match rejection {
Expand All @@ -39,7 +36,6 @@ impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let payload = json!({
"message": self.message,
// "origin": "derive_from_request"
});

(self.code, axum::Json(payload)).into_response()
Expand Down
File renamed without changes.
10 changes: 6 additions & 4 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
pub mod controller;
pub mod dto;
pub mod error;
pub mod service;
pub mod middleware;
pub mod record;
pub mod router;
pub mod validation;
pub mod zone;

use crate::{config, log_error, log_info};
use controller::ApiController;
use router::ApiRouter;
use std::net::SocketAddr;
use tokio::net::TcpListener;

Expand All @@ -31,7 +33,7 @@ pub async fn initialize() -> Result<(), String> {

// Spawn API server in background
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, ApiController::routes().await).await {
if let Err(e) = axum::serve(listener, ApiRouter::routes().await).await {
log_error!("API server error: {:?}", e);
}
});
Expand Down
19 changes: 10 additions & 9 deletions src/api/controller/record.rs → src/api/record.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::api::{
controller::middleware::body_parser::JsonBody,
dto::{CreateRecordRequest, GetRecordResponse},
service::record::RecordService,
error::ApiError,
middleware::body_parser::JsonBody,
};
use crate::service::record::RecordService;
use axum::{
Json, Router,
extract::{Path, Query},
Expand All @@ -13,9 +14,9 @@ use axum::{
use serde::Deserialize;
use serde_json::json;

pub struct RecordController;
pub struct RecordApi;

impl RecordController {
impl RecordApi {
pub async fn routes() -> Router {
Router::new()
.route("/records", routing::get(Self::get_records))
Expand All @@ -39,7 +40,7 @@ impl RecordController {

let raw_records = match RecordService::get_records(zone_name).await {
Ok(records) => records,
Err(err) => return err.into_response(),
Err(err) => return ApiError::from(err).into_response(),
};

let records = raw_records
Expand All @@ -57,7 +58,7 @@ impl RecordController {

let raw_record = match RecordService::get_record(&name, &record_type).await {
Ok(record) => record,
Err(err) => return err.into_response(),
Err(err) => return ApiError::from(err).into_response(),
};

let record = GetRecordResponse::from_record(&raw_record);
Expand All @@ -69,7 +70,7 @@ impl RecordController {
async fn create_record(JsonBody(body): JsonBody<CreateRecordRequest>) -> impl IntoResponse {
let raw_record = match RecordService::create_record(&body).await {
Ok(record) => record,
Err(err) => return err.into_response(),
Err(err) => return ApiError::from(err).into_response(),
};

let record = GetRecordResponse::from_record(&raw_record);
Expand All @@ -87,7 +88,7 @@ impl RecordController {

let raw_record = match RecordService::update_record(&name, &record_type, &body).await {
Ok(record) => record,
Err(err) => return err.into_response(),
Err(err) => return ApiError::from(err).into_response(),
};

let record = GetRecordResponse::from_record(&raw_record);
Expand All @@ -105,7 +106,7 @@ impl RecordController {
let json_body = json!({ "message": "Record deleted successfully" });
(StatusCode::OK, Json(json_body)).into_response()
}
Err(err) => err.into_response(),
Err(err) => ApiError::from(err).into_response(),
}
}
}
Expand Down
Loading
Loading