1use serde_json::Value;
4use tracing::instrument;
5
6use crate::control_plane::config::VendorKind;
7use crate::core::dns::capabilities::VendorCapabilities;
8use crate::core::dns::records::RecordData;
9use crate::core::dns::responses::ListRecordsResponse;
10use crate::core::dns::service::{
11 AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
12 RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
13};
14use crate::core::dns::logs::{LogLevel, LogLine, LogsOptions, LogsRead};
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(skip(self, options), fields(vendor = "technitium", operation = "get_logs"))]
273 async fn get_logs(&self, options: LogsOptions) -> Result<Vec<LogLine>> {
274 let lines = options.lines.to_string();
275 let mut params: Vec<(&str, &str)> = vec![("entriesPerPage", &lines)];
276 if let Some(ref s) = options.start { params.push(("start", s)); }
277 if let Some(ref e) = options.end { params.push(("end", e)); }
278 let response_type = match options.level {
279 Some(LogLevel::Critical) | Some(LogLevel::Error) => Some("Dropped"),
280 Some(LogLevel::Warning) => Some("Blocked"),
281 _ => None,
282 };
283 if let Some(rt) = response_type { params.push(("responseType", rt)); }
284 let raw = self.get("/api/log/query", ¶ms).await?;
285 parse_log_lines(&raw)
286 }
287}
288
289fn parse_log_lines(raw: &Value) -> Result<Vec<LogLine>> {
290 let entries = raw["response"]["entries"]
291 .as_array()
292 .ok_or_else(|| Error::parse("log query response missing entries array"))?;
293 let lines = entries.iter().map(|e| {
294 let response_type = e["responseType"].as_str().unwrap_or("");
295 let level = match response_type {
296 "Dropped" => LogLevel::Error,
297 "Blocked" => LogLevel::Warning,
298 "Cached" | "Recursive" | "Authoritative" | "LocallyServed" => LogLevel::Info,
299 _ => LogLevel::Debug,
300 };
301 let name = e["question"]["name"].as_str().unwrap_or("");
302 let qtype = e["question"]["type"].as_str().unwrap_or("");
303 let title = if name.is_empty() { None } else { Some(format!("{name} ({qtype})")) };
304 let rcode = e["rCode"].as_str().unwrap_or("");
305 let client_ip = e["clientIpAddress"].as_str().unwrap_or("");
306 let message = format!("{response_type}: {rcode} from {client_ip}");
307 let timestamp = e["timestamp"].as_str().unwrap_or("").to_string();
308 LogLine { timestamp, level, title, message }
309 }).collect();
310 Ok(lines)
311}