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}