1use std::collections::HashMap;
39use std::sync::Arc;
40use std::time::{SystemTime, UNIX_EPOCH};
41
42use hmac::{Hmac, Mac};
43use sha2::{Digest, Sha256};
44use tatara_lisp_eval::value::MapKey;
45use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
46
47use crate::script_ctx::ScriptCtx;
48
49type HmacSha256 = Hmac<Sha256>;
50
51const FN_UPSERT: &str = "dns/upsert";
52const FN_DELETE: &str = "dns/delete";
53const FN_LIST: &str = "dns/list";
54
55pub fn install(interp: &mut Interpreter<ScriptCtx>) {
60 interp.register_fn(FN_UPSERT, Arity::AtLeast(2), |args: &[Value], ctx: &mut ScriptCtx, sp| {
61 run(Action::Upsert, args, ctx, sp)
62 });
63 interp.register_fn(FN_DELETE, Arity::AtLeast(2), |args: &[Value], ctx: &mut ScriptCtx, sp| {
64 run(Action::Delete, args, ctx, sp)
65 });
66 interp.register_fn(FN_LIST, Arity::AtLeast(2), |args: &[Value], ctx: &mut ScriptCtx, sp| {
67 let kw = Kwargs::parse(args, FN_LIST, sp)?;
68 let provider = kw.provider(FN_LIST, sp)?;
69 let zone_id = kw.want_str("zone-id", FN_LIST, sp)?;
70 let creds = kw.credentials(FN_LIST, sp)?;
71 let transport = UreqTransport::new(ctx);
72 let records = dns_list(&transport, provider, &zone_id, &creds)
73 .map_err(|e| EvalError::native_fn(FN_LIST, e, sp))?;
74 Ok(Value::List(Arc::new(records.into_iter().map(Record::into_value).collect())))
75 });
76}
77
78fn run(action: Action, args: &[Value], ctx: &mut ScriptCtx, sp: tatara_lisp::Span) -> Result<Value, EvalError> {
81 let fn_name = action.fn_name();
82 let kw = Kwargs::parse(args, fn_name, sp)?;
83 let req = ChangeRequest {
84 provider: kw.provider(fn_name, sp)?,
85 zone_id: kw.want_str("zone-id", fn_name, sp)?,
86 credentials: kw.credentials(fn_name, sp)?,
87 record_type: kw.record_type(fn_name, sp)?,
88 name: kw.want_str("name", fn_name, sp)?,
89 value: kw.want_str("value", fn_name, sp)?,
90 ttl: kw.opt_int("ttl").unwrap_or(300),
91 proxied: kw.opt_bool("proxied").unwrap_or(false),
92 action,
93 };
94 let transport = UreqTransport::new(ctx);
95 let outcome = dns_change(&transport, &req).map_err(|e| EvalError::native_fn(fn_name, e, sp))?;
96 Ok(outcome.into_value())
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Provider {
105 Cloudflare,
106 Route53,
107 Hetzner,
108 Gcp,
109}
110
111impl Provider {
112 fn from_keyword(s: &str) -> Option<Self> {
113 match s.to_ascii_lowercase().as_str() {
114 "cloudflare" => Some(Self::Cloudflare),
115 "route53" => Some(Self::Route53),
116 "hetzner" => Some(Self::Hetzner),
117 "gcp" => Some(Self::Gcp),
118 _ => None,
119 }
120 }
121 fn as_str(self) -> &'static str {
122 match self {
123 Self::Cloudflare => "cloudflare",
124 Self::Route53 => "route53",
125 Self::Hetzner => "hetzner",
126 Self::Gcp => "gcp",
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum RecordType {
133 A,
134 Aaaa,
135 Cname,
136 Txt,
137}
138
139impl RecordType {
140 fn from_keyword(s: &str) -> Option<Self> {
144 let norm: String = s.chars().filter(|c| *c != '-').collect::<String>().to_ascii_lowercase();
145 match norm.as_str() {
146 "a" => Some(Self::A),
147 "aaaa" => Some(Self::Aaaa),
148 "cname" => Some(Self::Cname),
149 "txt" => Some(Self::Txt),
150 _ => None,
151 }
152 }
153 fn as_str(self) -> &'static str {
154 match self {
155 Self::A => "A",
156 Self::Aaaa => "AAAA",
157 Self::Cname => "CNAME",
158 Self::Txt => "TXT",
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Action {
165 Upsert,
166 Delete,
167}
168
169impl Action {
170 fn fn_name(self) -> &'static str {
171 match self {
172 Self::Upsert => FN_UPSERT,
173 Self::Delete => FN_DELETE,
174 }
175 }
176 fn route53_verb(self) -> &'static str {
178 match self {
179 Self::Upsert => "UPSERT",
180 Self::Delete => "DELETE",
181 }
182 }
183}
184
185#[derive(Debug, Clone, Default)]
188pub struct Credentials {
189 pub map: HashMap<String, String>,
190}
191
192impl Credentials {
193 fn get(&self, keys: &[&str]) -> Option<&str> {
194 keys.iter().find_map(|k| self.map.get(*k).map(String::as_str))
195 }
196 fn cloudflare_token(&self) -> Option<&str> {
197 self.get(&["api-token", "api_token", "token", "CLOUDFLARE_API_TOKEN"])
198 }
199 fn aws_access_key(&self) -> Option<&str> {
200 self.get(&["access-key-id", "access_key_id", "aws-access-key-id", "AWS_ACCESS_KEY_ID"])
201 }
202 fn aws_secret_key(&self) -> Option<&str> {
203 self.get(&["secret-access-key", "secret_access_key", "aws-secret-access-key", "AWS_SECRET_ACCESS_KEY"])
204 }
205 fn aws_session_token(&self) -> Option<&str> {
206 self.get(&["session-token", "session_token", "aws-session-token", "AWS_SESSION_TOKEN"])
207 }
208 fn aws_region(&self) -> &str {
209 self.get(&["region", "aws-region", "AWS_REGION"]).unwrap_or("us-east-1")
212 }
213}
214
215pub struct ChangeRequest {
216 pub provider: Provider,
217 pub zone_id: String,
218 pub credentials: Credentials,
219 pub record_type: RecordType,
220 pub name: String,
221 pub value: String,
222 pub ttl: i64,
223 pub proxied: bool,
224 pub action: Action,
225}
226
227#[derive(Debug)]
229pub struct Outcome {
230 provider: Provider,
231 action: Action,
232 name: String,
233 record_type: RecordType,
234}
235
236impl Outcome {
237 fn into_value(self) -> Value {
238 map_value(&[
239 ("status", Value::Str(Arc::from("ok"))),
240 ("provider", Value::Str(Arc::from(self.provider.as_str()))),
241 ("action", Value::Str(Arc::from(self.action.route53_verb().to_ascii_lowercase()))),
242 ("name", Value::Str(Arc::from(self.name))),
243 ("type", Value::Str(Arc::from(self.record_type.as_str()))),
244 ])
245 }
246}
247
248#[derive(Debug)]
250pub struct Record {
251 name: String,
252 record_type: String,
253 value: String,
254 ttl: Option<i64>,
255}
256
257impl Record {
258 fn into_value(self) -> Value {
259 let mut fields = vec![
260 ("name", Value::Str(Arc::from(self.name))),
261 ("type", Value::Str(Arc::from(self.record_type))),
262 ("value", Value::Str(Arc::from(self.value))),
263 ];
264 if let Some(ttl) = self.ttl {
265 fields.push(("ttl", Value::Int(ttl)));
266 }
267 map_value(&fields)
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum Method {
277 Get,
278 Post,
279 Put,
280 Delete,
281}
282
283pub struct HttpRequest {
284 pub method: Method,
285 pub url: String,
286 pub headers: Vec<(String, String)>,
287 pub body: Option<String>,
288}
289
290pub struct HttpResponse {
291 pub status: u16,
292 pub body: String,
293}
294
295impl HttpResponse {
296 fn is_success(&self) -> bool {
297 (200..300).contains(&self.status)
298 }
299}
300
301pub trait DnsTransport {
304 fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String>;
305}
306
307pub struct UreqTransport {
309 agent: ureq::Agent,
310}
311
312impl UreqTransport {
313 fn new(ctx: &mut ScriptCtx) -> Self {
314 Self { agent: ctx.http().clone() }
315 }
316}
317
318impl DnsTransport for UreqTransport {
319 fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String> {
320 let ctx = || format!("{} {}", method_str(req.method), req.url);
321 match req.method {
322 Method::Get | Method::Delete => {
323 let mut b = if req.method == Method::Get {
324 self.agent.get(&req.url)
325 } else {
326 self.agent.delete(&req.url)
327 };
328 for (k, v) in &req.headers {
329 b = b.header(k.as_str(), v.as_str());
330 }
331 let resp = b.call().map_err(|e| format!("{}: {e}", ctx()))?;
332 let status = resp.status().as_u16();
333 let body = resp.into_body().read_to_string().map_err(|e| format!("{}: {e}", ctx()))?;
334 Ok(HttpResponse { status, body })
335 }
336 Method::Post | Method::Put => {
337 let mut b = if req.method == Method::Post {
338 self.agent.post(&req.url)
339 } else {
340 self.agent.put(&req.url)
341 };
342 for (k, v) in &req.headers {
343 b = b.header(k.as_str(), v.as_str());
344 }
345 let body = req.body.clone().unwrap_or_default();
346 let resp = b.send(body).map_err(|e| format!("{}: {e}", ctx()))?;
347 let status = resp.status().as_u16();
348 let body = resp.into_body().read_to_string().map_err(|e| format!("{}: {e}", ctx()))?;
349 Ok(HttpResponse { status, body })
350 }
351 }
352 }
353}
354
355fn method_str(m: Method) -> &'static str {
356 match m {
357 Method::Get => "GET",
358 Method::Post => "POST",
359 Method::Put => "PUT",
360 Method::Delete => "DELETE",
361 }
362}
363
364fn dns_change(t: &dyn DnsTransport, req: &ChangeRequest) -> Result<Outcome, String> {
369 match req.provider {
370 Provider::Route53 => route53::change(t, req, now_unix()),
371 Provider::Cloudflare => cloudflare::change(t, req),
372 other => Err(unimplemented(other)),
373 }
374}
375
376fn dns_list(
377 t: &dyn DnsTransport,
378 provider: Provider,
379 zone_id: &str,
380 creds: &Credentials,
381) -> Result<Vec<Record>, String> {
382 match provider {
383 Provider::Route53 => route53::list(t, zone_id, creds, now_unix()),
384 Provider::Cloudflare => cloudflare::list(t, zone_id, creds),
385 other => Err(unimplemented(other)),
386 }
387}
388
389fn unimplemented(p: Provider) -> String {
390 format!(
391 "DNS provider '{}' is declared but not implemented — wire it in tatara-lisp-script/src/stdlib/dns.rs (no silent fallback)",
392 p.as_str()
393 )
394}
395
396fn now_unix() -> u64 {
397 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
398}
399
400mod route53 {
405 use super::*;
406
407 const API_VERSION: &str = "2013-04-01";
408 const HOST: &str = "route53.amazonaws.com";
409 const SERVICE: &str = "route53";
410
411 pub(super) fn change(t: &dyn DnsTransport, req: &ChangeRequest, now: u64) -> Result<Outcome, String> {
412 let zone = bare_zone_id(&req.zone_id);
413 let body = ChangeBatch {
414 action: req.action.route53_verb(),
415 name: &req.name,
416 rtype: req.record_type.as_str(),
417 ttl: req.ttl.max(0),
418 value: &formatted_value(req.record_type, &req.value),
419 }
420 .to_string();
421
422 let path = format!("/{API_VERSION}/hostedzone/{zone}/rrset/");
423 let url = format!("https://{HOST}{path}");
424 let canonical_uri = uri_encode_path(&path);
425 let signer = Sigv4 {
426 method: "POST",
427 host: HOST,
428 canonical_uri: &canonical_uri,
429 canonical_query: "",
430 payload: body.as_bytes(),
431 region: req.credentials.aws_region(),
432 service: SERVICE,
433 access_key: cred_required(req.credentials.aws_access_key(), "access-key-id")?,
434 secret_key: cred_required(req.credentials.aws_secret_key(), "secret-access-key")?,
435 session_token: req.credentials.aws_session_token(),
436 amz: AmzDate::from_unix(now),
437 };
438 let mut headers = signer.signed_headers();
439 headers.push(("content-type".into(), "application/xml".into()));
440
441 let resp = t.send(&HttpRequest {
442 method: Method::Post,
443 url,
444 headers,
445 body: Some(body),
446 })?;
447 if !resp.is_success() {
448 return Err(format!("route53 ChangeResourceRecordSets failed ({}): {}", resp.status, resp.body.trim()));
449 }
450 Ok(Outcome {
451 provider: Provider::Route53,
452 action: req.action,
453 name: req.name.clone(),
454 record_type: req.record_type,
455 })
456 }
457
458 pub(super) fn list(t: &dyn DnsTransport, zone_id: &str, creds: &Credentials, now: u64) -> Result<Vec<Record>, String> {
459 let zone = bare_zone_id(zone_id);
460 let path = format!("/{API_VERSION}/hostedzone/{zone}/rrset");
461 let url = format!("https://{HOST}{path}");
462 let canonical_uri = uri_encode_path(&path);
463 let signer = Sigv4 {
464 method: "GET",
465 host: HOST,
466 canonical_uri: &canonical_uri,
467 canonical_query: "",
468 payload: b"",
469 region: creds.aws_region(),
470 service: SERVICE,
471 access_key: cred_required(creds.aws_access_key(), "access-key-id")?,
472 secret_key: cred_required(creds.aws_secret_key(), "secret-access-key")?,
473 session_token: creds.aws_session_token(),
474 amz: AmzDate::from_unix(now),
475 };
476 let resp = t.send(&HttpRequest {
477 method: Method::Get,
478 url,
479 headers: signer.signed_headers(),
480 body: None,
481 })?;
482 if !resp.is_success() {
483 return Err(format!("route53 ListResourceRecordSets failed ({}): {}", resp.status, resp.body.trim()));
484 }
485 Ok(parse_rrsets(&resp.body))
486 }
487
488 fn bare_zone_id(z: &str) -> &str {
490 z.trim_start_matches("/hostedzone/").trim_start_matches('/')
491 }
492
493 pub(super) fn formatted_value(rtype: RecordType, value: &str) -> String {
495 if rtype == RecordType::Txt && !(value.starts_with('"') && value.ends_with('"')) {
496 format!("\"{}\"", value.replace('"', "\\\""))
497 } else {
498 value.to_string()
499 }
500 }
501
502 fn parse_rrsets(xml: &str) -> Vec<Record> {
506 let mut out = Vec::new();
507 for block in split_between(xml, "<ResourceRecordSet>", "</ResourceRecordSet>") {
508 let name = tag(block, "Name").unwrap_or_default();
509 let rtype = tag(block, "Type").unwrap_or_default();
510 if name.is_empty() || rtype.is_empty() {
511 continue;
512 }
513 let ttl = tag(block, "TTL").and_then(|t| t.trim().parse::<i64>().ok());
514 let value = tag(block, "Value").unwrap_or_default();
515 out.push(Record { name: xml_unescape(&name), record_type: rtype, value: xml_unescape(&value), ttl });
516 }
517 out
518 }
519
520 fn split_between<'a>(s: &'a str, open: &str, close: &str) -> Vec<&'a str> {
521 let mut blocks = Vec::new();
522 let mut rest = s;
523 while let Some(start) = rest.find(open) {
524 let after = &rest[start + open.len()..];
525 if let Some(end) = after.find(close) {
526 blocks.push(&after[..end]);
527 rest = &after[end + close.len()..];
528 } else {
529 break;
530 }
531 }
532 blocks
533 }
534
535 fn tag(block: &str, name: &str) -> Option<String> {
536 let open = format!("<{name}>");
537 let close = format!("</{name}>");
538 let start = block.find(&open)? + open.len();
539 let end = block[start..].find(&close)? + start;
540 Some(block[start..end].to_string())
541 }
542}
543
544struct ChangeBatch<'a> {
548 action: &'a str,
549 name: &'a str,
550 rtype: &'a str,
551 ttl: i64,
552 value: &'a str,
553}
554
555impl std::fmt::Display for ChangeBatch<'_> {
556 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557 write!(f, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
558 write!(f, "<ChangeResourceRecordSetsRequest xmlns=\"https://route53.amazonaws.com/doc/2013-04-01/\">")?;
559 write!(f, "<ChangeBatch><Changes><Change>")?;
560 write!(f, "<Action>{}</Action>", self.action)?;
561 write!(f, "<ResourceRecordSet>")?;
562 write!(f, "<Name>{}</Name>", XmlText(self.name))?;
563 write!(f, "<Type>{}</Type>", self.rtype)?;
564 write!(f, "<TTL>{}</TTL>", self.ttl)?;
565 write!(f, "<ResourceRecords><ResourceRecord><Value>{}</Value></ResourceRecord></ResourceRecords>", XmlText(self.value))?;
566 write!(f, "</ResourceRecordSet>")?;
567 write!(f, "</Change></Changes></ChangeBatch>")?;
568 write!(f, "</ChangeResourceRecordSetsRequest>")
569 }
570}
571
572struct XmlText<'a>(&'a str);
574
575impl std::fmt::Display for XmlText<'_> {
576 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577 for c in self.0.chars() {
578 match c {
579 '&' => f.write_str("&")?,
580 '<' => f.write_str("<")?,
581 '>' => f.write_str(">")?,
582 '"' => f.write_str(""")?,
583 '\'' => f.write_str("'")?,
584 other => f.write_str(other.encode_utf8(&mut [0; 4]))?,
585 }
586 }
587 Ok(())
588 }
589}
590
591fn xml_unescape(s: &str) -> String {
592 s.replace("<", "<")
593 .replace(">", ">")
594 .replace(""", "\"")
595 .replace("'", "'")
596 .replace("&", "&")
597}
598
599mod cloudflare {
604 use super::*;
605
606 const API: &str = "https://api.cloudflare.com/client/v4";
607
608 pub(super) fn change(t: &dyn DnsTransport, req: &ChangeRequest) -> Result<Outcome, String> {
609 let token = cred_required(req.credentials.cloudflare_token(), "api-token")?;
610 let existing = find_record(t, token, &req.zone_id, req.record_type, &req.name)?;
611 match req.action {
612 Action::Upsert => {
613 let payload = serde_json::json!({
614 "type": req.record_type.as_str(),
615 "name": req.name,
616 "content": req.value,
617 "ttl": req.ttl.max(1),
618 "proxied": req.proxied,
619 })
620 .to_string();
621 let (method, url) = match &existing {
622 Some(id) => (Method::Put, format!("{API}/zones/{}/dns_records/{id}", req.zone_id)),
623 None => (Method::Post, format!("{API}/zones/{}/dns_records", req.zone_id)),
624 };
625 let resp = t.send(&HttpRequest { method, url, headers: auth(token), body: Some(payload) })?;
626 check_cf(&resp, "upsert dns_record")?;
627 }
628 Action::Delete => {
629 let Some(id) = existing else {
630 return Ok(outcome(req));
632 };
633 let resp = t.send(&HttpRequest {
634 method: Method::Delete,
635 url: format!("{API}/zones/{}/dns_records/{id}", req.zone_id),
636 headers: auth(token),
637 body: None,
638 })?;
639 check_cf(&resp, "delete dns_record")?;
640 }
641 }
642 Ok(outcome(req))
643 }
644
645 pub(super) fn list(t: &dyn DnsTransport, zone_id: &str, creds: &Credentials) -> Result<Vec<Record>, String> {
646 let token = cred_required(creds.cloudflare_token(), "api-token")?;
647 let resp = t.send(&HttpRequest {
648 method: Method::Get,
649 url: format!("{API}/zones/{zone_id}/dns_records?per_page=100"),
650 headers: auth(token),
651 body: None,
652 })?;
653 let json = check_cf(&resp, "list dns_records")?;
654 let mut out = Vec::new();
655 if let Some(arr) = json.get("result").and_then(|v| v.as_array()) {
656 for r in arr {
657 out.push(Record {
658 name: r.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
659 record_type: r.get("type").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
660 value: r.get("content").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
661 ttl: r.get("ttl").and_then(serde_json::Value::as_i64),
662 });
663 }
664 }
665 Ok(out)
666 }
667
668 fn find_record(
669 t: &dyn DnsTransport,
670 token: &str,
671 zone_id: &str,
672 rtype: RecordType,
673 name: &str,
674 ) -> Result<Option<String>, String> {
675 let resp = t.send(&HttpRequest {
676 method: Method::Get,
677 url: format!("{API}/zones/{zone_id}/dns_records?type={}&name={name}", rtype.as_str()),
678 headers: auth(token),
679 body: None,
680 })?;
681 let json = check_cf(&resp, "lookup dns_record")?;
682 Ok(json
683 .get("result")
684 .and_then(|v| v.as_array())
685 .and_then(|a| a.first())
686 .and_then(|r| r.get("id"))
687 .and_then(|v| v.as_str())
688 .map(str::to_string))
689 }
690
691 fn auth(token: &str) -> Vec<(String, String)> {
692 vec![
693 ("authorization".into(), format!("Bearer {token}")),
694 ("content-type".into(), "application/json".into()),
695 ]
696 }
697
698 fn check_cf(resp: &HttpResponse, what: &str) -> Result<serde_json::Value, String> {
701 let json: serde_json::Value = serde_json::from_str(&resp.body)
702 .map_err(|e| format!("cloudflare {what}: response not JSON ({e})"))?;
703 if !resp.is_success() || json.get("success").and_then(serde_json::Value::as_bool) != Some(true) {
704 let errs = json.get("errors").map(ToString::to_string).unwrap_or_default();
705 return Err(format!("cloudflare {what} failed ({}): {errs}", resp.status));
706 }
707 Ok(json)
708 }
709
710 fn outcome(req: &ChangeRequest) -> Outcome {
711 Outcome {
712 provider: Provider::Cloudflare,
713 action: req.action,
714 name: req.name.clone(),
715 record_type: req.record_type,
716 }
717 }
718}
719
720pub struct Sigv4<'a> {
727 pub method: &'a str,
728 pub host: &'a str,
729 pub canonical_uri: &'a str,
731 pub canonical_query: &'a str,
733 pub payload: &'a [u8],
734 pub region: &'a str,
735 pub service: &'a str,
736 pub access_key: &'a str,
737 pub secret_key: &'a str,
738 pub session_token: Option<&'a str>,
739 pub amz: AmzDate,
740}
741
742impl Sigv4<'_> {
743 pub fn signed_headers(&self) -> Vec<(String, String)> {
748 let (authorization, _) = self.compute();
749 let mut headers = vec![("x-amz-date".to_string(), self.amz.iso.clone())];
750 if let Some(tok) = self.session_token {
751 headers.push(("x-amz-security-token".to_string(), tok.to_string()));
752 }
753 headers.push(("authorization".to_string(), authorization));
754 headers
755 }
756
757 fn compute(&self) -> (String, String) {
759 let payload_hash = sha256_hex(self.payload);
760
761 let mut signed: Vec<(String, String)> = vec![
764 ("host".to_string(), self.host.to_string()),
765 ("x-amz-date".to_string(), self.amz.iso.clone()),
766 ];
767 if let Some(tok) = self.session_token {
768 signed.push(("x-amz-security-token".to_string(), tok.to_string()));
769 }
770 signed.sort_by(|a, b| a.0.cmp(&b.0));
771
772 let canonical_headers: String =
773 signed.iter().map(|(k, v)| format!("{}:{}\n", k, v.trim())).collect();
774 let signed_headers = signed.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";");
775
776 let canonical_request = format!(
777 "{}\n{}\n{}\n{}\n{}\n{}",
778 self.method, self.canonical_uri, self.canonical_query, canonical_headers, signed_headers, payload_hash
779 );
780
781 let scope = format!("{}/{}/{}/aws4_request", self.amz.ymd, self.region, self.service);
782 let string_to_sign = format!(
783 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
784 self.amz.iso,
785 scope,
786 sha256_hex(canonical_request.as_bytes())
787 );
788
789 let signing_key = self.signing_key();
790 let signature = hex::encode(hmac(&signing_key, string_to_sign.as_bytes()));
791
792 let authorization = format!(
793 "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
794 self.access_key, scope, signed_headers, signature
795 );
796 (authorization, signature)
797 }
798
799 fn signing_key(&self) -> Vec<u8> {
800 let k_date = hmac(format!("AWS4{}", self.secret_key).as_bytes(), self.amz.ymd.as_bytes());
801 let k_region = hmac(&k_date, self.region.as_bytes());
802 let k_service = hmac(&k_region, self.service.as_bytes());
803 hmac(&k_service, b"aws4_request")
804 }
805}
806
807fn sha256_hex(data: &[u8]) -> String {
808 let mut h = Sha256::new();
809 h.update(data);
810 hex::encode(h.finalize())
811}
812
813fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
814 let mut m = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts a key of any length");
815 m.update(data);
816 m.finalize().into_bytes().to_vec()
817}
818
819#[derive(Debug, Clone, PartialEq, Eq)]
823pub struct AmzDate {
824 pub ymd: String,
825 pub iso: String,
826}
827
828impl AmzDate {
829 pub fn from_unix(secs: u64) -> Self {
830 let days = (secs / 86_400) as i64;
831 let rem = secs % 86_400;
832 let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
833 let (y, mo, d) = civil_from_days(days);
834 AmzDate {
835 ymd: format!("{y:04}{mo:02}{d:02}"),
836 iso: format!("{y:04}{mo:02}{d:02}T{h:02}{mi:02}{s:02}Z"),
837 }
838 }
839}
840
841fn civil_from_days(z: i64) -> (i64, u32, u32) {
843 let z = z + 719_468;
844 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
845 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
848 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
853}
854
855fn uri_encode_path(path: &str) -> String {
858 let mut out = String::with_capacity(path.len());
859 for b in path.bytes() {
860 match b {
861 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
862 out.push(b as char);
863 }
864 _ => out.push_str(&format!("%{b:02X}")),
865 }
866 }
867 out
868}
869
870struct Kwargs<'a> {
876 map: HashMap<String, &'a Value>,
877}
878
879impl<'a> Kwargs<'a> {
880 fn parse(args: &'a [Value], fn_name: &str, sp: tatara_lisp::Span) -> Result<Self, EvalError> {
881 if args.len() % 2 != 0 {
882 return Err(EvalError::native_fn(
883 fn_name,
884 "expects keyword arguments — got an odd number of values",
885 sp,
886 ));
887 }
888 let mut map = HashMap::new();
889 let mut i = 0;
890 while i < args.len() {
891 let key = match &args[i] {
892 Value::Keyword(k) | Value::Symbol(k) | Value::Str(k) => k.as_ref().to_string(),
893 other => {
894 return Err(EvalError::native_fn(
895 fn_name,
896 format!("argument {} must be a keyword like :provider, got {}", i + 1, other.type_name()),
897 sp,
898 ))
899 }
900 };
901 map.insert(key, &args[i + 1]);
902 i += 2;
903 }
904 Ok(Self { map })
905 }
906
907 fn provider(&self, fn_name: &str, sp: tatara_lisp::Span) -> Result<Provider, EvalError> {
908 let v = self.want("provider", fn_name, sp)?;
909 let s = as_word(v).ok_or_else(|| {
910 EvalError::native_fn(fn_name, ":provider must be a keyword/string", sp)
911 })?;
912 Provider::from_keyword(s)
913 .ok_or_else(|| EvalError::native_fn(fn_name, format!("unknown :provider '{s}'"), sp))
914 }
915
916 fn record_type(&self, fn_name: &str, sp: tatara_lisp::Span) -> Result<RecordType, EvalError> {
917 let v = self.want("record-type", fn_name, sp)?;
918 let s = as_word(v).ok_or_else(|| {
919 EvalError::native_fn(fn_name, ":record-type must be a keyword/string", sp)
920 })?;
921 RecordType::from_keyword(s)
922 .ok_or_else(|| EvalError::native_fn(fn_name, format!("unsupported :record-type '{s}'"), sp))
923 }
924
925 fn credentials(&self, fn_name: &str, sp: tatara_lisp::Span) -> Result<Credentials, EvalError> {
926 let v = self.want("credentials", fn_name, sp)?;
927 match v {
928 Value::Map(m) => {
929 let mut map = HashMap::new();
930 for (k, val) in m.iter() {
931 if let (Value::Str(ks) | Value::Symbol(ks) | Value::Keyword(ks), Value::Str(vs)) =
932 (k.to_value(), val)
933 {
934 map.insert(ks.as_ref().to_string(), vs.as_ref().to_string());
935 }
936 }
937 Ok(Credentials { map })
938 }
939 Value::Nil => Ok(Credentials::default()),
940 other => Err(EvalError::native_fn(
941 fn_name,
942 format!(":credentials must be a map, got {}", other.type_name()),
943 sp,
944 )),
945 }
946 }
947
948 fn want(&self, key: &str, fn_name: &str, sp: tatara_lisp::Span) -> Result<&'a Value, EvalError> {
949 self.map
950 .get(key)
951 .copied()
952 .ok_or_else(|| EvalError::native_fn(fn_name, format!("missing required :{key}"), sp))
953 }
954
955 fn want_str(&self, key: &str, fn_name: &str, sp: tatara_lisp::Span) -> Result<String, EvalError> {
956 match self.want(key, fn_name, sp)? {
957 Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => Ok(s.as_ref().to_string()),
958 other => Err(EvalError::native_fn(
959 fn_name,
960 format!(":{key} must be a string, got {}", other.type_name()),
961 sp,
962 )),
963 }
964 }
965
966 fn opt_int(&self, key: &str) -> Option<i64> {
967 match self.map.get(key)? {
968 Value::Int(n) => Some(*n),
969 Value::Str(s) => s.parse().ok(),
970 _ => None,
971 }
972 }
973
974 fn opt_bool(&self, key: &str) -> Option<bool> {
975 match self.map.get(key)? {
976 Value::Bool(b) => Some(*b),
977 _ => None,
978 }
979 }
980}
981
982fn as_word(v: &Value) -> Option<&str> {
983 match v {
984 Value::Keyword(s) | Value::Symbol(s) | Value::Str(s) => Some(s.as_ref()),
985 _ => None,
986 }
987}
988
989fn cred_required<'a>(v: Option<&'a str>, name: &str) -> Result<&'a str, String> {
990 v.filter(|s| !s.is_empty())
991 .ok_or_else(|| format!("missing credential '{name}' in :credentials map"))
992}
993
994fn map_value(fields: &[(&str, Value)]) -> Value {
995 let mut m = HashMap::new();
996 for (k, v) in fields {
997 m.insert(MapKey::Keyword(Arc::from(*k)), v.clone());
998 }
999 Value::Map(Arc::new(m))
1000}
1001
1002#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use std::cell::RefCell;
1010
1011 #[test]
1014 fn amz_date_epoch() {
1015 let d = AmzDate::from_unix(0);
1016 assert_eq!(d.ymd, "19700101");
1017 assert_eq!(d.iso, "19700101T000000Z");
1018 }
1019
1020 #[test]
1021 fn amz_date_aws_example_instant() {
1022 let secs = 1_440_938_160;
1024 let d = AmzDate::from_unix(secs);
1025 assert_eq!(d.ymd, "20150830");
1026 assert_eq!(d.iso, "20150830T123600Z");
1027 }
1028
1029 #[test]
1030 fn amz_date_leap_day() {
1031 let d = AmzDate::from_unix(1_583_020_799);
1033 assert_eq!(d.iso, "20200229T235959Z");
1034 }
1035
1036 #[test]
1044 fn sigv4_matches_aws_get_vanilla_vector() {
1045 let sig = Sigv4 {
1046 method: "GET",
1047 host: "example.amazonaws.com",
1048 canonical_uri: "/",
1049 canonical_query: "",
1050 payload: b"",
1051 region: "us-east-1",
1052 service: "service",
1053 access_key: "AKIDEXAMPLE",
1054 secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1055 session_token: None,
1056 amz: AmzDate { ymd: "20150830".into(), iso: "20150830T123600Z".into() },
1057 };
1058 let (authz, signature) = sig.compute();
1059 assert_eq!(signature, "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31");
1060 assert_eq!(
1061 authz,
1062 "AWS4-HMAC-SHA256 \
1063 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
1064 SignedHeaders=host;x-amz-date, \
1065 Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
1066 );
1067 }
1068
1069 #[test]
1073 fn sigv4_route53_post_vector() {
1074 let sig = Sigv4 {
1075 method: "POST",
1076 host: "route53.amazonaws.com",
1077 canonical_uri: "/2013-04-01/hostedzone/Z123/rrset/",
1078 canonical_query: "",
1079 payload: b"<x/>",
1080 region: "us-east-1",
1081 service: "route53",
1082 access_key: "AKIDEXAMPLE",
1083 secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1084 session_token: None,
1085 amz: AmzDate { ymd: "20150830".into(), iso: "20150830T123600Z".into() },
1086 };
1087 let (_authz, signature) = sig.compute();
1088 assert_eq!(signature, "27c5bd71813cc981dab5360ffe92bb03008c96db0ece56c2a3b9acea5e19922a");
1089 }
1090
1091 #[test]
1092 fn sigv4_session_token_is_signed() {
1093 let sig = Sigv4 {
1094 method: "POST",
1095 host: "route53.amazonaws.com",
1096 canonical_uri: "/2013-04-01/hostedzone/Z123/rrset/",
1097 canonical_query: "",
1098 payload: b"<x/>",
1099 region: "us-east-1",
1100 service: "route53",
1101 access_key: "AKID",
1102 secret_key: "SECRET",
1103 session_token: Some("FwoTOKEN=="),
1104 amz: AmzDate { ymd: "20260524".into(), iso: "20260524T000000Z".into() },
1105 };
1106 let headers = sig.signed_headers();
1107 assert!(headers.iter().any(|(k, v)| k == "x-amz-security-token" && v == "FwoTOKEN=="));
1108 let authz = headers.iter().find(|(k, _)| k == "authorization").unwrap();
1109 assert!(authz.1.contains("SignedHeaders=host;x-amz-date;x-amz-security-token"));
1110 }
1111
1112 #[test]
1115 fn change_batch_xml_upsert() {
1116 let xml = ChangeBatch {
1117 action: "UPSERT",
1118 name: "akeyless-saas.ab12cd34.pleme-dev.use1.quero.lol",
1119 rtype: "CNAME",
1120 ttl: 60,
1121 value: "tunnel.cfargotunnel.com",
1122 }
1123 .to_string();
1124 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
1125 assert!(xml.contains("<Action>UPSERT</Action>"));
1126 assert!(xml.contains("<Name>akeyless-saas.ab12cd34.pleme-dev.use1.quero.lol</Name>"));
1127 assert!(xml.contains("<Type>CNAME</Type>"));
1128 assert!(xml.contains("<TTL>60</TTL>"));
1129 assert!(xml.contains("<Value>tunnel.cfargotunnel.com</Value>"));
1130 }
1131
1132 #[test]
1133 fn change_batch_xml_escapes_values() {
1134 let xml = ChangeBatch { action: "UPSERT", name: "a&b", rtype: "TXT", ttl: 300, value: "x<y>\"z\"" }
1135 .to_string();
1136 assert!(xml.contains("<Name>a&b</Name>"));
1137 assert!(xml.contains("<y>"));
1138 assert!(xml.contains(""z""));
1139 }
1140
1141 #[derive(Default)]
1144 struct MockTransport {
1145 responses: RefCell<Vec<HttpResponse>>,
1146 seen: RefCell<Vec<(Method, String, Vec<(String, String)>, Option<String>)>>,
1147 }
1148 impl MockTransport {
1149 fn with(responses: Vec<(u16, &str)>) -> Self {
1150 Self {
1151 responses: RefCell::new(
1152 responses.into_iter().rev().map(|(s, b)| HttpResponse { status: s, body: b.to_string() }).collect(),
1153 ),
1154 seen: RefCell::new(Vec::new()),
1155 }
1156 }
1157 }
1158 impl DnsTransport for MockTransport {
1159 fn send(&self, req: &HttpRequest) -> Result<HttpResponse, String> {
1160 self.seen
1161 .borrow_mut()
1162 .push((req.method, req.url.clone(), req.headers.clone(), req.body.clone()));
1163 self.responses.borrow_mut().pop().ok_or_else(|| "mock: no response queued".to_string())
1164 }
1165 }
1166
1167 fn route53_creds() -> Credentials {
1168 let mut map = HashMap::new();
1169 map.insert("access-key-id".to_string(), "AKIDEXAMPLE".to_string());
1170 map.insert("secret-access-key".to_string(), "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string());
1171 Credentials { map }
1172 }
1173
1174 #[test]
1175 fn route53_upsert_signs_and_posts() {
1176 let t = MockTransport::with(vec![(200, "<ChangeResourceRecordSetsResponse/>")]);
1177 let req = ChangeRequest {
1178 provider: Provider::Route53,
1179 zone_id: "/hostedzone/Z3M3LMPEXAMPLE".into(),
1180 credentials: route53_creds(),
1181 record_type: RecordType::Cname,
1182 name: "x.pleme-dev.use1.quero.lol".into(),
1183 value: "tunnel.cfargotunnel.com".into(),
1184 ttl: 60,
1185 proxied: false,
1186 action: Action::Upsert,
1187 };
1188 let outcome = route53::change(&t, &req, 1_440_938_160).expect("upsert ok");
1189 assert_eq!(outcome.provider, Provider::Route53);
1190
1191 let seen = t.seen.borrow();
1192 assert_eq!(seen.len(), 1);
1193 let (method, url, headers, body) = &seen[0];
1194 assert_eq!(*method, Method::Post);
1195 assert_eq!(url, "https://route53.amazonaws.com/2013-04-01/hostedzone/Z3M3LMPEXAMPLE/rrset/");
1197 assert!(headers.iter().any(|(k, v)| k == "authorization" && v.starts_with("AWS4-HMAC-SHA256 ")));
1198 assert!(headers.iter().any(|(k, _)| k == "x-amz-date"));
1199 assert!(body.as_ref().unwrap().contains("<Action>UPSERT</Action>"));
1200 }
1201
1202 #[test]
1203 fn route53_upsert_surfaces_api_error() {
1204 let t = MockTransport::with(vec![(403, "<ErrorResponse><Error><Code>AccessDenied</Code></Error></ErrorResponse>")]);
1205 let req = ChangeRequest {
1206 provider: Provider::Route53,
1207 zone_id: "Z1".into(),
1208 credentials: route53_creds(),
1209 record_type: RecordType::A,
1210 name: "x".into(),
1211 value: "1.2.3.4".into(),
1212 ttl: 60,
1213 proxied: false,
1214 action: Action::Upsert,
1215 };
1216 let err = route53::change(&t, &req, 0).unwrap_err();
1217 assert!(err.contains("403"), "expected status in error: {err}");
1218 assert!(err.contains("AccessDenied"), "expected body in error: {err}");
1219 }
1220
1221 #[test]
1222 fn route53_missing_creds_is_typed_error() {
1223 let t = MockTransport::with(vec![]);
1224 let req = ChangeRequest {
1225 provider: Provider::Route53,
1226 zone_id: "Z1".into(),
1227 credentials: Credentials::default(),
1228 record_type: RecordType::A,
1229 name: "x".into(),
1230 value: "1.2.3.4".into(),
1231 ttl: 60,
1232 proxied: false,
1233 action: Action::Upsert,
1234 };
1235 let err = route53::change(&t, &req, 0).unwrap_err();
1236 assert!(err.contains("access-key-id"), "{err}");
1237 }
1238
1239 #[test]
1240 fn cloudflare_upsert_creates_when_absent() {
1241 let t = MockTransport::with(vec![
1243 (200, r#"{"success":true,"errors":[],"result":[]}"#),
1244 (200, r#"{"success":true,"errors":[],"result":{"id":"new"}}"#),
1245 ]);
1246 let mut map = HashMap::new();
1247 map.insert("api-token".to_string(), "cf-token".to_string());
1248 let req = ChangeRequest {
1249 provider: Provider::Cloudflare,
1250 zone_id: "zone1".into(),
1251 credentials: Credentials { map },
1252 record_type: RecordType::Cname,
1253 name: "x.quero.lol".into(),
1254 value: "target.example".into(),
1255 ttl: 60,
1256 proxied: false,
1257 action: Action::Upsert,
1258 };
1259 cloudflare::change(&t, &req).expect("cf upsert ok");
1260 let seen = t.seen.borrow();
1261 assert_eq!(seen.len(), 2);
1262 assert_eq!(seen[0].0, Method::Get); assert_eq!(seen[1].0, Method::Post); assert!(seen[0].2.iter().any(|(k, v)| k == "authorization" && v == "Bearer cf-token"));
1265 }
1266
1267 #[test]
1268 fn cloudflare_upsert_updates_when_present() {
1269 let t = MockTransport::with(vec![
1270 (200, r#"{"success":true,"errors":[],"result":[{"id":"abc123"}]}"#),
1271 (200, r#"{"success":true,"errors":[],"result":{"id":"abc123"}}"#),
1272 ]);
1273 let mut map = HashMap::new();
1274 map.insert("token".to_string(), "cf-token".to_string());
1275 let req = ChangeRequest {
1276 provider: Provider::Cloudflare,
1277 zone_id: "zone1".into(),
1278 credentials: Credentials { map },
1279 record_type: RecordType::A,
1280 name: "x.quero.lol".into(),
1281 value: "1.2.3.4".into(),
1282 ttl: 120,
1283 proxied: true,
1284 action: Action::Upsert,
1285 };
1286 cloudflare::change(&t, &req).expect("cf update ok");
1287 let seen = t.seen.borrow();
1288 assert_eq!(seen[1].0, Method::Put); assert!(seen[1].1.ends_with("/dns_records/abc123"));
1290 }
1291
1292 #[test]
1295 fn record_type_accepts_both_keyword_dialects() {
1296 for s in ["A", "a"] {
1297 assert_eq!(RecordType::from_keyword(s), Some(RecordType::A));
1298 }
1299 for s in ["AAAA", "aaaa", "a-a-a-a"] {
1300 assert_eq!(RecordType::from_keyword(s), Some(RecordType::Aaaa));
1301 }
1302 for s in ["CNAME", "cname", "c-n-a-m-e"] {
1303 assert_eq!(RecordType::from_keyword(s), Some(RecordType::Cname));
1304 }
1305 for s in ["TXT", "t-x-t"] {
1306 assert_eq!(RecordType::from_keyword(s), Some(RecordType::Txt));
1307 }
1308 assert_eq!(RecordType::from_keyword("mx"), None);
1309 }
1310
1311 #[test]
1312 fn unimplemented_providers_error_not_silent() {
1313 let t = MockTransport::with(vec![]);
1314 for p in [Provider::Hetzner, Provider::Gcp] {
1315 let req = ChangeRequest {
1316 provider: p,
1317 zone_id: "z".into(),
1318 credentials: Credentials::default(),
1319 record_type: RecordType::A,
1320 name: "x".into(),
1321 value: "1.2.3.4".into(),
1322 ttl: 60,
1323 proxied: false,
1324 action: Action::Upsert,
1325 };
1326 let err = dns_change(&t, &req).unwrap_err();
1327 assert!(err.contains("not implemented"), "{err}");
1328 }
1329 }
1330
1331 #[test]
1332 fn txt_values_get_route53_quoting() {
1333 let xml = ChangeBatch {
1334 action: "UPSERT",
1335 name: "_acme.quero.lol",
1336 rtype: "TXT",
1337 ttl: 60,
1338 value: &route53::formatted_value(RecordType::Txt, "token123"),
1339 }
1340 .to_string();
1341 assert!(xml.contains("<Value>"token123"</Value>"), "{xml}");
1342 }
1343
1344 #[test]
1345 fn route53_list_parses_rrsets() {
1346 let xml = r#"<ListResourceRecordSetsResponse>
1347 <ResourceRecordSets>
1348 <ResourceRecordSet><Name>a.quero.lol.</Name><Type>A</Type><TTL>60</TTL>
1349 <ResourceRecords><ResourceRecord><Value>1.2.3.4</Value></ResourceRecord></ResourceRecords>
1350 </ResourceRecordSet>
1351 <ResourceRecordSet><Name>cn.quero.lol.</Name><Type>CNAME</Type><TTL>300</TTL>
1352 <ResourceRecords><ResourceRecord><Value>tgt.example</Value></ResourceRecord></ResourceRecords>
1353 </ResourceRecordSet>
1354 </ResourceRecordSets>
1355 </ListResourceRecordSetsResponse>"#;
1356 let t = MockTransport::with(vec![(200, xml)]);
1357 let recs = route53::list(&t, "Z1", &route53_creds(), 0).unwrap();
1358 assert_eq!(recs.len(), 2);
1359 assert_eq!(recs[0].name, "a.quero.lol.");
1360 assert_eq!(recs[0].record_type, "A");
1361 assert_eq!(recs[0].value, "1.2.3.4");
1362 assert_eq!(recs[0].ttl, Some(60));
1363 assert_eq!(recs[1].record_type, "CNAME");
1364 }
1365}