diff --git a/i18n/en/cachyos_hello.ftl b/i18n/en/cachyos_hello.ftl index d6ae6f6..125b120 100644 --- a/i18n/en/cachyos_hello.ftl +++ b/i18n/en/cachyos_hello.ftl @@ -36,6 +36,9 @@ dot-tooltip = Encrypt DNS queries using TLS for improved privacy (requires serve enable-doh = Enable DNS over HTTPS (DoH) doh-tooltip = Encrypt DNS queries using HTTPS via blocky local proxy (requires server support, installs blocky) doh-blocky-install-failed = Failed to install blocky for DoH support! +enable-doq = Enable DNS over QUIC (DoQ) +doq-tooltip = Encrypt DNS queries using QUIC via blocky local proxy (requires server support, installs blocky) +doq-blocky-install-failed = Failed to install blocky for DoQ support! test-latency = Test Latency of Selected Server test-latency-tooltip = Measure network latency to the selected DNS server best-server = Select Best Server by Latency @@ -54,6 +57,8 @@ custom-dns-invalid = Please enter at least an IPv4 or IPv6 address custom-dns-invalid-hostname = Invalid DoT hostname custom-dns-doh-url = DoH URL (for DNS over HTTPS): custom-dns-doh-url-required = Please enter a valid DoH URL starting with https:// +custom-dns-doq-endpoint = DoQ endpoint (for DNS over QUIC): +custom-dns-doq-endpoint-required = Please enter a valid DoQ endpoint starting with quic: or quic:// dns-check-hint = After applying, verify your DNS provider at dns-server-changed = DNS server was successfully changed! dns-server-failed = Failed to set DNS server! diff --git a/src/actions.rs b/src/actions.rs index d13b2dc..0ba6ea1 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -152,7 +152,7 @@ pub fn change_dns_server( } pub fn reset_dns_server(conn_name: &str, dialog_tx: Sender) { - // Stop blocky if it was running (DoH mode) + // Stop blocky if it was running (DoH/DoQ mode) stop_blocky(); let result = (|| -> anyhow::Result<()> { @@ -185,24 +185,31 @@ pub fn reset_dns_server(conn_name: &str, dialog_tx: Sender) { } } -/// Set DNS to use `DoH` via blocky local proxy. +/// Set DNS to use an encrypted upstream via blocky local proxy. /// Installs blocky if needed, writes its config, starts the service, and points NM to 127.0.0.1. -pub fn change_dns_server_doh( +#[allow(clippy::too_many_arguments)] +pub fn change_dns_server_blocky( callback: RunCmdCallback, conn_name: &str, - doh_url: &str, + mode: dns::BlockyMode, + upstream: &str, bootstrap_ipv4: &str, bootstrap_ipv6: &str, dot_hostname: Option<&str>, dialog_tx: Sender, ) { + let install_failed_msg = match mode { + dns::BlockyMode::Doh => fl!("doh-blocky-install-failed"), + dns::BlockyMode::Doq => fl!("doq-blocky-install-failed"), + }; + // 1. Install blocky if not present if !utils::is_alpm_pkg_installed("blocky") { const ALPM_PACKAGE_NAMES: [&str; 1] = ["blocky"]; install_needed_packages( callback, &ALPM_PACKAGE_NAMES, - fl!("doh-blocky-install-failed"), + install_failed_msg, Action::SetDnsServer, dialog_tx.clone(), ); @@ -212,7 +219,8 @@ pub fn change_dns_server_doh( } // 2. Generate and write blocky config - let config = dns::generate_blocky_config(doh_url, bootstrap_ipv4, bootstrap_ipv6, dot_hostname); + let config = + dns::generate_blocky_config(upstream, bootstrap_ipv4, bootstrap_ipv6, dot_hostname); let write_result = (|| -> anyhow::Result<()> { let mut tmp = tempfile::NamedTempFile::new()?; @@ -272,13 +280,13 @@ pub fn change_dns_server_doh( } } -/// Stop blocky if it's running (used during reset or when switching away from `DoH`). +/// Stop blocky if it's running (used during reset or when switching away from encrypted DNS). pub fn stop_blocky() { let _ = systemd_units::systemd_stop(dns::BLOCKY_SERVICE, Scope::System); let _ = systemd_units::systemd_disable(&[dns::BLOCKY_SERVICE], Scope::System); } -/// Returns true if blocky is currently active. +/// Returns true if blocky encrypted DNS proxy is currently active. pub fn is_blocky_active() -> bool { systemd_units::systemd_is_active(dns::BLOCKY_SERVICE, Scope::System).unwrap_or(false) } diff --git a/src/cli_handler.rs b/src/cli_handler.rs index ef10dcf..8ae52b6 100644 --- a/src/cli_handler.rs +++ b/src/cli_handler.rs @@ -75,51 +75,108 @@ pub fn handle_tweak_command(action: TweakAction) -> Result<()> { } } +fn blocky_mode_label(mode: dns::BlockyMode) -> &'static str { + match mode { + dns::BlockyMode::Doh => "DoH", + dns::BlockyMode::Doq => "DoQ", + } +} + +fn apply_preset_blocky( + connection: &str, + server_name: &str, + server_addr: &dns::DnsEntry, + mode: dns::BlockyMode, + tx: async_channel::Sender, +) { + if let Some(upstream) = dns::get_blocky_upstream(server_name, mode) { + println!( + "Setting DNS for '{}' to '{}' ({} enabled via blocky)...", + connection.cyan(), + server_name.cyan(), + blocky_mode_label(mode), + ); + actions::change_dns_server_blocky( + crate::cli::run_command, + connection, + mode, + upstream, + server_addr.0, + server_addr.1, + server_addr.2, + tx, + ); + } else { + println!( + "{}: DNS over {} is not supported by '{}'.", + "Warning".yellow(), + blocky_mode_label(mode), + server_name + ); + println!("Setting DNS without {}...", blocky_mode_label(mode)); + actions::change_dns_server( + connection, + server_addr.0, + server_addr.1, + false, + server_addr.2.unwrap_or(""), + tx, + ); + } +} + +fn apply_custom_blocky( + connection: &str, + upstream: &str, + ipv4: &str, + ipv6: &str, + dot_hostname: &str, + mode: dns::BlockyMode, + tx: async_channel::Sender, +) { + let dot_suffix = if dot_hostname.is_empty() { + String::new() + } else { + format!(" DoT bootstrap={dot_hostname}") + }; + println!( + "Setting custom {} DNS for '{}': upstream='{}' (bootstrap: IPv4='{}' IPv6='{}'{})...", + blocky_mode_label(mode), + connection.cyan(), + upstream.cyan(), + if ipv4.is_empty() { "(none)" } else { ipv4 }, + if ipv6.is_empty() { "(none)" } else { ipv6 }, + dot_suffix, + ); + let dot_host = if dot_hostname.is_empty() { None } else { Some(dot_hostname) }; + actions::change_dns_server_blocky( + crate::cli::run_command, + connection, + mode, + upstream, + ipv4, + ipv6, + dot_host, + tx, + ); +} + pub fn handle_dns_command(action: DnsAction) -> Result<()> { let (tx, rx) = async_channel::unbounded(); match action { - DnsAction::Set { connection, server, dot, doh } => { + DnsAction::Set { connection, server, dot, doh, doq } => { let server_name = server.as_str(); let server_addr = dns::G_DNS_SERVERS.get(server_name).unwrap(); - if doh { - // DoH mode via blocky - let doh_url = dns::get_doh_url(server_name); - if let Some(url) = doh_url { - println!( - "Setting DNS for '{}' to '{}' (DoH enabled via blocky)...", - connection.cyan(), - server_name.cyan(), - ); - actions::change_dns_server_doh( - crate::cli::run_command, - &connection, - url, - server_addr.0, - server_addr.1, - server_addr.2, - tx, - ); - } else { - println!( - "{}: DNS over HTTPS is not supported by '{}'.", - "Warning".yellow(), - server_name - ); - println!("Setting DNS without DoH..."); - let dot_hostname = server_addr.2.unwrap_or(""); - actions::change_dns_server( - &connection, - server_addr.0, - server_addr.1, - false, - dot_hostname, - tx, - ); - } + if let Some(mode) = match (doh, doq) { + (true, _) => Some(dns::BlockyMode::Doh), + (_, true) => Some(dns::BlockyMode::Doq), + _ => None, + } { + apply_preset_blocky(&connection, server_name, server_addr, mode, tx); } else { - // Stop blocky if switching away from DoH + // Stop blocky if switching away from encrypted DNS actions::stop_blocky(); let dot_supported = server_addr.2.is_some(); @@ -151,7 +208,17 @@ pub fn handle_dns_command(action: DnsAction) -> Result<()> { ); } }, - DnsAction::SetCustom { connection, ipv4, ipv6, dot, dot_hostname, doh, doh_url } => { + DnsAction::SetCustom { + connection, + ipv4, + ipv6, + dot, + dot_hostname, + doh, + doh_url, + doq, + doq_endpoint, + } => { if ipv4.is_empty() && ipv6.is_empty() { eprintln!("{}: At least one of --ipv4 or --ipv6 must be provided.", "Error".red()); std::process::exit(1); @@ -162,36 +229,38 @@ pub fn handle_dns_command(action: DnsAction) -> Result<()> { } if doh { - if doh_url.is_empty() || !doh_url.starts_with("https://") { + if !dns::is_valid_doh_url(&doh_url) { eprintln!("{}: --doh-url must be a valid https:// URL.", "Error".red()); std::process::exit(1); } - println!( - "Setting custom DoH DNS for '{}': URL='{}' (bootstrap: IPv4='{}' \ - IPv6='{}'{})...", - connection.cyan(), - doh_url.cyan(), - if ipv4.is_empty() { "(none)" } else { &ipv4 }, - if ipv6.is_empty() { "(none)" } else { &ipv6 }, - if dot_hostname.is_empty() { - String::new() - } else { - format!(" DoT bootstrap={dot_hostname}") - }, - ); - let dot_host = - if dot_hostname.is_empty() { None } else { Some(dot_hostname.as_str()) }; - actions::change_dns_server_doh( - crate::cli::run_command, + apply_custom_blocky( &connection, &doh_url, &ipv4, &ipv6, - dot_host, + &dot_hostname, + dns::BlockyMode::Doh, + tx, + ); + } else if doq { + if !dns::is_valid_doq_endpoint(&doq_endpoint) { + eprintln!( + "{}: --doq-endpoint must start with quic: or quic://.", + "Error".red() + ); + std::process::exit(1); + } + apply_custom_blocky( + &connection, + &doq_endpoint, + &ipv4, + &ipv6, + &dot_hostname, + dns::BlockyMode::Doq, tx, ); } else { - // Stop blocky if switching away from DoH + // Stop blocky if switching away from encrypted DNS actions::stop_blocky(); let dot_label = if dot { " (DoT enabled)" } else { "" }; println!( @@ -231,15 +300,17 @@ pub fn handle_dns_command(action: DnsAction) -> Result<()> { Some(host) => format!(" [DoT: {host}]"), None => String::new(), }; - let doh_info = match dns::get_doh_url(name) { - Some(url) => format!(" [DoH: {url}]"), - None => String::new(), - }; + let doh_info = dns::get_blocky_upstream(name, dns::BlockyMode::Doh) + .map(|url| format!(" [DoH: {url}]")) + .unwrap_or_default(); + let doq_info = dns::get_blocky_upstream(name, dns::BlockyMode::Doq) + .map(|endpoint| format!(" [DoQ: {endpoint}]")) + .unwrap_or_default(); let region_info = match dns::G_DNS_SERVER_INFO.get(name) { Some(info) => format!(" ({} - {})", info.region, info.homepage), None => String::new(), }; - println!("- {name}{dot_info}{doh_info}{region_info}"); + println!("- {name}{dot_info}{doh_info}{doq_info}{region_info}"); } }, DnsAction::TestLatency => { diff --git a/src/dns.rs b/src/dns.rs index 87e0258..5740f64 100644 --- a/src/dns.rs +++ b/src/dns.rs @@ -29,6 +29,14 @@ pub static G_DNS_DOH_URLS: phf::Map<&'static str, &'static str> = phf_map! { "腾讯云 DNSPod (Tencent)" => "https://doh.pub/dns-query", }; +/// `DoQ` endpoint for servers that support DNS over QUIC (RFC 9250). +pub static G_DNS_DOQ_ENDPOINTS: phf::Map<&'static str, &'static str> = phf_map! { + "AdGuard" => "quic:dns.adguard-dns.com", + "AdGuard Family Protection" => "quic:family.adguard-dns.com", + "FFMUC DNS / Freie Netze Muenchen e.V." => "quic:doq.ffmuc.net", + "Quad9" => "quic:dns.quad9.net", +}; + pub static G_DNS_SERVERS: phf::OrderedMap<&'static str, DnsEntry> = phf_ordered_map! { "AdGuard" => ("94.140.14.14,94.140.15.15", "2a10:50c0::ad1:ff,2a10:50c0::ad2:ff", Some("dns.adguard-dns.com")), "AdGuard Family Protection" => ("94.140.14.15,94.140.15.16", "2a10:50c0::bad1:ff,2a10:50c0::bad2:ff", Some("family.adguard-dns.com")), @@ -80,12 +88,16 @@ pub enum DnsAction { server: DnsServer, /// Enable DNS over TLS (`DoT`) for the connection (requires server support) - #[clap(long)] + #[clap(long, conflicts_with_all = ["doh", "doq"])] dot: bool, /// Enable DNS over HTTPS (`DoH`) via blocky local proxy (requires server support) - #[clap(long, conflicts_with = "dot")] + #[clap(long, conflicts_with_all = ["dot", "doq"])] doh: bool, + + /// Enable DNS over QUIC (`DoQ`) via blocky local proxy (requires server support) + #[clap(long, conflicts_with_all = ["dot", "doh"])] + doq: bool, }, /// Set a custom DNS server for a network connection SetCustom { @@ -102,7 +114,7 @@ pub enum DnsAction { ipv6: String, /// Enable DNS over TLS (`DoT`) - #[clap(long)] + #[clap(long, conflicts_with_all = ["doh", "doq"])] dot: bool, /// `DoT` hostname for SNI (e.g. "dns.example.com") @@ -110,12 +122,20 @@ pub enum DnsAction { dot_hostname: String, /// Enable DNS over HTTPS (`DoH`) via blocky local proxy - #[clap(long, conflicts_with = "dot")] + #[clap(long, conflicts_with_all = ["dot", "doq"])] doh: bool, /// `DoH` URL (e.g. "") #[clap(long, value_name = "URL", default_value = "")] doh_url: String, + + /// Enable DNS over QUIC (`DoQ`) via blocky local proxy + #[clap(long, conflicts_with_all = ["dot", "doh"])] + doq: bool, + + /// `DoQ` endpoint (e.g. "quic:dns.example.com") + #[clap(long, value_name = "ENDPOINT", default_value = "")] + doq_endpoint: String, }, /// Reset DNS settings for a network connection to automatic (DHCP) Reset { @@ -251,27 +271,54 @@ pub fn measure_all_latencies() -> Vec<(&'static str, Option)> { results } -/// Returns the `DoH` URL for a given server name, if it supports `DoH`. -pub fn get_doh_url(server_name: &str) -> Option<&'static str> { - G_DNS_DOH_URLS.get(server_name).copied() +/// Encrypted DNS mode used by the blocky local proxy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockyMode { + Doh, + Doq, +} + +fn blocky_preset_map(mode: BlockyMode) -> &'static phf::Map<&'static str, &'static str> { + match mode { + BlockyMode::Doh => &G_DNS_DOH_URLS, + BlockyMode::Doq => &G_DNS_DOQ_ENDPOINTS, + } +} + +/// Returns the blocky upstream for a preset server, if supported. +pub fn get_blocky_upstream(server_name: &str, mode: BlockyMode) -> Option<&'static str> { + blocky_preset_map(mode).get(server_name).copied() } -/// Returns true if the named server supports `DoH`. -pub fn server_supports_doh(server_name: &str) -> bool { - G_DNS_DOH_URLS.contains_key(server_name) +pub fn is_valid_doh_url(url: &str) -> bool { + url.starts_with("https://") +} + +pub fn is_valid_doq_endpoint(endpoint: &str) -> bool { + endpoint.starts_with("quic:") || endpoint.starts_with("quic://") +} + +pub fn blocky_mode_from_upstream(upstream: &str) -> Option { + if is_valid_doh_url(upstream) { + Some(BlockyMode::Doh) + } else if is_valid_doq_endpoint(upstream) { + Some(BlockyMode::Doq) + } else { + None + } } pub const BLOCKY_CONFIG_PATH: &str = "/etc/blocky/blocky.yml"; pub const BLOCKY_SERVICE: &str = "blocky.service"; -/// Generate a blocky blocky.yml for `DoH` with bootstrap DNS. -/// `doh_url` is e.g. "" +/// Generate a blocky blocky.yml with bootstrap DNS. +/// `upstream` is e.g. "" or "quic:cloudflare-dns.com" /// `bootstrap_ipv4` is the plaintext IPv4 IPs, e.g. "1.1.1.1,1.0.0.1" /// `bootstrap_ipv6` is the plaintext IPv6 IPs, e.g. "`2606:4700:4700::1111,2606:4700:4700::1001`" /// `dot_hostname` is the optional `DoT` hostname — if provided, bootstrap uses `DoT` instead of /// plaintext. pub fn generate_blocky_config( - doh_url: &str, + upstream: &str, bootstrap_ipv4: &str, bootstrap_ipv6: &str, dot_hostname: Option<&str>, @@ -298,7 +345,7 @@ pub fn generate_blocky_config( upstreams: groups: default: - - "{doh_url}" + - "{upstream}" strategy: strict timeout: 5s userAgent: "CachyOS/blocky" @@ -319,31 +366,35 @@ caching: ) } -/// Read the active `DoH` URL from blocky's config file, if present. -/// Returns the `https://...` upstream URL, or None if not our config. -pub fn read_active_doh_url() -> Option { +/// Read the active blocky upstream from our generated config file, if present. +pub fn read_active_blocky_upstream() -> Option { let config = std::fs::read_to_string(BLOCKY_CONFIG_PATH).ok()?; - // Only parse configs we generated if !config.starts_with("# Generated by CachyOS Hello") { return None; } for line in config.lines() { let trimmed = line.trim().trim_start_matches("- ").trim_matches('"'); - if trimmed.starts_with("https://") { + if trimmed.starts_with("https://") || trimmed.starts_with("quic:") || trimmed.starts_with("quic://") + { return Some(trimmed.to_string()); } } None } -/// Given an active `DoH` URL, find which preset server it belongs to. -/// Returns the index into `G_DNS_SERVERS`, or None if it's a custom URL. -pub fn find_server_by_doh_url(doh_url: &str) -> Option { +/// Read active blocky mode and upstream from our generated config, if present. +pub fn read_active_blocky() -> Option<(BlockyMode, String)> { + let upstream = read_active_blocky_upstream()?; + let mode = blocky_mode_from_upstream(&upstream)?; + Some((mode, upstream)) +} + +/// Given an active blocky upstream, find which preset server it belongs to. +pub fn find_server_by_blocky_upstream(upstream: &str, mode: BlockyMode) -> Option { for (idx, (name, _)) in G_DNS_SERVERS.entries().enumerate() { - if let Some(url) = G_DNS_DOH_URLS.get(name) - && *url == doh_url { - return Some(idx); - } + if blocky_preset_map(mode).get(name).is_some_and(|preset| *preset == upstream) { + return Some(idx); + } } None } diff --git a/src/pages/dns.rs b/src/pages/dns.rs index 0fa79a9..b35f37e 100644 --- a/src/pages/dns.rs +++ b/src/pages/dns.rs @@ -12,19 +12,17 @@ fn is_valid_dns_input(s: &str) -> bool { } fn selection_index_for_connection(conn_name: &str) -> usize { - // If blocky is active (DoH mode) AND this connection points to blocky (127.0.0.1), + // If blocky is active (DoH/DoQ mode) AND this connection points to blocky (127.0.0.1), // read the blocky config to find which preset server is in use. if actions::is_blocky_active() { let points_to_blocky = actions::get_dns_for_connection(conn_name) .is_some_and(|info| info.ipv4.contains("127.0.0.1") || info.ipv6.contains("::1")); if points_to_blocky - && let Some(doh_url) = dns::read_active_doh_url() { - if let Some(idx) = dns::find_server_by_doh_url(&doh_url) { - return idx; - } - // Custom DoH URL — show as custom - return dns::G_DNS_SERVERS.len(); - } + && let Some((mode, upstream)) = dns::read_active_blocky() + { + return dns::find_server_by_blocky_upstream(&upstream, mode) + .unwrap_or(dns::G_DNS_SERVERS.len()); + } } if let Some(dns_info) = actions::get_dns_for_connection(conn_name) { @@ -50,9 +48,57 @@ fn server_supports_dot(index: usize) -> bool { dns::G_DNS_SERVERS.entries().nth(index).is_some_and(|(_, (_, _, dot))| dot.is_some()) } -/// Returns whether the server at `index` supports `DoH`. -fn server_supports_doh(index: usize) -> bool { - dns::G_DNS_SERVERS.entries().nth(index).is_some_and(|(name, _)| dns::server_supports_doh(name)) +/// Returns whether the server at `index` supports blocky encrypted DNS. +fn server_supports_blocky(index: usize, mode: dns::BlockyMode) -> bool { + dns::G_DNS_SERVERS + .entries() + .nth(index) + .is_some_and(|(name, _)| dns::get_blocky_upstream(name, mode).is_some()) +} + +fn disable_protocol_checks( + dot_check: >k::CheckButton, + doh_check: >k::CheckButton, + doq_check: >k::CheckButton, +) { + for check in [dot_check, doh_check, doq_check] { + check.set_sensitive(false); + check.set_active(false); + } +} + +fn fill_custom_blocky_fields( + upstream: &str, + mode: dns::BlockyMode, + custom_doh_entry: >k::Entry, + custom_doq_entry: >k::Entry, + doh_check: >k::CheckButton, + doq_check: >k::CheckButton, +) { + match mode { + dns::BlockyMode::Doh => { + custom_doh_entry.set_text(upstream); + doh_check.set_active(true); + doq_check.set_active(false); + }, + dns::BlockyMode::Doq => { + custom_doq_entry.set_text(upstream); + doq_check.set_active(true); + doh_check.set_active(false); + }, + } +} + +fn set_preset_blocky_check( + check: >k::CheckButton, + index: usize, + mode: dns::BlockyMode, + blocky_active: bool, + active_mode: Option, +) { + let supported = server_supports_blocky(index, mode); + check.set_sensitive(supported); + check.set_active(blocky_active && supported && active_mode == Some(mode)); } /// Returns (region, homepage) for the server at `index`. @@ -129,17 +175,31 @@ fn create_connections_section() -> gtk::Box { doh_check.set_tooltip_text(Some(&fl!("doh-tooltip"))); doh_check.set_widget_name("enable-doh"); - // DoT and DoH are mutually exclusive + // DoQ (DNS over QUIC) toggle — uses blocky local proxy + let doq_check = gtk::CheckButton::with_label(&fl!("enable-doq")); + doq_check.set_tooltip_text(Some(&fl!("doq-tooltip"))); + doq_check.set_widget_name("enable-doq"); + + // DoT, DoH, and DoQ are mutually exclusive let dot_check_excl = dot_check.clone(); let doh_check_excl = doh_check.clone(); - dot_check.connect_toggled(glib::clone!(@weak doh_check_excl => move |check| { + let doq_check_excl = doq_check.clone(); + dot_check.connect_toggled(glib::clone!(@weak doh_check_excl, @weak doq_check_excl => move |check| { if check.is_active() { doh_check_excl.set_active(false); + doq_check_excl.set_active(false); } })); - doh_check.connect_toggled(glib::clone!(@weak dot_check_excl => move |check| { + doh_check.connect_toggled(glib::clone!(@weak dot_check_excl, @weak doq_check_excl => move |check| { if check.is_active() { dot_check_excl.set_active(false); + doq_check_excl.set_active(false); + } + })); + doq_check.connect_toggled(glib::clone!(@weak dot_check_excl, @weak doh_check_excl => move |check| { + if check.is_active() { + dot_check_excl.set_active(false); + doh_check_excl.set_active(false); } })); @@ -155,6 +215,7 @@ fn create_connections_section() -> gtk::Box { let custom_ipv6_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); let custom_dot_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); let custom_doh_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); + let custom_doq_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); let custom_ipv4_label = gtk::Label::new(None); custom_ipv4_label.set_text(&fl!("custom-dns-ipv4")); @@ -180,6 +241,12 @@ fn create_connections_section() -> gtk::Box { custom_doh_entry.set_placeholder_text(Some("e.g. https://dns.example.com/dns-query")); custom_doh_entry.set_widget_name("custom-dns-doh-url"); + let custom_doq_label = gtk::Label::new(None); + custom_doq_label.set_text(&fl!("custom-dns-doq-endpoint")); + let custom_doq_entry = gtk::Entry::new(); + custom_doq_entry.set_placeholder_text(Some("e.g. quic:dns.example.com")); + custom_doq_entry.set_widget_name("custom-dns-doq-endpoint"); + custom_ipv4_box.pack_start(&custom_ipv4_label, true, true, 2); custom_ipv4_box.pack_end(&custom_ipv4_entry, true, true, 2); custom_ipv6_box.pack_start(&custom_ipv6_label, true, true, 2); @@ -188,11 +255,14 @@ fn create_connections_section() -> gtk::Box { custom_dot_box.pack_end(&custom_dot_entry, true, true, 2); custom_doh_box.pack_start(&custom_doh_label, true, true, 2); custom_doh_box.pack_end(&custom_doh_entry, true, true, 2); + custom_doq_box.pack_start(&custom_doq_label, true, true, 2); + custom_doq_box.pack_end(&custom_doq_entry, true, true, 2); custom_box.pack_start(&custom_ipv4_box, false, false, 2); custom_box.pack_start(&custom_ipv6_box, false, false, 2); custom_box.pack_start(&custom_dot_box, false, false, 2); custom_box.pack_start(&custom_doh_box, false, false, 2); + custom_box.pack_start(&custom_doq_box, false, false, 2); custom_box.set_widget_name("dns-custom-box"); custom_box.set_no_show_all(true); custom_box.set_visible(false); @@ -222,18 +292,19 @@ fn create_connections_section() -> gtk::Box { combo_servers.set_active(Some(combo_index as u32)); if selected_dns_index == usize::MAX { - // DHCP (automatic) — disable protocol checkboxes - dot_check.set_sensitive(false); - dot_check.set_active(false); - doh_check.set_sensitive(false); - doh_check.set_active(false); + disable_protocol_checks(&dot_check, &doh_check, &doq_check); } else if selected_dns_index == dns::G_DNS_SERVERS.len() { // Custom DNS — pre-fill entries with current values if actions::is_blocky_active() { - // Custom DoH — fill from blocky config; - // NM just has 127.0.0.1/::1, so read the real values from blocky - if let Some(doh_url) = dns::read_active_doh_url() { - custom_doh_entry.set_text(&doh_url); + if let Some((mode, upstream)) = dns::read_active_blocky() { + fill_custom_blocky_fields( + &upstream, + mode, + &custom_doh_entry, + &custom_doq_entry, + &doh_check, + &doq_check, + ); } let (ipv4, ipv6, dot_host) = dns::read_blocky_bootstrap(); if !ipv4.is_empty() { @@ -245,7 +316,6 @@ fn create_connections_section() -> gtk::Box { if let Some(ref hostname) = dot_host { custom_dot_entry.set_text(hostname); } - doh_check.set_active(true); dot_check.set_active(false); } else { if let Some(dns_info) = actions::get_dns_for_connection(&active_conn_name) { @@ -262,22 +332,39 @@ fn create_connections_section() -> gtk::Box { custom_box.show(); dot_check.set_sensitive(true); doh_check.set_sensitive(true); + doq_check.set_sensitive(true); } else { + let blocky_active = actions::is_blocky_active(); + let active_mode = + if blocky_active { dns::read_active_blocky().map(|(mode, _)| mode) } else { None }; + let supports_dot = server_supports_dot(selected_dns_index); dot_check.set_sensitive(supports_dot); - dot_check.set_active(supports_dot && !actions::is_blocky_active()); - - let supports_doh = server_supports_doh(selected_dns_index); - doh_check.set_sensitive(supports_doh); - doh_check.set_active(actions::is_blocky_active() && supports_doh); + dot_check.set_active(supports_dot && !blocky_active); + + set_preset_blocky_check( + &doh_check, + selected_dns_index, + dns::BlockyMode::Doh, + blocky_active, + active_mode, + ); + set_preset_blocky_check( + &doq_check, + selected_dns_index, + dns::BlockyMode::Doq, + blocky_active, + active_mode, + ); } update_server_info_label(&info_label, selected_dns_index); } } - // Update DoT/DoH checkboxes, info label, and custom fields when server selection changes + // Update DoT/DoH/DoQ checkboxes, info label, and custom fields when server selection changes let dot_check_clone = dot_check.clone(); let doh_check_clone = doh_check.clone(); + let doq_check_clone = doq_check.clone(); let info_label_clone = info_label.clone(); let custom_box_vis = custom_box.clone(); let best_btn_vis = best_btn.clone(); @@ -293,23 +380,33 @@ fn create_connections_section() -> gtk::Box { } best_btn_vis.set_visible(!is_custom && !is_dhcp); if is_dhcp { - dot_check_clone.set_sensitive(false); - dot_check_clone.set_active(false); - doh_check_clone.set_sensitive(false); - doh_check_clone.set_active(false); + disable_protocol_checks(&dot_check_clone, &doh_check_clone, &doq_check_clone); info_label_clone.set_visible(false); } else if is_custom { dot_check_clone.set_sensitive(true); doh_check_clone.set_sensitive(true); doh_check_clone.set_active(false); + doq_check_clone.set_sensitive(true); + doq_check_clone.set_active(false); info_label_clone.set_visible(false); } else { let supports_dot = server_supports_dot(idx as usize); dot_check_clone.set_sensitive(supports_dot); dot_check_clone.set_active(supports_dot); - let supports_doh = server_supports_doh(idx as usize); - doh_check_clone.set_sensitive(supports_doh); - doh_check_clone.set_active(false); + set_preset_blocky_check( + &doh_check_clone, + idx as usize, + dns::BlockyMode::Doh, + false, + None, + ); + set_preset_blocky_check( + &doq_check_clone, + idx as usize, + dns::BlockyMode::Doq, + false, + None, + ); update_server_info_label(&info_label_clone, idx as usize); } } @@ -322,6 +419,9 @@ fn create_connections_section() -> gtk::Box { let custom_ipv6_entry_conn = custom_ipv6_entry.clone(); let custom_dot_entry_conn = custom_dot_entry.clone(); let custom_doh_entry_conn = custom_doh_entry.clone(); + let custom_doq_entry_conn = custom_doq_entry.clone(); + let doh_check_conn = doh_check.clone(); + let doq_check_conn = doq_check.clone(); combo_conn.connect_changed(move |combo| { // use empty string which will trigger fallback let conn_name: String = combo.active_text().map(Into::into).unwrap_or_default(); @@ -332,8 +432,15 @@ fn create_connections_section() -> gtk::Box { if selected_dns_index == dns::G_DNS_SERVERS.len() { // Custom DNS — pre-fill entries from blocky config or NM if actions::is_blocky_active() { - if let Some(doh_url) = dns::read_active_doh_url() { - custom_doh_entry_conn.set_text(&doh_url); + if let Some((mode, upstream)) = dns::read_active_blocky() { + fill_custom_blocky_fields( + &upstream, + mode, + &custom_doh_entry_conn, + &custom_doq_entry_conn, + &doh_check_conn, + &doq_check_conn, + ); } let (ipv4, ipv6, dot_host) = dns::read_blocky_bootstrap(); custom_ipv4_entry_conn.set_text(&ipv4); @@ -450,7 +557,9 @@ fn create_connections_section() -> gtk::Box { let custom_ipv6_entry_apply = custom_ipv6_entry.clone(); let custom_dot_entry_apply = custom_dot_entry.clone(); let custom_doh_entry_apply = custom_doh_entry.clone(); + let custom_doq_entry_apply = custom_doq_entry.clone(); let doh_check_clone3 = doh_check.clone(); + let doq_check_clone3 = doq_check.clone(); apply_btn.connect_clicked(move |_| { let conn_name: String = combo_conn_clone.active_text().map(Into::into).unwrap_or_default(); let is_custom = @@ -470,6 +579,7 @@ fn create_connections_section() -> gtk::Box { let enable_dot = dot_check_clone3.is_active(); let enable_doh = doh_check_clone3.is_active(); + let enable_doq = doq_check_clone3.is_active(); let (ipv4, ipv6, dot_hostname) = if is_custom { let ipv4: String = custom_ipv4_entry_apply.text().trim().to_string(); @@ -503,15 +613,27 @@ fn create_connections_section() -> gtk::Box { }; let dialog_tx_clone = dialog_tx_clone.clone(); - if enable_doh { - // DoH mode: use blocky proxy. For custom servers, use the DoH URL - // field; for presets, look up the URL from our map. The IPv4/IPv6 - // addresses and DoT hostname (if any) are used as bootstrap DNS. - let (doh_url, bootstrap_ipv4, bootstrap_ipv6, bootstrap_dot) = if is_custom { - let custom_doh_url: String = custom_doh_entry_apply.text().trim().to_string(); - if custom_doh_url.is_empty() || !custom_doh_url.starts_with("https://") { + if let Some(mode) = match (enable_doh, enable_doq) { + (true, _) => Some(dns::BlockyMode::Doh), + (_, true) => Some(dns::BlockyMode::Doq), + _ => None, + } { + let (upstream, bootstrap_ipv4, bootstrap_ipv6, bootstrap_dot) = if is_custom { + let custom_upstream = match mode { + dns::BlockyMode::Doh => custom_doh_entry_apply.text().trim().to_string(), + dns::BlockyMode::Doq => custom_doq_entry_apply.text().trim().to_string(), + }; + let valid = match mode { + dns::BlockyMode::Doh => dns::is_valid_doh_url(&custom_upstream), + dns::BlockyMode::Doq => dns::is_valid_doq_endpoint(&custom_upstream), + }; + if !valid { + let msg = match mode { + dns::BlockyMode::Doh => fl!("custom-dns-doh-url-required"), + dns::BlockyMode::Doq => fl!("custom-dns-doq-endpoint-required"), + }; let _ = dialog_tx_clone.try_send(DialogMessage { - msg: fl!("custom-dns-doh-url-required"), + msg, msg_type: MessageType::Error, action: Action::SetDnsServer, }); @@ -519,23 +641,24 @@ fn create_connections_section() -> gtk::Box { } let dot_host = if dot_hostname.is_empty() { None } else { Some(dot_hostname.clone()) }; - (custom_doh_url, ipv4.clone(), ipv6.clone(), dot_host) + (custom_upstream, ipv4.clone(), ipv6.clone(), dot_host) } else { let server_name: String = combo_serv_clone.active_text().map(Into::into).unwrap_or_default(); let server_addr = dns::G_DNS_SERVERS.get(&server_name).unwrap(); ( - dns::get_doh_url(&server_name).unwrap_or("").to_string(), + dns::get_blocky_upstream(&server_name, mode).unwrap_or("").to_string(), server_addr.0.to_string(), server_addr.1.to_string(), server_addr.2.map(String::from), ) }; std::thread::spawn(move || { - actions::change_dns_server_doh( + actions::change_dns_server_blocky( crate::gui::run_command, &conn_name, - &doh_url, + mode, + &upstream, &bootstrap_ipv4, &bootstrap_ipv6, bootstrap_dot.as_deref(), @@ -543,7 +666,7 @@ fn create_connections_section() -> gtk::Box { ); }); } else { - // Stop blocky if switching away from DoH (in background thread) + // Stop blocky if switching away from encrypted DNS (in background thread) std::thread::spawn(move || { actions::stop_blocky(); actions::change_dns_server( @@ -562,6 +685,7 @@ fn create_connections_section() -> gtk::Box { let combo_serv_reset = combo_servers.clone(); let dot_check_reset = dot_check.clone(); let doh_check_reset = doh_check.clone(); + let doq_check_reset = doq_check.clone(); reset_btn.connect_clicked(move |_| { let dialog_tx_clone = dialog_tx_clone.clone(); let conn_name: String = combo_conn_clone.active_text().map(Into::into).unwrap_or_default(); @@ -570,6 +694,7 @@ fn create_connections_section() -> gtk::Box { combo_serv_reset.set_active(Some(dhcp_index)); dot_check_reset.set_active(false); doh_check_reset.set_active(false); + doq_check_reset.set_active(false); std::thread::spawn(move || { actions::reset_dns_server(&conn_name, dialog_tx_clone); }); @@ -603,6 +728,7 @@ fn create_connections_section() -> gtk::Box { latency_box.pack_start(&latency_label, false, false, 2); dot_box.pack_start(&dot_check, false, false, 2); dot_box.pack_start(&doh_check, false, false, 2); + dot_box.pack_start(&doq_check, false, false, 2); dot_box.set_halign(gtk::Align::Center); dot_box.set_widget_name("dns-dot-box"); button_box.pack_start(&reset_btn, true, true, 2);