diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml index 342bcef..b61a4dc 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -20,8 +20,6 @@ jobs: - module-ipv4-test - module-ipv6-test - module-nginx-test - - module-ipv4-only-test - - module-ipv6-only-test steps: - uses: https://git.salame.cl/actions/checkout@v4 - name: Check diff --git a/flake-modules/tests.nix b/flake-modules/tests.nix index 5f0feef..3d06218 100644 --- a/flake-modules/tests.nix +++ b/flake-modules/tests.nix @@ -158,27 +158,18 @@ STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"] DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"] - def dig_cmd(domain: str, record: str, ip: str | None) -> str: - 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_available(domain: str, record: str): + machine.succeed(f"dig @localhost {record} {domain} | grep ^{domain}") - def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str: - 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 domain_missing(domain: str, record: str): + machine.fail(f"dig @localhost {record} {domain} +noall +noanswer | grep ^{domain}") - def domain_available(domain: str, record: str, ip: str | None=None): - 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)) + def update_records(domain: str="localhost", path: str="update"): + machine.succeed(f"{CURL} -u test:test -X GET http://{domain}{"" if NGINX else ":5353"}/{path}") machine.succeed("cat ${lastIPPath}") - def update_records_fail(domain: str="localhost", /, *, identity: str="test:test", path: str="update", **kwargs): - machine.fail(curl_cmd(domain, identity, path, kwargs)) + def update_records_fail(domain: str="localhost", identity: str="test:test", path: str="update"): + machine.fail(f"{CURL} -u {identity} -X GET http://{domain}{"" if NGINX else ":5353"}/{path}") machine.fail("cat ${lastIPPath}") def invalid_update(domain: str="localhost"): @@ -190,8 +181,8 @@ with subtest("static DNS records are available"): print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") for domain in STATIC_DOMAINS: - domain_available(domain, "A", "127.0.0.1") # IPv4 - domain_available(domain, "AAAA", "::1") # IPv6 + domain_available(domain, "A") # IPv4 + domain_available(domain, "AAAA") # IPv6 with subtest("dynamic DNS records are missing"): print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") @@ -223,50 +214,12 @@ for domain in DYNAMIC_DOMAINS: if IPV4: - domain_available(domain, "A", "127.0.0.1") + domain_available(domain, "A") elif IPV6 and EXCLUSIVE: domain_missing(domain, "A") if IPV6: - 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") + domain_available(domain, "AAAA") elif IPV4 and EXCLUSIVE: domain_missing(domain, "AAAA") @@ -278,19 +231,19 @@ with subtest("static DNS records are available after reboot"): print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") for domain in STATIC_DOMAINS: - domain_available(domain, "A", "127.0.0.1") # IPv4 - domain_available(domain, "AAAA", "::1") # IPv6 + domain_available(domain, "A") # IPv4 + domain_available(domain, "AAAA") # IPv6 with subtest("dynamic DNS records are available after reboot"): print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") for domain in DYNAMIC_DOMAINS: if IPV4: - domain_available(domain, "A", "127.0.0.1") + domain_available(domain, "A") elif IPV6 and EXCLUSIVE: domain_missing(domain, "A") if IPV6: - domain_available(domain, "AAAA", "::1") + domain_available(domain, "AAAA") elif IPV4 and EXCLUSIVE: domain_missing(domain, "AAAA") ''; diff --git a/src/main.rs b/src/main.rs index d522390..922b00f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,7 @@ use std::{ time::Duration, }; -use axum::{ - extract::{Query, State}, - routing::get, - Router, -}; +use axum::{extract::State, routing::get, Router}; use axum_client_ip::{SecureClientIp, SecureClientIpSource}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use clap::{Parser, Subcommand}; @@ -414,115 +410,19 @@ fn main() -> Result<()> { .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, - /// IPv4 address for the domain - #[serde(default)] - ipv4: Option, - /// IPv6 address for the domain - #[serde(default)] - ipv6: Option, - /// IPv6 prefix for the home network - #[allow(unused)] - #[serde(default)] - ipv6prefix: Option, - /// Whether the networks uses both IPv4 and IPv6 - #[allow(unused)] - #[serde(default)] - dualstack: Option, -} - -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"))] async fn update_records( State(state): State>, SecureClientIp(ip): SecureClientIp, - Query(update_params): Query, ) -> axum::response::Result<&'static str> { info!("accepted update from {ip}"); - if !update_params.has_data() { - if !state.ip_type.valid_for_type(ip) { - tracing::warn!( - "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; + if !state.ip_type.valid_for_type(ip) { + let ip_type = state.ip_type; + tracing::warn!("rejecting update from {ip} as we are running a {ip_type} filter"); + return Err((StatusCode::CONFLICT, format!("running in {ip_type} mode")).into()); } - // 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 { Ok(status) if status.success() => { let ips = { @@ -532,17 +432,16 @@ async fn trigger_update( ips.clone() }; - let ip_file = state.ip_file; tokio::task::spawn_blocking(move || { info!("updating last ips to {ips:?}"); let data = serde_json::to_vec(&ips).expect("invalid serialization impl"); - if let Err(err) = std::fs::write(ip_file, data) { + if let Err(err) = std::fs::write(state.ip_file, data) { error!("Failed to update last IP: {err}"); } info!("updated last ips to {ips:?}"); }); - Ok("Successfully updated IP of records!\n") + Ok("successful update") } Ok(status) => { error!("nsupdate failed with code {status}"); @@ -559,98 +458,3 @@ async fn trigger_update( .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 = 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 = 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 = 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 = 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, - }, - ) - "#); - } -}