From 9444eee182183651e0418c822994013f9be070ea Mon Sep 17 00:00:00 2001 From: metah3m Date: Thu, 6 Aug 2026 14:39:13 +0800 Subject: [PATCH 1/5] feat: expose endpoint identity replacement --- dhttp/Cargo.toml | 1 + dhttp/src/endpoint.rs | 173 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/dhttp/Cargo.toml b/dhttp/Cargo.toml index 8e8e21c..e2f978f 100644 --- a/dhttp/Cargo.toml +++ b/dhttp/Cargo.toml @@ -43,4 +43,5 @@ url = "2" [dev-dependencies] dhttp-access = { workspace = true, features = ["http", "orm"] } +rcgen = "0.14" tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/dhttp/src/endpoint.rs b/dhttp/src/endpoint.rs index 498d2cf..e032e37 100644 --- a/dhttp/src/endpoint.rs +++ b/dhttp/src/endpoint.rs @@ -53,6 +53,34 @@ pub enum InvalidEndpointIdentityError { }, } +/// Result of replacing a live endpoint identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplaceIdentityOutcome { + Updated, + Unchanged, +} + +/// Error returned when replacing a live endpoint identity. +#[derive(Debug, snafu::Snafu)] +#[snafu(module(replace_identity_error))] +pub enum ReplaceIdentityError { + #[snafu(display("invalid replacement identity"))] + InvalidIdentity { + source: InvalidEndpointIdentityError, + }, + #[snafu(display("cannot replace the identity of an anonymous endpoint"))] + MissingCurrentIdentity, + #[snafu(display("replacement identity name changed from {current} to {replacement}"))] + NameChanged { + current: dhttp_identity::name::DhttpName<'static>, + replacement: dhttp_identity::name::DhttpName<'static>, + }, + #[snafu(display("failed to replace the transport identity"))] + ReplaceTransport { + source: crate::dquic::ReplaceIdentityError, + }, +} + #[derive(Debug, snafu::Snafu)] #[snafu(module(invalid_endpoint_parts_error))] pub enum InvalidEndpointPartsError { @@ -325,6 +353,47 @@ impl Endpoint { self.inner.quic().identity() } + /// Replace certificate and private-key material without rebuilding the endpoint. + /// + /// The DHTTP name must stay unchanged. Existing connections continue with + /// their negotiated credentials; new connections use the replacement. + pub async fn replace_identity( + &self, + identity: Arc, + ) -> Result { + Self::validate_identity(Some(&identity)) + .context(replace_identity_error::InvalidIdentitySnafu)?; + let current = self + .identity() + .ok_or(ReplaceIdentityError::MissingCurrentIdentity)?; + let current_name = Self::name_from_identity(¤t) + .expect("BUG: dhttp endpoint identity must be a valid dhttp name"); + let replacement_name = + Self::name_from_identity(&identity).expect("replacement identity was validated above"); + if current_name != replacement_name { + return Err(ReplaceIdentityError::NameChanged { + current: current_name, + replacement: replacement_name, + }); + } + + let outcome = self + .inner + .quic() + .replace_identity(identity) + .await + .context(replace_identity_error::ReplaceTransportSnafu)?; + match outcome { + crate::dquic::ReplaceIdentityOutcome::Updated => { + self.inner.clear_pool(); + Ok(ReplaceIdentityOutcome::Updated) + } + crate::dquic::ReplaceIdentityOutcome::Unchanged => { + Ok(ReplaceIdentityOutcome::Unchanged) + } + } + } + /// Return the DHttp name used by this endpoint, if any. pub fn name(&self) -> Option> { self.identity().map(|identity| { @@ -614,6 +683,25 @@ mod tests { ) } + fn generated_dhttp_identity(name: &str, sequence: u64) -> Identity { + let key_pair = rcgen::KeyPair::generate().expect("generate test key"); + let mut params = + rcgen::CertificateParams::new(vec![name.to_owned()]).expect("valid certificate name"); + params.is_ca = rcgen::IsCa::ExplicitNoCa; + params.key_identifier_method = rcgen::KeyIdMethod::PreSpecified( + format!( + "{sequence}:0:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + .into_bytes(), + ); + let cert = params.self_signed(&key_pair).expect("self-sign test cert"); + Identity::new( + name.parse().unwrap(), + vec![CertificateDer::from(cert.der().to_vec())], + PrivateKeyDer::try_from(key_pair.serialize_der()).expect("valid test private key"), + ) + } + #[test] fn bootstrap_url_comes_from_compile_time_environment() { if let Some(expected) = option_env!("DHTTP_BOOTSTRAP_URL") { @@ -708,6 +796,91 @@ mod tests { )); } + #[tokio::test] + async fn replace_identity_updates_all_endpoint_clones() { + let endpoint = Endpoint::builder() + .identity(Arc::new(generated_dhttp_identity( + "rotate.example.dhttp.net", + 0, + ))) + .build() + .await + .expect("initial endpoint should build"); + let cloned = endpoint.clone(); + let old_key = endpoint.identity().unwrap().key().secret_der().to_vec(); + + let outcome = cloned + .replace_identity(Arc::new(generated_dhttp_identity( + "rotate.example.dhttp.net", + 1, + ))) + .await + .expect("same-name replacement should succeed"); + + assert_eq!(outcome, ReplaceIdentityOutcome::Updated); + assert_ne!(endpoint.identity().unwrap().key().secret_der(), old_key); + } + + #[tokio::test] + async fn replace_identity_rejects_dhttp_name_change() { + let endpoint = Endpoint::builder() + .identity(Arc::new(generated_dhttp_identity( + "first.example.dhttp.net", + 0, + ))) + .build() + .await + .expect("initial endpoint should build"); + + let error = endpoint + .replace_identity(Arc::new(generated_dhttp_identity( + "second.example.dhttp.net", + 1, + ))) + .await + .expect_err("live replacement must preserve the dhttp name"); + + assert!(matches!(error, ReplaceIdentityError::NameChanged { .. })); + assert_eq!( + endpoint.name().unwrap().as_full(), + "first.example.dhttp.net" + ); + } + + #[tokio::test] + async fn replace_identity_keeps_current_material_when_key_does_not_match() { + let endpoint = Endpoint::builder() + .identity(Arc::new(generated_dhttp_identity( + "mismatch.example.dhttp.net", + 0, + ))) + .build() + .await + .expect("initial endpoint should build"); + let original_key = endpoint.identity().unwrap().key().secret_der().to_vec(); + let replacement_cert = generated_dhttp_identity("mismatch.example.dhttp.net", 1); + let other_key = generated_dhttp_identity("mismatch.example.dhttp.net", 2); + let mismatched = Identity::new( + replacement_cert.name().clone(), + replacement_cert.certs().to_vec(), + other_key.key().clone_key(), + ); + + let error = endpoint + .replace_identity(Arc::new(mismatched)) + .await + .expect_err("certificate and private key must match"); + + assert!(matches!( + error, + ReplaceIdentityError::ReplaceTransport { .. } + )); + assert_eq!( + endpoint.identity().unwrap().key().secret_der(), + original_key + ); + } + #[tokio::test] async fn from_parts_preserves_matching_parts() { let network = DhttpNetwork::builder() From 72e6a6c5d3c77de8eff4f7d4e04e079c7f8a19f7 Mon Sep 17 00:00:00 2001 From: metah3m Date: Fri, 7 Aug 2026 10:05:45 +0800 Subject: [PATCH 2/5] chore(release): prepare dhttp 0.6.1-beta.2 --- Cargo.toml | 4 ++-- api/package-lock.json | 4 ++-- api/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 23a453a..bb03406 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["dhttp", "identity", "home", "api", "access", "log"] [workspace.package] -version = "0.6.1-beta.1" +version = "0.6.1-beta.2" edition = "2024" license = "Apache-2.0" repository = "https://github.com/genmeta/dhttp" @@ -55,6 +55,6 @@ ddns = { package = "dyns", version = "0.7.0", features = [ h3x = { version = "0.6.0", features = [ "dquic", ] } -dhttp = { path = "dhttp", version = "0.6.1-beta.1" } +dhttp = { path = "dhttp", version = "0.6.1-beta.2" } dhttp-access = { path = "access", version = "0.4.1-beta.1" } dhttp-log = { path = "log", version = "0.1.0" } diff --git a/api/package-lock.json b/api/package-lock.json index 527b8fa..018570a 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@genmeta/dhttp", - "version": "0.6.1-beta.1", + "version": "0.6.1-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@genmeta/dhttp", - "version": "0.6.1-beta.1", + "version": "0.6.1-beta.2", "devDependencies": { "@napi-rs/cli": "^3.3.5" } diff --git a/api/package.json b/api/package.json index 13ec16b..5ff43ed 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@genmeta/dhttp", - "version": "0.6.1-beta.1", + "version": "0.6.1-beta.2", "description": "The True Internet", "license": "Apache-2.0", "homepage": "https://dhttp.net/", From 291aa755aac5bc68af8ef9cc0603c27a83393713 Mon Sep 17 00:00:00 2001 From: metah3m Date: Fri, 7 Aug 2026 14:58:13 +0800 Subject: [PATCH 3/5] fix: make access rule deduplication consistent --- access/src/db/mod.rs | 169 +++++++++++++++++- access/src/db/service/error.rs | 8 +- access/src/db/service/location_service.rs | 103 ++++++++--- .../m20260807_120000_unique_location_rules.rs | 43 +++++ access/src/migration/mod.rs | 6 +- 5 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 access/src/migration/m20260807_120000_unique_location_rules.rs diff --git a/access/src/db/mod.rs b/access/src/db/mod.rs index a029c36..7b785e9 100644 --- a/access/src/db/mod.rs +++ b/access/src/db/mod.rs @@ -182,8 +182,12 @@ pub async fn init_access_database_for( mod tests { use std::path::PathBuf; - use crate::{action::RequestAction, matcher::LocationRulesMatcher}; - use sea_orm::{ConnectionTrait, Statement}; + use crate::{ + action::RequestAction, + db::entities::location::{location, rule}, + matcher::LocationRulesMatcher, + }; + use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set, Statement}; use super::service::location_service::LocationService; use super::*; @@ -282,6 +286,49 @@ mod tests { assert!(!tables.contains(&"location_domain_rule_sets".to_string())); } + #[tokio::test] + async fn migration_deduplicates_existing_rules() { + use sea_orm_migration::MigratorTrait; + + let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap(); + migration::Migrator::up(&db, Some(1)).await.unwrap(); + + let now = chrono::Utc::now(); + let location_id = location::Entity::insert(location::ActiveModel { + pattern: Set("/api".parse().unwrap()), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }) + .exec(&db) + .await + .unwrap() + .last_insert_id; + let expr: crate::expr::exprs::LocationRuleExprs = "alice.pilot~".parse().unwrap(); + + for _ in 0..2 { + rule::Entity::insert(rule::ActiveModel { + location_id: Set(location_id), + action: Set(RequestAction::Allow), + exprs: Set(expr.clone()), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }) + .exec(&db) + .await + .unwrap(); + } + + migration::Migrator::up(&db, None).await.unwrap(); + let rules = rule::Entity::find() + .filter(rule::Column::LocationId.eq(location_id)) + .all(&db) + .await + .unwrap(); + assert_eq!(rules.len(), 1); + } + #[tokio::test] async fn appending_duplicate_rules_is_idempotent() { let test_home = TestHome::new("duplicate-rules"); @@ -323,6 +370,124 @@ mod tests { assert_eq!(rules.rules.len(), 1); } + #[tokio::test] + async fn concurrent_appends_are_idempotent() { + let test_home = TestHome::new("concurrent-duplicate-rules"); + let home = test_home.home(); + let identity: identity::Name<'static> = "alice.pilot".parse().unwrap(); + let db = init_identity_access_database(&home, identity.borrow()) + .await + .unwrap(); + let service = LocationService::new(&db); + let location = "/api".parse().unwrap(); + let expr: crate::expr::exprs::LocationRuleExprs = "alice.pilot~".parse().unwrap(); + + let (first, second) = tokio::join!( + service.append_rule_with_id(&location, RequestAction::Allow, expr.clone()), + service.append_rule_with_id(&location, RequestAction::Allow, expr), + ); + let first = first.unwrap(); + let second = second.unwrap(); + + assert_eq!(first.id, second.id); + assert_eq!( + service + .list_rules_by_pattern(&location) + .await + .unwrap() + .rules + .len(), + 1 + ); + } + + async fn insert_duplicate_rule(db: &DatabaseConnection, original: &rule::Model) { + db.execute_unprepared("DROP INDEX idx_location_rules_logical_unique") + .await + .unwrap(); + + let now = chrono::Utc::now(); + rule::Entity::insert(rule::ActiveModel { + location_id: Set(original.location_id), + action: Set(original.action), + exprs: Set(original.exprs.clone()), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }) + .exec(db) + .await + .unwrap(); + } + + #[tokio::test] + async fn removing_rule_by_sequence_removes_duplicate_rows() { + let test_home = TestHome::new("remove-duplicate-by-sequence"); + let home = test_home.home(); + let identity: identity::Name<'static> = "alice.pilot".parse().unwrap(); + let db = init_identity_access_database(&home, identity.borrow()) + .await + .unwrap(); + let service = LocationService::new(&db); + let original = service + .append_rule_with_id( + &"/api".parse().unwrap(), + RequestAction::Allow, + "alice.pilot~".parse().unwrap(), + ) + .await + .unwrap(); + + insert_duplicate_rule(&db, &original).await; + service + .remove_rules(&"/api".parse().unwrap(), [0]) + .await + .unwrap(); + + assert!( + service + .list_rules_by_pattern(&"/api".parse().unwrap()) + .await + .unwrap() + .rules + .is_empty() + ); + } + + #[tokio::test] + async fn removing_rule_by_id_removes_duplicate_rows() { + let test_home = TestHome::new("remove-duplicate-by-id"); + let home = test_home.home(); + let identity: identity::Name<'static> = "alice.pilot".parse().unwrap(); + let db = init_identity_access_database(&home, identity.borrow()) + .await + .unwrap(); + let service = LocationService::new(&db); + let original = service + .append_rule_with_id( + &"/api".parse().unwrap(), + RequestAction::Allow, + "alice.pilot~".parse().unwrap(), + ) + .await + .unwrap(); + + insert_duplicate_rule(&db, &original).await; + service + .remove_rules_by_ids(&"/api".parse().unwrap(), [original.id]) + .await + .unwrap(); + + assert!( + service + .list_rules_by_pattern(&"/api".parse().unwrap()) + .await + .unwrap() + .rules + .is_empty() + ); + } + #[tokio::test] async fn location_service_location_only_crud() { let test_home = TestHome::new("location-crud"); diff --git a/access/src/db/service/error.rs b/access/src/db/service/error.rs index 2d553eb..b48ae3e 100644 --- a/access/src/db/service/error.rs +++ b/access/src/db/service/error.rs @@ -92,8 +92,8 @@ pub enum RemoveRulesError { BeginTransaction { source: DbErr }, #[snafu(display("failed to locate location rule set before removing rules"))] MatchLocation { source: MatchLocationError }, - #[snafu(display("failed to select location rule ids for removal"))] - LoadRuleIds { source: DbErr }, + #[snafu(display("failed to select location rules for removal"))] + LoadRules { source: DbErr }, #[snafu(display("location rule cannot be removed"))] Rule { source: RemoveRuleFailed }, #[snafu(display("failed to delete location rules"))] @@ -126,6 +126,8 @@ pub enum MatchOrCreateLocationError { MatchLocation { source: MatchLocationError }, #[snafu(display("failed to insert location rule set"))] InsertLocation { source: DbErr }, + #[snafu(display("location rule set disappeared after a conflicting insert"))] + LocationMissing, } #[derive(Debug, snafu::Snafu)] @@ -143,6 +145,8 @@ pub enum AppendRuleError { LoadInsertedRule { source: DbErr }, #[snafu(display("inserted location rule `{id}` could not be loaded"))] InsertedRuleMissing { id: i32 }, + #[snafu(display("conflicting location rule could not be loaded"))] + ConflictingRuleMissing, #[snafu(display("failed to commit transaction after appending location rule"))] Commit { source: DbErr }, } diff --git a/access/src/db/service/location_service.rs b/access/src/db/service/location_service.rs index bc449fb..ad5f446 100644 --- a/access/src/db/service/location_service.rs +++ b/access/src/db/service/location_service.rs @@ -10,7 +10,7 @@ use crate::{ matcher::{LocationPatternMatcher, LocationRulesMatcher, PatternWithTime}, pattern::{LocationPattern, LocationPatternKind}, }; -use sea_orm::{prelude::*, *}; +use sea_orm::{prelude::*, sea_query::OnConflict, *}; use snafu::{OptionExt, ResultExt}; use crate::db::{entities::location::*, service::error::*}; @@ -48,10 +48,9 @@ pub struct MatchedLocationRules { fn deduplicate_rules(rules: &mut Vec) { let mut unique = Vec::with_capacity(rules.len()); for candidate in rules.drain(..) { - let duplicate = unique.iter().any(|existing: &rule::Model| { - existing.action == candidate.action - && existing.exprs.polish() == candidate.exprs.polish() - }); + let duplicate = unique + .iter() + .any(|existing: &rule::Model| same_logical_rule(existing, &candidate)); if !duplicate { unique.push(candidate); } @@ -59,6 +58,10 @@ fn deduplicate_rules(rules: &mut Vec) { *rules = unique; } +fn same_logical_rule(left: &rule::Model, right: &rule::Model) -> bool { + left.action == right.action && left.exprs.polish() == right.exprs.polish() +} + impl Display for MatchedLocationRules { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { location, rules } = self; @@ -345,22 +348,21 @@ impl LocationService<'_> { .context(RuleSetNotExistSnafu) .context(remove_rules_error::RuleSnafu)?; - let rule_ids: Vec = rule::Entity::find() + let rules = rule::Entity::find() .filter(rule::Column::LocationId.eq(location_id)) .order_by_asc(rule::Column::CreatedAt) - .select_only() - .column(rule::Column::Id) - .into_tuple() .all(&txn) .await - .context(remove_rules_error::LoadRuleIdsSnafu)?; + .context(remove_rules_error::LoadRulesSnafu)?; + let mut visible_rules = rules.clone(); + deduplicate_rules(&mut visible_rules); - let ids_to_delete = sequence + let selected_rules = sequence .into_iter() .map(|seq| { - rule_ids + visible_rules .get(seq) - .copied() + .cloned() .context(RuleNotExistSnafu { seq }) }) .try_fold(vec![], |mut set, id| { @@ -370,6 +372,15 @@ impl LocationService<'_> { }) }) .context(remove_rules_error::RuleSnafu)?; + let ids_to_delete: Vec = rules + .iter() + .filter(|candidate| { + selected_rules + .iter() + .any(|selected| same_logical_rule(candidate, selected)) + }) + .map(|rule| rule.id) + .collect(); rule::Entity::delete_many() .filter(rule::Column::Id.is_in(ids_to_delete)) @@ -435,8 +446,23 @@ impl LocationService<'_> { .context(remove_rules_by_ids_error::RuleSnafu); } + let mut rules = rule::Entity::find() + .filter(rule::Column::LocationId.eq(location_id)) + .all(&txn) + .await + .context(remove_rules_by_ids_error::LoadRulesSnafu)?; + let ids_to_delete: Vec = rules + .drain(..) + .filter(|candidate| { + matched_rules + .iter() + .any(|selected| same_logical_rule(candidate, selected)) + }) + .map(|rule| rule.id) + .collect(); + rule::Entity::delete_many() - .filter(rule::Column::Id.is_in(requested_set.iter().copied())) + .filter(rule::Column::Id.is_in(ids_to_delete)) .exec(&txn) .await .context(remove_rules_by_ids_error::DeleteRulesSnafu)?; @@ -466,10 +492,24 @@ impl LocationService<'_> { ..Default::default() }; let res = location::Entity::insert(new_location) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .try_insert() .exec(txn) .await .context(match_or_create_location_error::InsertLocationSnafu)?; - Ok(res.last_insert_id) + match res { + TryInsertResult::Inserted(res) => Ok(res.last_insert_id), + TryInsertResult::Conflicted => location::Entity::find() + .filter(location::Column::Pattern.eq(location.clone())) + .select_only() + .column(location::Column::Id) + .into_tuple() + .one(txn) + .await + .context(match_or_create_location_error::InsertLocationSnafu)? + .context(match_or_create_location_error::LocationMissingSnafu), + TryInsertResult::Empty => unreachable!("inserting one location is not empty"), + } } } } @@ -497,9 +537,10 @@ impl LocationService<'_> { .all(&txn) .await .context(append_rule_error::LoadExistingRulesSnafu)?; + let expr_polish = expr.polish().clone(); if let Some(existing_rule) = existing_rules .into_iter() - .find(|candidate| candidate.exprs.polish() == expr.polish()) + .find(|candidate| candidate.exprs.polish() == &expr_polish) { txn.commit().await.context(append_rule_error::CommitSnafu)?; return Ok(existing_rule); @@ -516,17 +557,33 @@ impl LocationService<'_> { }; let result = rule::Entity::insert(new_rule) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .try_insert() .exec(&txn) .await .context(append_rule_error::InsertRuleSnafu)?; - let inserted_rule = rule::Entity::find_by_id(result.last_insert_id) - .one(&txn) - .await - .context(append_rule_error::LoadInsertedRuleSnafu)? - .context(append_rule_error::InsertedRuleMissingSnafu { - id: result.last_insert_id, - })?; + let inserted_rule = match result { + TryInsertResult::Inserted(result) => { + let id = result.last_insert_id; + rule::Entity::find_by_id(id) + .one(&txn) + .await + .context(append_rule_error::LoadInsertedRuleSnafu)? + .context(append_rule_error::InsertedRuleMissingSnafu { id })? + } + TryInsertResult::Conflicted => rule::Entity::find() + .filter(rule::Column::LocationId.eq(location_id)) + .filter(rule::Column::Action.eq(action)) + .order_by_asc(rule::Column::CreatedAt) + .all(&txn) + .await + .context(append_rule_error::LoadExistingRulesSnafu)? + .into_iter() + .find(|candidate| candidate.exprs.polish() == &expr_polish) + .context(append_rule_error::ConflictingRuleMissingSnafu)?, + TryInsertResult::Empty => unreachable!("inserting one rule is not empty"), + }; txn.commit().await.context(append_rule_error::CommitSnafu)?; diff --git a/access/src/migration/m20260807_120000_unique_location_rules.rs b/access/src/migration/m20260807_120000_unique_location_rules.rs new file mode 100644 index 0000000..95290fd --- /dev/null +++ b/access/src/migration/m20260807_120000_unique_location_rules.rs @@ -0,0 +1,43 @@ +use sea_orm_migration::prelude::*; + +const INDEX_NAME: &str = "idx_location_rules_logical_unique"; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Keep the earliest row for each logical rule before adding the constraint. + manager + .get_connection() + .execute_unprepared( + r#"DELETE FROM location_rules + WHERE id NOT IN ( + SELECT MIN(id) + FROM location_rules + GROUP BY location_id, action, json_extract(exprs, '$.polish') + )"#, + ) + .await?; + + manager + .get_connection() + .execute_unprepared(&format!( + "CREATE UNIQUE INDEX IF NOT EXISTS {INDEX_NAME} ON location_rules \ + (location_id, action, json_extract(exprs, '$.polish'))" + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(&format!("DROP INDEX IF EXISTS {INDEX_NAME}")) + .await?; + + Ok(()) + } +} diff --git a/access/src/migration/mod.rs b/access/src/migration/mod.rs index 183f5ac..12ab383 100644 --- a/access/src/migration/mod.rs +++ b/access/src/migration/mod.rs @@ -1,6 +1,7 @@ pub use sea_orm_migration::prelude::*; mod m20250909_154000_create_table; +mod m20260807_120000_unique_location_rules; pub struct Migrator; @@ -12,6 +13,9 @@ impl MigratorTrait for Migrator { } fn migrations() -> Vec> { - vec![Box::new(m20250909_154000_create_table::Migration)] + vec![ + Box::new(m20250909_154000_create_table::Migration), + Box::new(m20260807_120000_unique_location_rules::Migration), + ] } } From bedbd089d2a07ecba84a073991198be2d45d7302 Mon Sep 17 00:00:00 2001 From: metah3m Date: Fri, 7 Aug 2026 17:15:17 +0800 Subject: [PATCH 4/5] fix(release): target h3x 0.6.1-beta.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index bb03406..52656cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ ddns = { package = "dyns", version = "0.7.0", features = [ "mdns", "dquic-network", ] } -h3x = { version = "0.6.0", features = [ +h3x = { version = "0.6.1-beta.1", features = [ "dquic", ] } dhttp = { path = "dhttp", version = "0.6.1-beta.2" } From da18d8982f00f26a2e0b3dbcb7d94b5009aa4786 Mon Sep 17 00:00:00 2001 From: metah3m Date: Fri, 7 Aug 2026 18:35:35 +0800 Subject: [PATCH 5/5] fix: route external endpoint DNS through system resolver --- dhttp/src/ddns.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/dhttp/src/ddns.rs b/dhttp/src/ddns.rs index bf03773..027a3c3 100644 --- a/dhttp/src/ddns.rs +++ b/dhttp/src/ddns.rs @@ -113,18 +113,18 @@ type DeferredEndpointResolver = resolvers::deferred::DeferredResolver>; #[derive(Debug)] -struct StunResolverRouter { +struct DhttpDnsRouter { dhttp: ArcResolvers, external: ArcResolvers, } -impl fmt::Display for StunResolverRouter { +impl fmt::Display for DhttpDnsRouter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("STUN DNS Router") + f.write_str("DHTTP DNS Router") } } -impl Resolve for StunResolverRouter { +impl Resolve for DhttpDnsRouter { fn lookup<'a>(&'a self, name: &'a str) -> crate::dquic::resolver::ResolveFuture<'a> { let resolvers = if uses_h3_dns(name) { &self.dhttp @@ -297,7 +297,7 @@ async fn network_stun_resolver_from_plan( return Ok(dhttp_resolvers); }; - let router: ArcResolver = Arc::new(StunResolverRouter { + let router: ArcResolver = Arc::new(DhttpDnsRouter { dhttp: dhttp_resolvers, external: external_resolvers, }); @@ -375,6 +375,21 @@ async fn endpoint_dns_from_quic( } let resolvers = endpoint_resolver_chain(resolver_builder.build())?; + let resolvers = if uses_h3(&operations) { + let external = non_h3_resolvers( + &operations, + endpoint.network().clone(), + endpoint.bind_patterns().clone(), + ) + .await; + let router: ArcResolver = Arc::new(DhttpDnsRouter { + dhttp: Arc::new(resolvers), + external: Arc::new(external), + }); + endpoint_resolver_chain(resolvers::Resolvers::new().with(router))? + } else { + resolvers + }; Ok((resolvers, publishers)) } @@ -669,7 +684,7 @@ mod tests { let external = Arc::new(resolvers::Resolvers::new().with(Arc::new(CountingResolver { calls: external_calls.clone(), }))); - let router = StunResolverRouter { dhttp, external }; + let router = DhttpDnsRouter { dhttp, external }; let _dhttp_records = router .lookup("node.dhttp.net:443")