bindcar/zones_types.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Zone data types — the pure request/response and configuration structs.
5//!
6//! These are the types a library consumer needs in order to talk to bindcar's
7//! zone API, with no HTTP machinery attached. They are deliberately free of
8//! `axum`, `ApiError` and `AppState` so that a consumer importing only
9//! `bindcar::ZoneConfig` does not inherit the server stack.
10//!
11//! The HTTP handlers that consume these live in [`crate::zones`], which
12//! re-exports everything here so existing `bindcar::zones::ZoneConfig` paths
13//! keep working.
14//!
15//! See `.github/community/02-feature-gate-http-server.md` — this split is
16//! phase 1 of putting the server behind a cargo feature.
17
18use serde::{Deserialize, Serialize};
19#[cfg(feature = "server")]
20use utoipa::ToSchema;
21
22/// Zone type constants
23pub const ZONE_TYPE_PRIMARY: &str = "primary";
24pub const ZONE_TYPE_SECONDARY: &str = "secondary";
25
26/// SOA (Start of Authority) record configuration
27///
28/// # Default Values
29///
30/// - `serial`: Automatically generated in YYYYMMDD01 format (e.g., 2025120601) if not provided
31/// - `refresh`: 3600 seconds
32/// - `retry`: 600 seconds
33/// - `expire`: 604800 seconds (7 days)
34/// - `negative_ttl`: 86400 seconds (1 day)
35#[derive(Debug, Clone, Deserialize, Serialize)]
36#[cfg_attr(feature = "server", derive(ToSchema))]
37#[serde(rename_all = "camelCase")]
38pub struct SoaRecord {
39 /// Primary nameserver (e.g., "ns1.example.com.")
40 pub primary_ns: String,
41
42 /// Admin email (e.g., "admin.example.com.")
43 pub admin_email: String,
44
45 /// Serial number (e.g., 2025120601) - defaults to current date in YYYYMMDD01 format
46 #[serde(default = "default_serial")]
47 pub serial: u32,
48
49 /// Refresh interval in seconds (default: 3600)
50 #[serde(default = "default_refresh")]
51 pub refresh: u32,
52
53 /// Retry interval in seconds (default: 600)
54 #[serde(default = "default_retry")]
55 pub retry: u32,
56
57 /// Expire time in seconds (default: 604800)
58 #[serde(default = "default_expire")]
59 pub expire: u32,
60
61 /// Negative TTL in seconds (default: 86400)
62 #[serde(default = "default_negative_ttl")]
63 pub negative_ttl: u32,
64}
65
66fn default_serial() -> u32 {
67 // Generate serial as YYYYMMDD01
68 let now = chrono::Utc::now();
69 let date_part = now.format("%Y%m%d").to_string();
70 format!("{}01", date_part).parse().unwrap_or(2025120601)
71}
72
73fn default_refresh() -> u32 {
74 3600
75}
76
77fn default_retry() -> u32 {
78 600
79}
80
81fn default_expire() -> u32 {
82 604_800
83}
84
85fn default_negative_ttl() -> u32 {
86 86400
87}
88
89/// DNS record entry
90#[derive(Debug, Clone, Deserialize, Serialize)]
91#[cfg_attr(feature = "server", derive(ToSchema))]
92#[serde(rename_all = "camelCase")]
93pub struct DnsRecord {
94 /// Record name (e.g., "www", "@")
95 pub name: String,
96
97 /// Record type (e.g., "A", "AAAA", "CNAME", "MX", "TXT")
98 #[serde(rename = "type")]
99 pub record_type: String,
100
101 /// Record value (e.g., "192.0.2.1", "example.com.")
102 pub value: String,
103
104 /// Optional TTL (uses zone default if not specified)
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub ttl: Option<u32>,
107
108 /// Optional priority (for MX, SRV records)
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub priority: Option<u16>,
111}
112
113/// Structured zone configuration
114#[derive(Debug, Clone, Deserialize, Serialize)]
115#[cfg_attr(feature = "server", derive(ToSchema))]
116#[serde(rename_all = "camelCase")]
117pub struct ZoneConfig {
118 /// Default TTL for the zone (e.g., 3600)
119 pub ttl: u32,
120
121 /// SOA record
122 pub soa: SoaRecord,
123
124 /// Name servers for the zone
125 pub name_servers: Vec<String>,
126
127 /// A records for nameservers (glue records)
128 /// Maps nameserver hostname to IP address (e.g., "ns1.example.com." -> "192.0.2.1")
129 pub name_server_ips: std::collections::HashMap<String, String>,
130
131 /// DNS records in the zone
132 #[serde(default)]
133 pub records: Vec<DnsRecord>,
134
135 /// IP addresses of secondary servers to notify when zone changes (BIND9 also-notify)
136 /// Example: ["10.244.2.101", "10.244.2.102"]
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub also_notify: Option<Vec<String>>,
139
140 /// IP addresses allowed to transfer the zone (BIND9 allow-transfer)
141 /// Example: ["10.244.2.101", "10.244.2.102"]
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub allow_transfer: Option<Vec<String>>,
144
145 /// IP addresses of primary servers for secondary zones (BIND9 primaries/masters)
146 /// Example: ["192.0.2.1", "192.0.2.2"]
147 /// Required for secondary zone types
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub primaries: Option<Vec<String>>,
150
151 /// DNSSEC policy name to apply to this zone (BIND9 9.16+)
152 ///
153 /// Specifies the name of a `dnssec-policy` block defined in `named.conf.options`.
154 /// When set, BIND9 will automatically sign the zone using the specified policy.
155 ///
156 /// Example: `"default"`, `"high-security"`
157 ///
158 /// Requires `inline_signing` to be enabled for DNSSEC to function.
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub dnssec_policy: Option<String>,
161
162 /// Enable inline signing for DNSSEC (BIND9 inline-signing)
163 ///
164 /// When `true`, BIND9 will sign the zone inline rather than requiring pre-signed zone files.
165 /// This is required for DNSSEC with dynamic zones and modern BIND9 configurations.
166 ///
167 /// Should be set to `true` when `dnssec_policy` is specified.
168 ///
169 /// Default: `false`
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub inline_signing: Option<bool>,
172}
173
174impl ZoneConfig {
175 /// Generate BIND9 zone file content from structured configuration
176 pub fn to_zone_file(&self) -> String {
177 let mut zone_file = String::new();
178
179 // TTL directive
180 zone_file.push_str(&format!("$TTL {}\n\n", self.ttl));
181
182 // SOA record
183 zone_file.push_str(&format!(
184 "@ IN SOA {} {} (\n",
185 self.soa.primary_ns, self.soa.admin_email
186 ));
187 zone_file.push_str(&format!(" {} ; Serial\n", self.soa.serial));
188 zone_file.push_str(&format!(" {} ; Refresh\n", self.soa.refresh));
189 zone_file.push_str(&format!(" {} ; Retry\n", self.soa.retry));
190 zone_file.push_str(&format!(" {} ; Expire\n", self.soa.expire));
191 zone_file.push_str(&format!(
192 " {} ); Negative TTL\n\n",
193 self.soa.negative_ttl
194 ));
195
196 // Name servers
197 for ns in &self.name_servers {
198 zone_file.push_str(&format!("@ IN NS {}\n", ns));
199 }
200
201 if !self.name_servers.is_empty() {
202 zone_file.push('\n');
203 }
204
205 // Glue records (A records for nameservers)
206 for (ns_name, ip) in &self.name_server_ips {
207 // Use FQDN with trailing dot to prevent BIND9 from appending zone name
208 zone_file.push_str(&format!("{} IN A {}\n", ns_name, ip));
209 }
210 if !self.name_server_ips.is_empty() {
211 zone_file.push('\n');
212 }
213
214 // DNS records
215 for record in &self.records {
216 let ttl_str = if let Some(ttl) = record.ttl {
217 format!("{} ", ttl)
218 } else {
219 String::new()
220 };
221
222 let priority_str = if let Some(priority) = record.priority {
223 format!("{} ", priority)
224 } else {
225 String::new()
226 };
227
228 zone_file.push_str(&format!(
229 "{} {}IN {} {}{}\n",
230 record.name, ttl_str, record.record_type, priority_str, record.value
231 ));
232 }
233
234 zone_file
235 }
236}
237
238/// Request to create a new zone
239#[derive(Debug, Serialize, Deserialize)]
240#[cfg_attr(feature = "server", derive(ToSchema))]
241#[serde(rename_all = "camelCase")]
242pub struct CreateZoneRequest {
243 /// Zone name (e.g., "example.com")
244 pub zone_name: String,
245
246 /// Zone type ("primary" or "secondary")
247 pub zone_type: String,
248
249 /// Structured zone configuration
250 pub zone_config: ZoneConfig,
251
252 /// Optional: TSIG key name for allow-update
253 pub update_key_name: Option<String>,
254}
255
256/// Request to modify a zone configuration
257#[derive(Debug, Serialize, Deserialize)]
258#[cfg_attr(feature = "server", derive(ToSchema))]
259#[serde(rename_all = "camelCase")]
260pub struct ModifyZoneRequest {
261 /// IP addresses of secondary servers to notify when zone changes (BIND9 also-notify)
262 /// Example: ["10.244.2.101", "10.244.2.102"]
263 #[serde(skip_serializing_if = "Option::is_none")]
264 pub also_notify: Option<Vec<String>>,
265
266 /// IP addresses allowed to transfer the zone (BIND9 allow-transfer)
267 /// Example: ["10.244.2.101", "10.244.2.102"]
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub allow_transfer: Option<Vec<String>>,
270
271 /// IP addresses allowed to update the zone dynamically (BIND9 allow-update)
272 /// Example: ["10.244.2.101", "10.244.2.102"]
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub allow_update: Option<Vec<String>>,
275
276 /// DNSSEC policy transition (ADR-0001). Merge semantics: omitting the
277 /// field means "leave signing as it is", never "remove".
278 ///
279 /// * a policy name — enable signing with, or switch to, that
280 /// `dnssec-policy` (must be defined in named.conf)
281 /// * `"insecure"` — BIND's built-in policy: gracefully unsign the zone
282 /// (the only way to start turning DNSSEC off on a signed zone)
283 /// * `"none"` — remove the `dnssec-policy` directive entirely; refused
284 /// while the zone still serves DNSKEY records
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub dnssec_policy: Option<String>,
287
288 /// Enable or disable BIND9 inline-signing (ADR-0001). Usually omitted:
289 /// when a policy is set on a zone without dynamic updates,
290 /// inline-signing is enabled implicitly (BIND requires one or the
291 /// other).
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub inline_signing: Option<bool>,
294}
295
296/// Zone status response: the classic text `details` plus a typed DNSSEC
297/// block parsed from `rndc dnssec -status` (ADR-0001).
298#[derive(Debug, Serialize, Deserialize)]
299#[cfg_attr(feature = "server", derive(ToSchema))]
300#[serde(rename_all = "camelCase")]
301pub struct ZoneStatusResponse {
302 /// Whether the status lookup succeeded.
303 pub success: bool,
304 /// Human-readable summary.
305 pub message: String,
306 /// Raw `rndc zonestatus` output.
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub details: Option<String>,
309 /// DNSSEC signing state; omitted when it could not be determined.
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub dnssec: Option<crate::dnssec::DnssecStatus>,
312}
313
314/// One DS record of a zone, ready to publish at the parent (ADR-0001).
315#[derive(Debug, Serialize, Deserialize)]
316#[cfg_attr(feature = "server", derive(ToSchema))]
317#[serde(rename_all = "camelCase")]
318pub struct DsRecordView {
319 /// Key tag of the DNSKEY this DS refers to.
320 pub key_tag: u16,
321 /// DNSSEC algorithm number.
322 pub algorithm: u8,
323 /// DS digest type (2 = SHA-256).
324 pub digest_type: u8,
325 /// Uppercase hex digest.
326 pub digest: String,
327 /// Full record in presentation format, e.g.
328 /// `example.com. IN DS 60485 5 2 <digest>`.
329 pub rr: String,
330}
331
332/// Response of `GET /api/v1/zones/{name}/ds` (ADR-0001).
333#[derive(Debug, Serialize, Deserialize)]
334#[cfg_attr(feature = "server", derive(ToSchema))]
335#[serde(rename_all = "camelCase")]
336pub struct DsSetResponse {
337 /// Whether DS computation succeeded.
338 pub success: bool,
339 /// The zone the DS records belong to.
340 pub zone: String,
341 /// One entry per key-signing key.
342 pub ds_records: Vec<DsRecordView>,
343}
344
345/// Request body of `POST /api/v1/zones/{name}/dnssec/checkds` (ADR-0001):
346/// tells named that the DS for a key was published at, or withdrawn from,
347/// the parent zone — required to complete rollovers and the graceful
348/// `insecure` transition when no parental agents are configured.
349#[derive(Debug, Serialize, Deserialize)]
350#[cfg_attr(feature = "server", derive(ToSchema))]
351#[serde(rename_all = "camelCase")]
352pub struct CheckdsRequest {
353 /// Key tag of the DNSKEY whose DS changed at the parent.
354 pub key_tag: u16,
355 /// The new DS state at the parent.
356 pub ds: crate::dnssec::CheckdsState,
357}
358
359/// Response from zone operations
360#[derive(Debug, Serialize, Deserialize)]
361#[cfg_attr(feature = "server", derive(ToSchema))]
362pub struct ZoneResponse {
363 pub success: bool,
364 pub message: String,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub details: Option<String>,
367}
368
369/// Server status response
370#[derive(Debug, Serialize, Deserialize)]
371#[cfg_attr(feature = "server", derive(ToSchema))]
372pub struct ServerStatusResponse {
373 pub status: String,
374}
375
376/// Zone information
377#[derive(Debug, Serialize, Deserialize)]
378#[cfg_attr(feature = "server", derive(ToSchema))]
379#[serde(rename_all = "camelCase")]
380pub struct ZoneInfo {
381 pub name: String,
382 pub zone_type: String,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub serial: Option<u32>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub file_path: Option<String>,
387}
388
389/// List of zones response
390#[derive(Debug, Serialize, Deserialize)]
391#[cfg_attr(feature = "server", derive(ToSchema))]
392pub struct ZoneListResponse {
393 pub zones: Vec<String>,
394 pub count: usize,
395}