#![forbid(unsafe_code)]
#![allow(
clippy::doc_markdown,
clippy::missing_errors_doc,
clippy::module_name_repetitions,
reason = "HNS, DNS, ODoH, and P2P are protocol names"
)]
use hns_resolution_policy::{PolicySnapshot, ResolutionTransport, TransportPlan};
use std::sync::atomic::{AtomicU64, Ordering};
use thiserror::Error;
static NEXT_GATEWAY_ID: AtomicU64 = AtomicU64::new(1);
pub const DEFAULT_ATTEMPT_TIMEOUT_SECONDS: u64 = 10;
pub const DEFAULT_MAXIMUM_RESPONSE_BYTES: usize = 65_535;
pub const DEFAULT_MAXIMUM_IDENTITY_BYTES: usize = 256;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct GatewayLimits {
pub attempt_timeout_seconds: u64,
pub maximum_response_bytes: usize,
pub maximum_identity_bytes: usize,
}
impl Default for GatewayLimits {
fn default() -> Self {
Self {
attempt_timeout_seconds: DEFAULT_ATTEMPT_TIMEOUT_SECONDS,
maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES,
maximum_identity_bytes: DEFAULT_MAXIMUM_IDENTITY_BYTES,
}
}
}
impl GatewayLimits {
fn validate(self) -> Result<Self, GatewayError> {
if self.attempt_timeout_seconds == 0
|| self.attempt_timeout_seconds > 300
|| self.maximum_response_bytes == 0
|| self.maximum_response_bytes > DEFAULT_MAXIMUM_RESPONSE_BYTES
|| self.maximum_identity_bytes == 0
|| self.maximum_identity_bytes > 4_096
{
return Err(GatewayError::InvalidLimits);
}
Ok(self)
}
}
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportFailure {
Unreachable = 0,
Timeout = 1,
Unsupported = 2,
Truncated = 3,
Malformed = 4,
AuthenticationFailed = 5,
Cancelled = 6,
}
impl TransportFailure {
const fn permits_fallback_from(self, transport: ResolutionTransport) -> bool {
matches!(self, Self::Unreachable | Self::Timeout | Self::Unsupported)
|| (matches!(self, Self::Truncated)
&& matches!(transport, ResolutionTransport::DirectAuthoritativeUdp))
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GatewayIdentities {
pub peer: Option<String>,
pub proxy: Option<String>,
pub target: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AttemptOutcome {
Response {
bytes: Vec<u8>,
identities: GatewayIdentities,
},
Failure(TransportFailure),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct GatewayAttempt {
instance_id: u64,
policy_generation: u64,
sequence: u64,
candidate_index: usize,
transport: ResolutionTransport,
deadline: u64,
}
impl GatewayAttempt {
#[must_use]
pub const fn policy_generation(self) -> u64 {
self.policy_generation
}
#[must_use]
pub const fn sequence(self) -> u64 {
self.sequence
}
#[must_use]
pub const fn transport(self) -> ResolutionTransport {
self.transport
}
#[must_use]
pub const fn deadline(self) -> u64 {
self.deadline
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct GatewaySelection {
policy_generation: u64,
transport: ResolutionTransport,
response: Vec<u8>,
identities: GatewayIdentities,
direct_relay_fallback: bool,
}
impl GatewaySelection {
#[must_use]
pub const fn policy_generation(&self) -> u64 {
self.policy_generation
}
#[must_use]
pub const fn transport(&self) -> ResolutionTransport {
self.transport
}
#[must_use]
pub fn response(&self) -> &[u8] {
&self.response
}
#[must_use]
pub const fn identities(&self) -> &GatewayIdentities {
&self.identities
}
#[must_use]
pub const fn direct_relay_fallback(&self) -> bool {
self.direct_relay_fallback
}
#[must_use]
pub fn into_parts(self) -> (u64, ResolutionTransport, Vec<u8>, GatewayIdentities, bool) {
(
self.policy_generation,
self.transport,
self.response,
self.identities,
self.direct_relay_fallback,
)
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum GatewayStep {
RetryAvailable,
Selected(GatewaySelection),
Unavailable,
}
#[derive(Debug)]
pub struct Gateway {
instance_id: u64,
limits: GatewayLimits,
policy: PolicySnapshot,
plan: TransportPlan,
next_candidate: usize,
sequence: u64,
active: Option<GatewayAttempt>,
terminal: bool,
odoh_attempted: bool,
}
impl Gateway {
pub fn new(policy: PolicySnapshot, limits: GatewayLimits) -> Result<Self, GatewayError> {
let limits = limits.validate()?;
let instance_id = NEXT_GATEWAY_ID
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
current.checked_add(1)
})
.map_err(|_| GatewayError::InstanceSequenceExhausted)?;
Ok(Self {
instance_id,
limits,
policy,
plan: TransportPlan::for_policy(policy.config()),
next_candidate: 0,
sequence: 0,
active: None,
terminal: false,
odoh_attempted: false,
})
}
#[must_use]
pub const fn plan(&self) -> &TransportPlan {
&self.plan
}
pub fn next_attempt(
&mut self,
current_policy: PolicySnapshot,
now: u64,
) -> Result<GatewayAttempt, GatewayError> {
self.ensure_current_policy(current_policy)?;
if self.terminal {
return Err(GatewayError::Terminal);
}
if self.active.is_some() {
return Err(GatewayError::AttemptAlreadyActive);
}
let transport = self
.plan
.as_slice()
.get(self.next_candidate)
.copied()
.ok_or(GatewayError::NoCandidate)?;
let sequence = self
.sequence
.checked_add(1)
.ok_or(GatewayError::SequenceExhausted)?;
let deadline = now
.checked_add(self.limits.attempt_timeout_seconds)
.ok_or(GatewayError::TimeOverflow)?;
let attempt = GatewayAttempt {
instance_id: self.instance_id,
policy_generation: self.policy.generation(),
sequence,
candidate_index: self.next_candidate,
transport,
deadline,
};
self.next_candidate += 1;
self.sequence = sequence;
self.odoh_attempted |= transport == ResolutionTransport::HandshakeP2pOdoh;
self.active = Some(attempt);
Ok(attempt)
}
pub fn complete(
&mut self,
current_policy: PolicySnapshot,
attempt: GatewayAttempt,
outcome: AttemptOutcome,
now: u64,
) -> Result<GatewayStep, GatewayError> {
self.ensure_current_policy(current_policy)?;
let active = self.active.ok_or(GatewayError::NoActiveAttempt)?;
if active != attempt {
return Err(GatewayError::AttemptMismatch);
}
self.active = None;
if now > attempt.deadline {
return self.retry_or_unavailable(TransportFailure::Timeout, attempt.transport);
}
match outcome {
AttemptOutcome::Failure(failure) => {
self.retry_or_unavailable(failure, attempt.transport)
}
AttemptOutcome::Response { bytes, identities } => {
if let Err(error) = self.validate_response(&bytes, &identities, attempt.transport) {
self.terminal = true;
return Err(error);
}
self.terminal = true;
Ok(GatewayStep::Selected(GatewaySelection {
policy_generation: self.policy.generation(),
transport: attempt.transport,
response: bytes,
identities,
direct_relay_fallback: attempt.transport
== ResolutionTransport::HandshakeP2pDnsRelay
&& self.odoh_attempted,
}))
}
}
}
pub fn revoke(&mut self) {
self.active = None;
self.terminal = true;
}
fn retry_or_unavailable(
&mut self,
failure: TransportFailure,
transport: ResolutionTransport,
) -> Result<GatewayStep, GatewayError> {
if !failure.permits_fallback_from(transport) {
self.terminal = true;
return Err(GatewayError::TerminalTransportFailure(failure));
}
if self.next_candidate < self.plan.as_slice().len() {
Ok(GatewayStep::RetryAvailable)
} else {
self.terminal = true;
Ok(GatewayStep::Unavailable)
}
}
fn ensure_current_policy(&mut self, current: PolicySnapshot) -> Result<(), GatewayError> {
if current.generation() != self.policy.generation()
|| current.config() != self.policy.config()
{
self.revoke();
return Err(GatewayError::StalePolicy);
}
Ok(())
}
fn validate_response(
&self,
bytes: &[u8],
identities: &GatewayIdentities,
transport: ResolutionTransport,
) -> Result<(), GatewayError> {
if bytes.is_empty() || bytes.len() > self.limits.maximum_response_bytes {
return Err(GatewayError::InvalidResponseSize);
}
for identity in [
identities.peer.as_deref(),
identities.proxy.as_deref(),
identities.target.as_deref(),
]
.into_iter()
.flatten()
{
if identity.is_empty() || identity.len() > self.limits.maximum_identity_bytes {
return Err(GatewayError::InvalidIdentity);
}
}
match transport {
ResolutionTransport::HandshakeP2pOdoh => {
let proxy = identities
.proxy
.as_deref()
.ok_or(GatewayError::MissingIdentity)?;
let target = identities
.target
.as_deref()
.ok_or(GatewayError::MissingIdentity)?;
if identities.peer.is_some() || proxy == target {
return Err(GatewayError::InvalidIdentityTopology);
}
}
ResolutionTransport::HandshakeP2pDnsRelay => {
if identities.peer.is_none()
|| identities.proxy.is_some()
|| identities.target.is_some()
{
return Err(GatewayError::InvalidIdentityTopology);
}
}
ResolutionTransport::DirectAuthoritativeUdp
| ResolutionTransport::DirectAuthoritativeTcp
| ResolutionTransport::AuthenticatedAuthoritativeDoh
| ResolutionTransport::UserConfiguredRecursiveHnsDoh => {
if identities != &GatewayIdentities::default() {
return Err(GatewayError::InvalidIdentityTopology);
}
}
ResolutionTransport::Unavailable
| ResolutionTransport::ValidatingIcannDoh
| ResolutionTransport::LocalHnsProof => {
return Err(GatewayError::UnavailableTransport);
}
}
Ok(())
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum GatewayError {
#[error("invalid HNS gateway limits")]
InvalidLimits,
#[error("HNS gateway policy is stale")]
StalePolicy,
#[error("HNS gateway attempt is already active")]
AttemptAlreadyActive,
#[error("HNS gateway has no active attempt")]
NoActiveAttempt,
#[error("HNS gateway completion does not match the active attempt")]
AttemptMismatch,
#[error("HNS gateway has no remaining transport candidate")]
NoCandidate,
#[error("HNS gateway is terminal")]
Terminal,
#[error("HNS gateway attempt sequence exhausted")]
SequenceExhausted,
#[error("HNS gateway instance sequence exhausted")]
InstanceSequenceExhausted,
#[error("HNS gateway deadline overflow")]
TimeOverflow,
#[error("HNS gateway transport failed closed: {0:?}")]
TerminalTransportFailure(TransportFailure),
#[error("HNS gateway response size is invalid")]
InvalidResponseSize,
#[error("HNS gateway intermediary identity is missing")]
MissingIdentity,
#[error("HNS gateway intermediary identity is invalid")]
InvalidIdentity,
#[error("HNS gateway intermediary topology is invalid")]
InvalidIdentityTopology,
#[error("unavailable cannot carry a gateway response")]
UnavailableTransport,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "tests fail immediately on invalid deterministic gateway fixtures"
)]
mod tests {
use hns_resolution_policy::{ObliviousDnsPolicy, PolicyConfig, ProviderPolicy, WireProfile};
use super::*;
fn policy() -> PolicySnapshot {
PolicySnapshot::default()
}
fn failure_then_next(
gateway: &mut Gateway,
snapshot: PolicySnapshot,
now: u64,
) -> GatewayAttempt {
let attempt = gateway.next_attempt(snapshot, now).unwrap();
assert_eq!(
gateway
.complete(
snapshot,
attempt,
AttemptOutcome::Failure(TransportFailure::Unreachable),
now
)
.unwrap(),
GatewayStep::RetryAvailable
);
attempt
}
#[test]
fn follows_direct_first_plan_and_derives_relay_downgrade() {
let snapshot = policy();
let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
assert_eq!(
gateway.plan().as_slice(),
[
ResolutionTransport::DirectAuthoritativeUdp,
ResolutionTransport::DirectAuthoritativeTcp,
ResolutionTransport::AuthenticatedAuthoritativeDoh,
ResolutionTransport::HandshakeP2pOdoh,
ResolutionTransport::HandshakeP2pDnsRelay,
]
);
for expected in [
ResolutionTransport::DirectAuthoritativeUdp,
ResolutionTransport::DirectAuthoritativeTcp,
ResolutionTransport::AuthenticatedAuthoritativeDoh,
ResolutionTransport::HandshakeP2pOdoh,
] {
assert_eq!(
failure_then_next(&mut gateway, snapshot, 100).transport(),
expected
);
}
let relay = gateway.next_attempt(snapshot, 100).unwrap();
assert_eq!(relay.transport(), ResolutionTransport::HandshakeP2pDnsRelay);
let selected = gateway
.complete(
snapshot,
relay,
AttemptOutcome::Response {
bytes: vec![1, 2, 3],
identities: GatewayIdentities {
peer: Some("relay-peer".to_owned()),
..GatewayIdentities::default()
},
},
100,
)
.unwrap();
let selected = match selected {
GatewayStep::Selected(selected) => Some(selected),
GatewayStep::RetryAvailable | GatewayStep::Unavailable => None,
}
.unwrap();
assert!(selected.direct_relay_fallback());
assert_eq!(
selected.transport(),
ResolutionTransport::HandshakeP2pDnsRelay
);
}
#[test]
fn malformed_and_authentication_failures_are_terminal() {
for failure in [
TransportFailure::Malformed,
TransportFailure::AuthenticationFailed,
TransportFailure::Cancelled,
] {
let snapshot = policy();
let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
let attempt = gateway.next_attempt(snapshot, 100).unwrap();
assert!(matches!(
gateway.complete(
snapshot,
attempt,
AttemptOutcome::Failure(failure),
100
),
Err(GatewayError::TerminalTransportFailure(actual)) if actual == failure
));
assert!(matches!(
gateway.next_attempt(snapshot, 100),
Err(GatewayError::Terminal)
));
}
}
#[test]
fn enforces_response_bounds_and_transport_identity_topology() {
let mut config = PolicyConfig {
authenticated_authoritative_doh: false,
oblivious_dns: ObliviousDnsPolicy::Required,
providers: ProviderPolicy::default(),
wire_profile: WireProfile::DenuoV1,
..PolicyConfig::default()
};
let snapshot = PolicySnapshot::new(9, config).unwrap();
let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
failure_then_next(&mut gateway, snapshot, 100);
failure_then_next(&mut gateway, snapshot, 100);
let odoh = gateway.next_attempt(snapshot, 100).unwrap();
assert!(matches!(
gateway.complete(
snapshot,
odoh,
AttemptOutcome::Response {
bytes: vec![1],
identities: GatewayIdentities {
proxy: Some("same".to_owned()),
target: Some("same".to_owned()),
..GatewayIdentities::default()
},
},
100
),
Err(GatewayError::InvalidIdentityTopology)
));
assert!(matches!(
gateway.next_attempt(snapshot, 100),
Err(GatewayError::Terminal)
));
config.oblivious_dns = ObliviousDnsPolicy::Disabled;
let direct = PolicySnapshot::new(10, config).unwrap();
let mut gateway = Gateway::new(
direct,
GatewayLimits {
maximum_response_bytes: 2,
..GatewayLimits::default()
},
)
.unwrap();
let attempt = gateway.next_attempt(direct, 100).unwrap();
assert!(matches!(
gateway.complete(
direct,
attempt,
AttemptOutcome::Response {
bytes: vec![1, 2, 3],
identities: GatewayIdentities::default(),
},
100
),
Err(GatewayError::InvalidResponseSize)
));
}
#[test]
fn rejects_stale_policy_mismatched_tokens_and_late_responses() {
let snapshot = policy();
let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
let attempt = gateway.next_attempt(snapshot, 100).unwrap();
assert!(matches!(
gateway.next_attempt(snapshot, 100),
Err(GatewayError::AttemptAlreadyActive)
));
let mut other = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
let foreign = other.next_attempt(snapshot, 100).unwrap();
assert!(matches!(
gateway.complete(
snapshot,
foreign,
AttemptOutcome::Failure(TransportFailure::Timeout),
100
),
Err(GatewayError::AttemptMismatch)
));
assert_eq!(
gateway
.complete(
snapshot,
attempt,
AttemptOutcome::Response {
bytes: vec![1],
identities: GatewayIdentities::default(),
},
111
)
.unwrap(),
GatewayStep::RetryAvailable
);
let changed = PolicySnapshot::new(snapshot.generation() + 1, snapshot.config()).unwrap();
assert!(matches!(
gateway.next_attempt(changed, 112),
Err(GatewayError::StalePolicy)
));
assert!(matches!(
gateway.next_attempt(snapshot, 112),
Err(GatewayError::Terminal)
));
}
#[test]
fn valid_udp_truncation_advances_only_to_direct_tcp() {
let snapshot = policy();
let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
let udp = gateway.next_attempt(snapshot, 100).unwrap();
assert_eq!(udp.transport(), ResolutionTransport::DirectAuthoritativeUdp);
assert_eq!(
gateway
.complete(
snapshot,
udp,
AttemptOutcome::Failure(TransportFailure::Truncated),
100
)
.unwrap(),
GatewayStep::RetryAvailable
);
let tcp = gateway.next_attempt(snapshot, 100).unwrap();
assert_eq!(tcp.transport(), ResolutionTransport::DirectAuthoritativeTcp);
let selected = gateway
.complete(
snapshot,
tcp,
AttemptOutcome::Response {
bytes: vec![1],
identities: GatewayIdentities::default(),
},
100,
)
.unwrap();
assert!(matches!(
selected,
GatewayStep::Selected(selection)
if selection.transport() == ResolutionTransport::DirectAuthoritativeTcp
&& !selection.direct_relay_fallback()
));
}
#[test]
fn recursive_hns_doh_is_terminal_and_requires_policy_consent() {
let config = PolicyConfig {
user_configured_recursive_hns_doh: true,
..PolicyConfig::default()
};
let snapshot = PolicySnapshot::new(2, config).unwrap();
let gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
assert_eq!(
gateway.plan().as_slice().last(),
Some(&ResolutionTransport::UserConfiguredRecursiveHnsDoh)
);
assert!(
gateway
.validate_response(
&[1],
&GatewayIdentities::default(),
ResolutionTransport::UserConfiguredRecursiveHnsDoh,
)
.is_ok()
);
}
#[test]
fn unsupported_final_candidate_reports_unavailable() {
let config = PolicyConfig {
authenticated_authoritative_doh: false,
oblivious_dns: ObliviousDnsPolicy::Disabled,
dns_relay_requester: hns_resolution_policy::DnsRelayRequesterPolicy::Disabled,
..PolicyConfig::default()
};
let snapshot = PolicySnapshot::new(3, config).unwrap();
let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
let first = gateway.next_attempt(snapshot, 100).unwrap();
assert_eq!(
gateway
.complete(
snapshot,
first,
AttemptOutcome::Failure(TransportFailure::Unsupported),
100
)
.unwrap(),
GatewayStep::RetryAvailable
);
let second = gateway.next_attempt(snapshot, 100).unwrap();
assert_eq!(
gateway
.complete(
snapshot,
second,
AttemptOutcome::Failure(TransportFailure::Unsupported),
100
)
.unwrap(),
GatewayStep::Unavailable
);
}
}