use std::net::{IpAddr, SocketAddr};
use askama::Template;
use askama_web::WebTemplate;
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Redirect, Response},
};
use serde::Deserialize;
use crate::{
storage::forward_zones::ForwardZoneRepository,
web::{
AppState, Chrome,
auth::CurrentUser,
render::{WebError, WebResult},
},
};
impl AppState {
pub(crate) async fn rebuild_forward_zones(&self) -> WebResult<()> {
let rows = self.db.forward_zones().list_enabled().await?;
let set = crate::resolver::forward_zone::ForwardZoneSet::build(&rows, &self.tracker).await;
self.resolver.store_forward_zones(set);
self.resolver.cache().clear();
self.reverse.clear();
Ok(())
}
async fn render_forwarding(
&self,
user: &CurrentUser,
error: Option<String>,
) -> WebResult<ForwardingPageTemplate> {
let zones = self
.db
.forward_zones()
.list()
.await?
.into_iter()
.map(|z| ForwardZoneView {
id: z.id,
zone_suffix: z.zone_suffix,
target: z.target.unwrap_or_default(),
enabled: z.enabled,
})
.collect();
Ok(ForwardingPageTemplate {
chrome: self.chrome("forwarding", user).await,
zones,
error,
})
}
pub async fn forwarding_page(
user: CurrentUser,
State(state): State<AppState>,
) -> WebResult<Response> {
Ok(state.render_forwarding(&user, None).await?.into_response())
}
pub async fn forward_zone_set_target(
user: CurrentUser,
State(state): State<AppState>,
axum::Form(form): axum::Form<SetTargetForm>,
) -> WebResult<Response> {
match state.set_zone_target(form).await {
Ok(()) => Ok(Redirect::to("/forwarding").into_response()),
Err(WebError::BadRequest(msg)) => {
let page = state.render_forwarding(&user, Some(msg)).await?;
Ok((StatusCode::BAD_REQUEST, page).into_response())
}
Err(e) => Err(e),
}
}
async fn set_zone_target(&self, form: SetTargetForm) -> WebResult<()> {
let target = normalize_target(&form.target)?;
self.db
.forward_zones()
.set_target(form.id, target.as_deref())
.await?;
self.rebuild_forward_zones().await
}
pub async fn forward_zone_toggle(
_user: CurrentUser,
State(state): State<AppState>,
axum::Form(form): axum::Form<ToggleZoneForm>,
) -> WebResult<Response> {
state
.db
.forward_zones()
.set_enabled(form.id, form.enabled)
.await?;
state.rebuild_forward_zones().await?;
Ok(Redirect::to("/forwarding").into_response())
}
pub async fn forward_zone_apply_all(
user: CurrentUser,
State(state): State<AppState>,
axum::Form(form): axum::Form<ApplyAllForm>,
) -> WebResult<Response> {
match state.apply_target_to_all(form).await {
Ok(()) => Ok(Redirect::to("/forwarding").into_response()),
Err(WebError::BadRequest(msg)) => {
let page = state.render_forwarding(&user, Some(msg)).await?;
Ok((StatusCode::BAD_REQUEST, page).into_response())
}
Err(e) => Err(e),
}
}
async fn apply_target_to_all(&self, form: ApplyAllForm) -> WebResult<()> {
let Some(target) = normalize_target(&form.target)? else {
return Err(WebError::bad_request(
"Enter the router/DHCP resolver address to forward all reverse zones to.",
));
};
let repo = self.db.forward_zones();
for zone in repo.list().await? {
repo.set_target(zone.id, Some(&target)).await?;
repo.set_enabled(zone.id, true).await?;
}
self.rebuild_forward_zones().await
}
}
fn normalize_target(raw: &str) -> WebResult<Option<String>> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.parse::<IpAddr>().is_ok() || trimmed.parse::<SocketAddr>().is_ok() {
Ok(Some(trimmed.to_owned()))
} else {
Err(WebError::bad_request(
"Target must be an IP address (optionally with :port), e.g. 192.168.1.1 or 192.168.1.1:5353.",
))
}
}
#[derive(Debug, Deserialize)]
pub struct SetTargetForm {
id: i64,
#[serde(default)]
target: String,
}
#[derive(Debug, Deserialize)]
pub struct ToggleZoneForm {
id: i64,
enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct ApplyAllForm {
#[serde(default)]
target: String,
}
struct ForwardZoneView {
id: i64,
zone_suffix: String,
target: String,
enabled: bool,
}
#[derive(Template, WebTemplate)]
#[template(path = "forwarding.html")]
struct ForwardingPageTemplate {
chrome: Chrome,
zones: Vec<ForwardZoneView>,
error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn state() -> (TempDir, AppState) {
let (dir, db) = crate::test_support::temp_db().await;
(dir, AppState::for_test(db).await)
}
#[test]
fn normalize_target_accepts_ip_and_socket() {
assert_eq!(
normalize_target("192.168.1.1").unwrap().as_deref(),
Some("192.168.1.1")
);
assert_eq!(
normalize_target(" 10.0.0.1:5353 ").unwrap().as_deref(),
Some("10.0.0.1:5353")
);
assert_eq!(
normalize_target("fd00::1").unwrap().as_deref(),
Some("fd00::1")
);
}
#[test]
fn normalize_target_blank_is_none() {
assert!(normalize_target(" ").unwrap().is_none());
}
#[test]
fn normalize_target_rejects_garbage() {
assert!(matches!(
normalize_target("router.local"),
Err(WebError::BadRequest(_))
));
}
#[tokio::test]
async fn set_target_then_toggle_persists() {
let (_d, st) = state().await;
let zones = st.db.forward_zones().list().await.unwrap();
let id = zones
.iter()
.find(|z| z.zone_suffix == "168.192.in-addr.arpa")
.unwrap()
.id;
st.set_zone_target(SetTargetForm {
id,
target: "192.168.1.1".to_owned(),
})
.await
.expect("set target");
st.db
.forward_zones()
.set_enabled(id, true)
.await
.expect("enable");
let enabled = st.db.forward_zones().list_enabled().await.unwrap();
assert_eq!(enabled.len(), 1);
assert_eq!(enabled[0].target.as_deref(), Some("192.168.1.1"));
}
#[tokio::test]
async fn zone_edit_flushes_the_dns_cache() {
use crate::codec::{
header::{Header, Rcode},
message::{Qclass, Qtype, Question},
name::Name,
ttl::TtlScan,
writer::Writer,
};
let (_d, st) = state().await;
let qname: Name = "1.1.168.192.in-addr.arpa".parse().unwrap();
let question = Question {
name: qname.clone(),
qtype: Qtype::Ptr,
qclass: Qclass::In,
};
let mut w = Writer::with_capacity(64);
Header::new(0x1234)
.with_qr(true)
.with_rcode(Rcode::NoError)
.with_qdcount(1)
.with_ancount(0)
.write(&mut w);
qname.write(&mut w);
w.write_u16(u16::from(Qtype::Ptr));
w.write_u16(1);
let bytes = w.finish();
let offsets = TtlScan::scan(&bytes)
.map(|s| s.ttl_offsets)
.unwrap_or_default();
st.resolver
.cache()
.insert(question.clone(), bytes, offsets, 300)
.await;
assert!(
st.resolver.cache().get(&question, 0x1).await.is_some(),
"precondition: the stale answer is cached"
);
let id = st
.db
.forward_zones()
.list()
.await
.unwrap()
.into_iter()
.find(|z| z.zone_suffix == "168.192.in-addr.arpa")
.unwrap()
.id;
st.set_zone_target(SetTargetForm {
id,
target: "192.168.1.1".to_owned(),
})
.await
.expect("set target");
st.resolver.cache().run_pending_tasks().await;
assert!(
st.resolver.cache().get(&question, 0x1).await.is_none(),
"a zone edit must flush the stale cached answer so it routes to the new target"
);
}
#[tokio::test]
async fn set_target_rejects_bad_address() {
let (_d, st) = state().await;
let id = st.db.forward_zones().list().await.unwrap()[0].id;
let err = st
.set_zone_target(SetTargetForm {
id,
target: "not-an-ip".to_owned(),
})
.await
.unwrap_err();
assert!(matches!(err, WebError::BadRequest(_)));
}
#[tokio::test]
async fn apply_all_targets_and_enables_every_zone() {
let (_d, st) = state().await;
st.apply_target_to_all(ApplyAllForm {
target: "192.168.1.1".to_owned(),
})
.await
.expect("apply all");
let all = st.db.forward_zones().list().await.unwrap();
let enabled = st.db.forward_zones().list_enabled().await.unwrap();
assert_eq!(
enabled.len(),
all.len(),
"every zone must be enabled and targeted"
);
for z in &enabled {
assert_eq!(z.target.as_deref(), Some("192.168.1.1"));
}
}
#[tokio::test]
async fn apply_all_requires_a_target() {
let (_d, st) = state().await;
let err = st
.apply_target_to_all(ApplyAllForm {
target: " ".to_owned(),
})
.await
.unwrap_err();
assert!(matches!(err, WebError::BadRequest(_)));
}
#[tokio::test]
async fn render_lists_seeded_zones() {
let (_d, st) = state().await;
let user = CurrentUser {
user_id: 1,
session_id: "sess".to_owned(),
};
let page = st.render_forwarding(&user, None).await.expect("render");
assert_eq!(page.zones.len(), 20, "all seeded zones must render");
assert!(page.zones.iter().all(|z| !z.enabled));
}
}