1use serde_json::Value;
4use tracing::instrument;
5
6use crate::control_plane::config::VendorKind;
7use crate::core::dns::capabilities::VendorCapabilities;
8use crate::core::dns::logs::{LogLevel, LogLine, LogsOptions, LogsRead};
9use crate::core::dns::records::RecordData;
10use crate::core::dns::responses::ListRecordsResponse;
11use crate::core::dns::service::{
12 AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
13 RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
14};
15use crate::core::error::{Error, Result};
16use crate::vendors::technitium::client::TechnitiumClient;
17
18impl DnsVendor for TechnitiumClient {
19 fn kind(&self) -> VendorKind {
20 VendorKind::Technitium
21 }
22
23 fn capabilities(&self) -> VendorCapabilities {
24 VendorCapabilities {
25 zones: true,
26 records: true,
27 cache: true,
28 access_lists: true,
29 settings: true,
30 zone_import: true,
31 zone_export: true,
32 logs: true,
33 }
34 }
35}
36
37impl ZoneRead for TechnitiumClient {
38 #[instrument(skip(self), fields(vendor = "technitium", operation = "list_zones"))]
39 async fn list_zones(&self, page: u32, per_page: u32) -> Result<Value> {
40 self.get(
41 "/api/zones/list",
42 &[
43 ("pageNumber", &page.to_string()),
44 ("zonesPerPage", &per_page.to_string()),
45 ],
46 )
47 .await
48 }
49
50 #[instrument(
51 skip(self, options),
52 fields(vendor = "technitium", operation = "list_records")
53 )]
54 async fn list_records(
55 &self,
56 domain: &str,
57 zone: Option<&str>,
58 options: ListRecordsOptions,
59 ) -> Result<ListRecordsResponse> {
60 let query_domain = if options.all_subdomains {
63 zone.unwrap_or(domain)
64 } else {
65 domain
66 };
67 let mut params = vec![("domain", query_domain)];
68 if let Some(z) = zone {
69 params.push(("zone", z));
70 }
71 if options.all_subdomains {
72 params.push(("listZone", "true"));
73 }
74 let raw = self.get("/api/zones/records/get", ¶ms).await?;
75 ListRecordsResponse::from_value(&raw)
76 }
77}
78
79impl ZoneWrite for TechnitiumClient {
80 #[instrument(skip(self), fields(vendor = "technitium", operation = "create_zone"))]
81 async fn create_zone(&self, zone: &str, zone_type: &str) -> Result<Value> {
82 self.post("/api/zones/create", &[("zone", zone), ("type", zone_type)])
83 .await
84 }
85
86 #[instrument(skip(self), fields(vendor = "technitium", operation = "delete_zone"))]
87 async fn delete_zone(&self, zone: &str) -> Result<Value> {
88 self.post("/api/zones/delete", &[("zone", zone)]).await
89 }
90
91 #[instrument(skip(self), fields(vendor = "technitium", operation = "enable_zone"))]
92 async fn enable_zone(&self, zone: &str) -> Result<Value> {
93 self.post("/api/zones/enable", &[("zone", zone)]).await
94 }
95
96 #[instrument(skip(self), fields(vendor = "technitium", operation = "disable_zone"))]
97 async fn disable_zone(&self, zone: &str) -> Result<Value> {
98 self.post("/api/zones/disable", &[("zone", zone)]).await
99 }
100}
101
102impl RecordWrite for TechnitiumClient {
103 #[instrument(
104 skip(self, record),
105 fields(vendor = "technitium", operation = "add_record")
106 )]
107 async fn add_record(
108 &self,
109 zone: &str,
110 domain: &str,
111 ttl: u32,
112 record: &RecordData,
113 ) -> Result<Value> {
114 let ttl_s = ttl.to_string();
115 let type_params = record.to_api_params();
116
117 let mut form: Vec<(&str, &str)> = vec![("zone", zone), ("domain", domain), ("ttl", &ttl_s)];
118 let type_refs: Vec<(&str, &str)> =
119 type_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
120 form.extend(type_refs);
121
122 self.post("/api/zones/records/add", &form).await
123 }
124
125 #[instrument(
126 skip(self, type_params),
127 fields(vendor = "technitium", operation = "delete_record")
128 )]
129 async fn delete_record(
130 &self,
131 zone: &str,
132 domain: &str,
133 type_params: &[(&str, String)],
134 ) -> Result<Value> {
135 let mut form: Vec<(&str, &str)> = vec![("zone", zone), ("domain", domain)];
136 let type_refs: Vec<(&str, &str)> =
137 type_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
138 form.extend(type_refs);
139 self.post("/api/zones/records/delete", &form).await
140 }
141}
142
143impl CacheRead for TechnitiumClient {
144 #[instrument(skip(self), fields(vendor = "technitium", operation = "list_cache"))]
145 async fn list_cache(&self, domain: &str) -> Result<Value> {
146 self.get("/api/cache/list", &[("domain", domain)]).await
147 }
148}
149
150impl CacheWrite for TechnitiumClient {
151 #[instrument(
152 skip(self),
153 fields(vendor = "technitium", operation = "delete_cache_zone")
154 )]
155 async fn delete_cache_zone(&self, domain: &str) -> Result<Value> {
156 self.post("/api/cache/delete", &[("domain", domain)]).await
157 }
158
159 #[instrument(skip(self), fields(vendor = "technitium", operation = "flush_cache"))]
160 async fn flush_cache(&self) -> Result<Value> {
161 self.get("/api/cache/flush", &[]).await
162 }
163}
164
165impl StatsRead for TechnitiumClient {
166 #[instrument(skip(self), fields(vendor = "technitium", operation = "get_stats"))]
167 async fn get_stats(&self, stats_type: &str) -> Result<Value> {
168 self.get("/api/dashboard/stats/get", &[("type", stats_type)])
169 .await
170 }
171}
172
173impl AccessListRead for TechnitiumClient {
174 #[instrument(skip(self), fields(vendor = "technitium", operation = "list_blocked"))]
175 async fn list_blocked(&self) -> Result<Value> {
176 self.get("/api/blocked/list", &[]).await
177 }
178
179 #[instrument(skip(self), fields(vendor = "technitium", operation = "list_allowed"))]
180 async fn list_allowed(&self) -> Result<Value> {
181 self.get("/api/allowed/list", &[]).await
182 }
183}
184
185impl AccessListWrite for TechnitiumClient {
186 #[instrument(skip(self), fields(vendor = "technitium", operation = "add_blocked"))]
187 async fn add_blocked(&self, domain: &str) -> Result<Value> {
188 self.post("/api/blocked/add", &[("domain", domain)]).await
189 }
190
191 #[instrument(
192 skip(self),
193 fields(vendor = "technitium", operation = "delete_blocked")
194 )]
195 async fn delete_blocked(&self, domain: &str) -> Result<Value> {
196 self.post("/api/blocked/delete", &[("domain", domain)])
197 .await
198 }
199
200 #[instrument(skip(self), fields(vendor = "technitium", operation = "add_allowed"))]
201 async fn add_allowed(&self, domain: &str) -> Result<Value> {
202 self.post("/api/allowed/add", &[("domain", domain)]).await
203 }
204
205 #[instrument(
206 skip(self),
207 fields(vendor = "technitium", operation = "delete_allowed")
208 )]
209 async fn delete_allowed(&self, domain: &str) -> Result<Value> {
210 self.post("/api/allowed/delete", &[("domain", domain)])
211 .await
212 }
213}
214
215impl ZoneImport for TechnitiumClient {
216 #[instrument(
217 skip(self, file_bytes),
218 fields(vendor = "technitium", operation = "import_zone_file")
219 )]
220 async fn import_zone_file(
221 &self,
222 zone: &str,
223 file_name: String,
224 file_bytes: Vec<u8>,
225 overwrite: bool,
226 overwrite_zone: bool,
227 overwrite_soa_serial: bool,
228 ) -> Result<Value> {
229 self.post_file(
230 "/api/zones/import",
231 &[
232 ("zone", zone),
233 ("overwrite", if overwrite { "true" } else { "false" }),
234 (
235 "overwriteZone",
236 if overwrite_zone { "true" } else { "false" },
237 ),
238 (
239 "overwriteSoaSerial",
240 if overwrite_soa_serial {
241 "true"
242 } else {
243 "false"
244 },
245 ),
246 ],
247 file_name,
248 file_bytes,
249 )
250 .await
251 }
252}
253
254impl ZoneExport for TechnitiumClient {
255 #[instrument(
256 skip(self),
257 fields(vendor = "technitium", operation = "export_zone_file")
258 )]
259 async fn export_zone_file<'a>(&'a self, zone: &'a str) -> Result<String> {
260 self.get_text("/api/zones/export", &[("zone", zone)]).await
261 }
262}
263
264impl SettingsRead for TechnitiumClient {
265 #[instrument(skip(self), fields(vendor = "technitium", operation = "get_settings"))]
266 async fn get_settings(&self) -> Result<Value> {
267 self.get("/api/settings/get", &[]).await
268 }
269}
270
271impl LogsRead for TechnitiumClient {
272 #[instrument(
273 skip(self, options),
274 fields(vendor = "technitium", operation = "get_logs")
275 )]
276 async fn get_logs(&self, options: LogsOptions) -> Result<Vec<LogLine>> {
277 let raw = self.get("/api/logs/list", &[]).await?;
278 let file_name = latest_log_file_name(&raw)?;
279 let text = self
280 .get_text(
281 "/api/logs/download",
282 &[("fileName", file_name.as_str()), ("limit", "1")],
283 )
284 .await?;
285 Ok(parse_log_file(&text, &options))
286 }
287}
288
289fn latest_log_file_name(raw: &Value) -> Result<String> {
290 let entries = raw["response"]["logFiles"]
291 .as_array()
292 .ok_or_else(|| Error::parse("logs list response missing logFiles array"))?;
293 entries
294 .iter()
295 .filter_map(|entry| entry["fileName"].as_str())
296 .max()
297 .map(ToOwned::to_owned)
298 .ok_or_else(|| Error::parse("logs list response did not include any fileName values"))
299}
300
301fn parse_log_file(text: &str, options: &LogsOptions) -> Vec<LogLine> {
302 let mut lines: Vec<LogLine> = text
303 .lines()
304 .filter_map(parse_log_file_line)
305 .filter(|line| {
306 options
307 .level
308 .map(|level| line.level >= level)
309 .unwrap_or(true)
310 })
311 .filter(|line| {
312 options
313 .start
314 .as_deref()
315 .map(|start| line.timestamp.as_str() >= start)
316 .unwrap_or(true)
317 })
318 .filter(|line| {
319 options
320 .end
321 .as_deref()
322 .map(|end| line.timestamp.as_str() <= end)
323 .unwrap_or(true)
324 })
325 .collect();
326
327 if let Some(requested) = options.lines {
328 let requested = requested as usize;
329 if requested == 0 {
330 lines.clear();
331 } else if lines.len() > requested {
332 lines = lines.split_off(lines.len() - requested);
333 }
334 }
335 lines
336}
337
338fn parse_log_file_line(line: &str) -> Option<LogLine> {
339 let rest = line.strip_prefix('[')?;
340 let (timestamp, rest) = rest.split_once(']')?;
341 let message = rest.trim().to_string();
342 Some(LogLine {
343 timestamp: timestamp.trim().to_string(),
344 level: classify_log_level(&message),
345 title: log_title(&message),
346 message,
347 })
348}
349
350fn classify_log_level(message: &str) -> LogLevel {
351 let lower = message.to_ascii_lowercase();
352 if lower.contains("critical") || lower.contains("fatal") {
353 LogLevel::Critical
354 } else if lower.contains("error")
355 || lower.contains("failed")
356 || lower.contains("refused")
357 || lower.contains("exception")
358 {
359 LogLevel::Error
360 } else if lower.contains("warn") || lower.contains("not allowed") {
361 LogLevel::Warning
362 } else {
363 LogLevel::Info
364 }
365}
366
367fn log_title(message: &str) -> Option<String> {
368 let lower = message.to_ascii_lowercase();
369 let title = if lower.contains("zone transfer") {
370 "zone transfer"
371 } else if lower.contains("notify") {
372 "notify"
373 } else if lower.contains("configuration") {
374 "configuration"
375 } else if lower.contains("new record") {
376 "record"
377 } else {
378 return None;
379 };
380 Some(title.to_string())
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use serde_json::json;
387
388 #[test]
389 fn latest_log_file_name_picks_latest_file() {
390 let raw = json!({
391 "response": {
392 "logFiles": [
393 {"fileName": "2026-05-28", "size": "1 KB"},
394 {"fileName": "2026-05-29", "size": "2 KB"}
395 ]
396 },
397 "status": "ok"
398 });
399
400 assert_eq!(latest_log_file_name(&raw).unwrap(), "2026-05-29");
401 }
402
403 #[test]
404 fn parse_log_file_extracts_and_filters_recent_lines() {
405 let text = "\
406[2026-05-29 05:36:25 Local] [10.2.65.122:0] [admin] New record was added to Primary zone 'hankin.io' successfully
407[2026-05-29 05:36:30 Local] DNS Server failed to notify name server '10.5.161.84' (RCODE=Refused) for zone: hankin.io
408[2026-05-29 05:36:31 Local] Saved zone file for domain: hankin.io
409";
410
411 let lines = parse_log_file(
412 text,
413 &LogsOptions {
414 lines: Some(1),
415 start: None,
416 end: None,
417 level: Some(LogLevel::Error),
418 },
419 );
420
421 assert_eq!(lines.len(), 1);
422 assert_eq!(lines[0].level, LogLevel::Error);
423 assert_eq!(lines[0].title.as_deref(), Some("notify"));
424 assert!(lines[0].message.contains("RCODE=Refused"));
425 }
426
427 #[test]
428 fn parse_log_file_line_ignores_unstructured_lines() {
429 assert!(parse_log_file_line("not a technitium log line").is_none());
430 }
431}