feat(webnsupdate): add support for fritzbox style updates
All checks were successful
/ build (push) Successful in 1s
/ check (clippy) (push) Successful in 2s
/ check (module-ipv4-only-test) (push) Successful in 6s
/ check (module-ipv4-test) (push) Successful in 6s
/ check (module-ipv6-only-test) (push) Successful in 10s
/ check (module-ipv6-test) (push) Successful in 8s
/ check (module-nginx-test) (push) Successful in 10s
/ check (nextest) (push) Successful in 2s
/ check (treefmt) (push) Successful in 3s
/ report-size (push) Successful in 2s

Update now optionally accepts query params (e.g. `/update?ipv4=1.2.3.4`)
if present they will control how the update is made (only ipv4 and ipv6
are implemented right now).

Closes #80
This commit is contained in:
Jalil David Salamé Messina 2025-01-27 18:49:46 +01:00
parent 26566fd612
commit a39fa354e4
Signed by: jalil
GPG key ID: F016B9E770737A0B
3 changed files with 268 additions and 23 deletions

View file

@ -20,6 +20,8 @@ jobs:
- module-ipv4-test - module-ipv4-test
- module-ipv6-test - module-ipv6-test
- module-nginx-test - module-nginx-test
- module-ipv4-only-test
- module-ipv6-only-test
steps: steps:
- uses: https://git.salame.cl/actions/checkout@v4 - uses: https://git.salame.cl/actions/checkout@v4
- name: Check - name: Check

View file

@ -158,18 +158,27 @@
STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"] STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"]
DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"] DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"]
def domain_available(domain: str, record: str): def dig_cmd(domain: str, record: str, ip: str | None) -> str:
machine.succeed(f"dig @localhost {record} {domain} | grep ^{domain}") match_ip = "" if ip is None else f"\\s\\+60\\s\\+IN\\s\\+{record}\\s\\+{ip}$"
return f"dig @localhost {record} {domain} +noall +answer | grep '^{domain}.{match_ip}'"
def domain_missing(domain: str, record: str): def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str:
machine.fail(f"dig @localhost {record} {domain} +noall +noanswer | grep ^{domain}") from urllib.parse import urlencode
q= f"?{urlencode(query)}" if query else ""
return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'"
def update_records(domain: str="localhost", path: str="update"): def domain_available(domain: str, record: str, ip: str | None=None):
machine.succeed(f"{CURL} -u test:test -X GET http://{domain}{"" if NGINX else ":5353"}/{path}") machine.succeed(dig_cmd(domain, record, ip))
def domain_missing(domain: str, record: str, ip: str | None=None):
machine.fail(dig_cmd(domain, record, ip))
def update_records(domain: str="localhost", /, *, path: str="update", **kwargs):
machine.succeed(curl_cmd(domain, "test:test", path, kwargs))
machine.succeed("cat ${lastIPPath}") machine.succeed("cat ${lastIPPath}")
def update_records_fail(domain: str="localhost", identity: str="test:test", path: str="update"): def update_records_fail(domain: str="localhost", /, *, identity: str="test:test", path: str="update", **kwargs):
machine.fail(f"{CURL} -u {identity} -X GET http://{domain}{"" if NGINX else ":5353"}/{path}") machine.fail(curl_cmd(domain, identity, path, kwargs))
machine.fail("cat ${lastIPPath}") machine.fail("cat ${lastIPPath}")
def invalid_update(domain: str="localhost"): def invalid_update(domain: str="localhost"):
@ -181,8 +190,8 @@
with subtest("static DNS records are available"): with subtest("static DNS records are available"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in STATIC_DOMAINS: for domain in STATIC_DOMAINS:
domain_available(domain, "A") # IPv4 domain_available(domain, "A", "127.0.0.1") # IPv4
domain_available(domain, "AAAA") # IPv6 domain_available(domain, "AAAA", "::1") # IPv6
with subtest("dynamic DNS records are missing"): with subtest("dynamic DNS records are missing"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
@ -214,12 +223,50 @@
for domain in DYNAMIC_DOMAINS: for domain in DYNAMIC_DOMAINS:
if IPV4: if IPV4:
domain_available(domain, "A") domain_available(domain, "A", "127.0.0.1")
elif IPV6 and EXCLUSIVE: elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A") domain_missing(domain, "A")
if IPV6: if IPV6:
domain_available(domain, "AAAA") domain_available(domain, "AAAA", "::1")
elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA")
with subtest("valid auth fritzbox compatible updates records"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
if IPV4 and IPV6:
update_records("127.0.0.1", domain="test", ipv4="1.2.3.4", ipv6="::1234")
elif IPV4:
update_records("127.0.0.1", ipv4="1.2.3.4")
elif IPV6:
update_records("[::1]", ipv6="::1234")
for domain in DYNAMIC_DOMAINS:
if IPV4:
domain_available(domain, "A", "1.2.3.4")
elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A")
if IPV6:
domain_available(domain, "AAAA", "::1234")
elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA")
with subtest("valid auth replaces records"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
if IPV4:
update_records("127.0.0.1")
if IPV6:
update_records("[::1]")
for domain in DYNAMIC_DOMAINS:
if IPV4:
domain_available(domain, "A", "127.0.0.1")
elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A")
if IPV6:
domain_available(domain, "AAAA", "::1")
elif IPV4 and EXCLUSIVE: elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA") domain_missing(domain, "AAAA")
@ -231,19 +278,19 @@
with subtest("static DNS records are available after reboot"): with subtest("static DNS records are available after reboot"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in STATIC_DOMAINS: for domain in STATIC_DOMAINS:
domain_available(domain, "A") # IPv4 domain_available(domain, "A", "127.0.0.1") # IPv4
domain_available(domain, "AAAA") # IPv6 domain_available(domain, "AAAA", "::1") # IPv6
with subtest("dynamic DNS records are available after reboot"): with subtest("dynamic DNS records are available after reboot"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in DYNAMIC_DOMAINS: for domain in DYNAMIC_DOMAINS:
if IPV4: if IPV4:
domain_available(domain, "A") domain_available(domain, "A", "127.0.0.1")
elif IPV6 and EXCLUSIVE: elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A") domain_missing(domain, "A")
if IPV6: if IPV6:
domain_available(domain, "AAAA") domain_available(domain, "AAAA", "::1")
elif IPV4 and EXCLUSIVE: elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA") domain_missing(domain, "AAAA")
''; '';

View file

@ -5,7 +5,11 @@ use std::{
time::Duration, time::Duration,
}; };
use axum::{extract::State, routing::get, Router}; use axum::{
extract::{Query, State},
routing::get,
Router,
};
use axum_client_ip::{SecureClientIp, SecureClientIpSource}; use axum_client_ip::{SecureClientIp, SecureClientIpSource};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -410,19 +414,115 @@ fn main() -> Result<()> {
.wrap_err("failed to run main loop") .wrap_err("failed to run main loop")
} }
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct FritzBoxUpdateParams {
/// The domain that should be updated
#[allow(unused)]
#[serde(default)]
domain: Option<String>,
/// IPv4 address for the domain
#[serde(default)]
ipv4: Option<Ipv4Addr>,
/// IPv6 address for the domain
#[serde(default)]
ipv6: Option<Ipv6Addr>,
/// IPv6 prefix for the home network
#[allow(unused)]
#[serde(default)]
ipv6prefix: Option<String>,
/// Whether the networks uses both IPv4 and IPv6
#[allow(unused)]
#[serde(default)]
dualstack: Option<String>,
}
impl FritzBoxUpdateParams {
fn has_data(&self) -> bool {
let Self {
domain,
ipv4,
ipv6,
ipv6prefix,
dualstack,
} = self;
domain.is_some()
| ipv4.is_some()
| ipv6.is_some()
| ipv6prefix.is_some()
| dualstack.is_some()
}
}
#[tracing::instrument(skip(state), level = "trace", ret(level = "info"))] #[tracing::instrument(skip(state), level = "trace", ret(level = "info"))]
async fn update_records( async fn update_records(
State(state): State<AppState<'static>>, State(state): State<AppState<'static>>,
SecureClientIp(ip): SecureClientIp, SecureClientIp(ip): SecureClientIp,
Query(update_params): Query<FritzBoxUpdateParams>,
) -> axum::response::Result<&'static str> { ) -> axum::response::Result<&'static str> {
info!("accepted update from {ip}"); info!("accepted update from {ip}");
if !state.ip_type.valid_for_type(ip) { if !update_params.has_data() {
let ip_type = state.ip_type; if !state.ip_type.valid_for_type(ip) {
tracing::warn!("rejecting update from {ip} as we are running a {ip_type} filter"); tracing::warn!(
return Err((StatusCode::CONFLICT, format!("running in {ip_type} mode")).into()); "rejecting update from {ip} as we are running a {} filter",
state.ip_type
);
return Err((
StatusCode::CONFLICT,
format!("running in {} mode", state.ip_type),
)
.into());
}
return trigger_update(ip, &state).await;
} }
// FIXME: mark suspicious updates (where IP doesn't match the update_ip) and reject them based
// on policy
let FritzBoxUpdateParams {
domain: _,
ipv4,
ipv6,
ipv6prefix: _,
dualstack: _,
} = update_params;
if ipv4.is_none() && ipv6.is_none() {
return Err((
StatusCode::BAD_REQUEST,
"failed to provide an IP for the update",
)
.into());
}
if let Some(ip) = ipv4 {
let ip = IpAddr::V4(ip);
if !state.ip_type.valid_for_type(ip) {
tracing::warn!("requested update of IPv4 but we are {}", state.ip_type);
}
_ = trigger_update(ip, &state).await?;
}
if let Some(ip) = ipv6 {
let ip = IpAddr::V6(ip);
if !state.ip_type.valid_for_type(ip) {
tracing::warn!("requested update of IPv6 but we are {}", state.ip_type);
}
_ = trigger_update(ip, &state).await?;
}
Ok("Successfully updated IP of records!\n")
}
#[tracing::instrument(skip(state), level = "trace", ret(level = "info"))]
async fn trigger_update(
ip: IpAddr,
state: &AppState<'static>,
) -> axum::response::Result<&'static str> {
match nsupdate::nsupdate(ip, state.ttl, state.key_file, state.records).await { match nsupdate::nsupdate(ip, state.ttl, state.key_file, state.records).await {
Ok(status) if status.success() => { Ok(status) if status.success() => {
let ips = { let ips = {
@ -432,16 +532,17 @@ async fn update_records(
ips.clone() ips.clone()
}; };
let ip_file = state.ip_file;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
info!("updating last ips to {ips:?}"); info!("updating last ips to {ips:?}");
let data = serde_json::to_vec(&ips).expect("invalid serialization impl"); let data = serde_json::to_vec(&ips).expect("invalid serialization impl");
if let Err(err) = std::fs::write(state.ip_file, data) { if let Err(err) = std::fs::write(ip_file, data) {
error!("Failed to update last IP: {err}"); error!("Failed to update last IP: {err}");
} }
info!("updated last ips to {ips:?}"); info!("updated last ips to {ips:?}");
}); });
Ok("successful update") Ok("Successfully updated IP of records!\n")
} }
Ok(status) => { Ok(status) => {
error!("nsupdate failed with code {status}"); error!("nsupdate failed with code {status}");
@ -458,3 +559,98 @@ async fn update_records(
.into()), .into()),
} }
} }
#[cfg(test)]
mod parse_query_params {
use axum::extract::Query;
use super::FritzBoxUpdateParams;
#[test]
fn no_params() {
let uri = http::Uri::builder()
.path_and_query("/update")
.build()
.unwrap();
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
insta::assert_debug_snapshot!(query, @r#"
Query(
FritzBoxUpdateParams {
domain: None,
ipv4: None,
ipv6: None,
ipv6prefix: None,
dualstack: None,
},
)
"#);
}
#[test]
fn ipv4() {
let uri = http::Uri::builder()
.path_and_query("/update?ipv4=1.2.3.4")
.build()
.unwrap();
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
insta::assert_debug_snapshot!(query, @r#"
Query(
FritzBoxUpdateParams {
domain: None,
ipv4: Some(
1.2.3.4,
),
ipv6: None,
ipv6prefix: None,
dualstack: None,
},
)
"#);
}
#[test]
fn ipv6() {
let uri = http::Uri::builder()
.path_and_query("/update?ipv6=%3A%3A1234")
.build()
.unwrap();
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
insta::assert_debug_snapshot!(query, @r#"
Query(
FritzBoxUpdateParams {
domain: None,
ipv4: None,
ipv6: Some(
::1234,
),
ipv6prefix: None,
dualstack: None,
},
)
"#);
}
#[test]
fn ipv4_and_ipv6() {
let uri = http::Uri::builder()
.path_and_query("/update?ipv4=1.2.3.4&ipv6=%3A%3A1234")
.build()
.unwrap();
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
insta::assert_debug_snapshot!(query, @r#"
Query(
FritzBoxUpdateParams {
domain: None,
ipv4: Some(
1.2.3.4,
),
ipv6: Some(
::1234,
),
ipv6prefix: None,
dualstack: None,
},
)
"#);
}
}