//! **The rig's own DNS-over-HTTPS resolver** (A34-5, RA5-24): a company's e-mail domain
//! leads to its account only after a DNS proof, read from the resolver the operator names.
//! The rig names this one (`GUNNAR_SSO_DNS_RESOLVER`, on loopback over plain http, the
//! `GUNNAR_SSO_ALLOW_LOOPBACK_HTTP` test door), and an SSO row publishes the TXT value the
//! console shows the account admin ([`publish`]), as the admin would at their DNS host.
//!
//! It answers `application/dns-json` (`{"Status":0,"Answer":[{"type":16,"data":…}]}`) for
//! the TXT records published under the asked `name`, and NXDOMAIN (`Status` 3) for any
//! other name. Never a real resolver, never off the loopback.
use std::collections::BTreeMap;
use std::io::{BufRead as _, BufReader, Write as _};
use std::net::TcpListener;
use std::sync::{Mutex, OnceLock};
struct Doh {
url: String,
records: Mutex<BTreeMap<String, Vec<String>>>,
}
fn doh() -> Result<&'static Doh, String> {
static DOH: OnceLock<Result<Doh, String>> = OnceLock::new();
DOH.get_or_init(|| {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("the rig's resolver: bind: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
std::thread::spawn(move || {
let _ = answer(stream);
});
}
});
Ok(Doh { url: format!("http://127.0.0.1:{port}/resolve"), records: Mutex::new(BTreeMap::new()) })
})
.as_ref()
.map_err(Clone::clone)
}
/// The resolver's URL, started on first use.
pub fn url() -> Result<String, String> {
Ok(doh()?.url.clone())
}
/// Publish TXT `value` under `name` (`_sso-proof.<domain>`).
pub fn publish(name: &str, value: &str) -> Result<(), String> {
let doh = doh()?;
let mut all = doh.records.lock().unwrap_or_else(|e| e.into_inner());
all.entry(name.trim().trim_end_matches('.').to_ascii_lowercase()).or_default().push(value.to_owned());
Ok(())
}
fn answer(stream: std::net::TcpStream) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
// The headers, to the blank line: a GET carries no body.
let mut header = String::new();
while reader.read_line(&mut header)? > 2 {
header.clear();
}
let target = line.split_whitespace().nth(1).unwrap_or("");
let name = target
.split_once('?')
.map(|(_, q)| q)
.unwrap_or("")
.split('&')
.find_map(|kv| kv.strip_prefix("name="))
.unwrap_or("")
.trim_end_matches('.')
.to_ascii_lowercase();
let records = doh().ok().and_then(|d| d.records.lock().ok().and_then(|all| all.get(&name).cloned()));
let body = match records {
Some(values) => {
let answers: Vec<String> = values
.iter()
.map(|v| format!("{{\"name\":{},\"type\":16,\"TTL\":60,\"data\":{}}}", crate::json::esc(&format!("{name}.")), crate::json::esc(&format!("\"{v}\""))))
.collect();
format!("{{\"Status\":0,\"Answer\":[{}]}}", answers.join(","))
}
None => "{\"Status\":3}".to_owned(),
};
let mut out = stream;
write!(
out,
"HTTP/1.1 200 OK\r\nContent-Type: application/dns-json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)?;
out.flush()
}