From c876d17e8c176e28d16bb7183fb7b64cff3b0c88 Mon Sep 17 00:00:00 2001 From: NetworkCats Date: Wed, 11 Mar 2026 04:33:01 +0800 Subject: [PATCH] feat: add IPV4_DOMAIN config for dual-stack IP detection --- .env.example | 10 +++++++++- docker-compose.yml | 2 ++ src/config.rs | 22 ++++++++++++++++++++++ src/handlers.rs | 3 +++ src/main.rs | 2 ++ tests/integration.rs | 2 ++ 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 13d3f8b..e1433c7 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,17 @@ DB_UPDATE_INTERVAL_HOURS=24 # Site domain name (used for display/metadata) SITE_DOMAIN=localhost +# IPv4-only domain for dual-stack detection (e.g., noipv6.org) +# Leave empty to disable IPv4 detection for IPv6 users +IPV4_DOMAIN= + # Enable development mode (uses 1.1.1.1 as fallback when CF-Connecting-IP is absent) DEV_MODE=false -# Path to the TLS certificate directory for HAproxy +# Path to the TLS certificate directory for HAProxy (main domain) # The directory must contain origin.pem (combined cert + key) CERT_PATH=./haproxy/certs + +# Path to the TLS certificate directory for the IPv4-only domain +# The directory must contain origin.pem (combined cert + key) +IPV4_CERT_PATH=./haproxy/certs-ipv4 diff --git a/docker-compose.yml b/docker-compose.yml index bc06b8f..35e7ea4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: volumes: - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - ${CERT_PATH:-./haproxy/certs}:/etc/haproxy/certs:ro + - ${IPV4_CERT_PATH:-./haproxy/certs-ipv4}:/etc/haproxy/certs-ipv4:ro depends_on: app: condition: service_healthy @@ -21,6 +22,7 @@ services: - DB_UPDATE_URL=${DB_UPDATE_URL:-https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb} - DB_UPDATE_INTERVAL_HOURS=${DB_UPDATE_INTERVAL_HOURS:-24} - SITE_DOMAIN=${SITE_DOMAIN:-localhost} + - IPV4_DOMAIN=${IPV4_DOMAIN:-} volumes: - db_data:/data healthcheck: diff --git a/src/config.rs b/src/config.rs index 0001a7c..10f76f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ pub struct Config { pub db_update_url: String, pub db_update_interval_hours: u64, pub site_domain: String, + pub ipv4_domain: String, pub dev_mode: bool, } @@ -31,6 +32,8 @@ impl Config { let site_domain = env::var("SITE_DOMAIN").unwrap_or_else(|_| "localhost".to_string()); + let ipv4_domain = env::var("IPV4_DOMAIN").unwrap_or_default(); + let dev_mode = env::var("DEV_MODE") .unwrap_or_default() .eq_ignore_ascii_case("true"); @@ -41,6 +44,7 @@ impl Config { db_update_url, db_update_interval_hours, site_domain, + ipv4_domain, dev_mode, } } @@ -67,6 +71,7 @@ mod tests { "DB_UPDATE_URL", "DB_UPDATE_INTERVAL_HOURS", "SITE_DOMAIN", + "IPV4_DOMAIN", "DEV_MODE", ]; @@ -98,6 +103,7 @@ mod tests { assert!(config.db_update_url.contains("Merged-IP.mmdb")); assert_eq!(config.db_update_interval_hours, 24); assert_eq!(config.site_domain, "localhost"); + assert!(config.ipv4_domain.is_empty()); assert!(!config.dev_mode); } @@ -181,6 +187,22 @@ mod tests { unsafe { remove_var("SITE_DOMAIN") }; } + #[test] + fn custom_ipv4_domain() { + let _guard = lock_env(); + // SAFETY: ENV_LOCK is held. + unsafe { + clear_config_vars(); + set_var("IPV4_DOMAIN", "noipv6.org"); + } + + let config = Config::from_env(); + assert_eq!(config.ipv4_domain, "noipv6.org"); + + // SAFETY: ENV_LOCK is held. + unsafe { remove_var("IPV4_DOMAIN") }; + } + #[test] fn dev_mode_enabled() { let _guard = lock_env(); diff --git a/src/handlers.rs b/src/handlers.rs index 29ff8f6..15631fe 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -25,6 +25,7 @@ const DEV_FALLBACK_IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(1, 1, 1, 1)); pub struct AppState { pub db: SharedDb, pub site_domain: Arc, + pub ipv4_domain: Arc, pub dev_mode: bool, pub openapi_json: Arc, } @@ -36,6 +37,7 @@ struct IndexTemplate { query: String, is_query: bool, site_domain: Arc, + ipv4_domain: Arc, css_version: u64, js_version: u64, asn: String, @@ -571,6 +573,7 @@ pub async fn root( query: query.ip.unwrap_or_default(), is_query, site_domain: state.site_domain.clone(), + ipv4_domain: state.ipv4_domain.clone(), css_version: static_assets::asset_version("style.css"), js_version: static_assets::asset_version("script.js"), asn: format_asn(&info), diff --git a/src/main.rs b/src/main.rs index 9bcecc2..67d9ac6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,11 +39,13 @@ async fn main() { let cancel = CancellationToken::new(); let site_domain: std::sync::Arc = config.site_domain.into(); + let ipv4_domain: std::sync::Arc = config.ipv4_domain.into(); let openapi_json = handlers::build_openapi_json(&site_domain); let state = handlers::AppState { db: shared_db.clone(), site_domain, + ipv4_domain, dev_mode: config.dev_mode, openapi_json, }; diff --git a/tests/integration.rs b/tests/integration.rs index 711d179..1008a07 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -26,9 +26,11 @@ fn build_test_app_with_dev_mode(dev_mode: bool) -> axum::Router { let shared_db = db::new_shared(reader); let site_domain: std::sync::Arc = "test.example.com".into(); let openapi_json = build_openapi_json(&site_domain); + let ipv4_domain: std::sync::Arc = "noipv6.test.example.com".into(); let state = AppState { db: shared_db, site_domain, + ipv4_domain, dev_mode, openapi_json, };