/home/runner/work/lyquor/lyquor/barstrainer/src/validation.rs
Line | Count | Source |
1 | | use std::collections::BTreeSet; |
2 | | use std::net::{Ipv4Addr, Ipv6Addr}; |
3 | | use std::str::FromStr; |
4 | | |
5 | | use crate::dns::RecordType; |
6 | | use lyquor_primitives::NodeID; |
7 | | |
8 | | const ACME_CHALLENGE_PREFIX: &str = "_acme-challenge."; |
9 | | const MAX_DOMAIN_LEN: usize = 253; |
10 | | const MAX_LABEL_LEN: usize = 63; |
11 | | |
12 | | #[derive(Debug, thiserror::Error)] |
13 | | pub enum RequestError { |
14 | | #[error("invalid argument: {0}")] |
15 | | InvalidArgument(String), |
16 | | #[error("permission denied: {0}")] |
17 | | PermissionDenied(String), |
18 | | } |
19 | | |
20 | 13 | pub fn normalize_fqdn(input: &str) -> Result<String, RequestError> { |
21 | 13 | let trimmed = input.trim(); |
22 | 13 | if trimmed.is_empty() { |
23 | 0 | return Err(RequestError::InvalidArgument("fqdn is required".into())); |
24 | 13 | } |
25 | | |
26 | 13 | let lower = trimmed.to_ascii_lowercase(); |
27 | 13 | let normalized = lower.trim_end_matches('.'); |
28 | 13 | if normalized.is_empty() { |
29 | 0 | return Err(RequestError::InvalidArgument( |
30 | 0 | "fqdn is empty after normalization".into(), |
31 | 0 | )); |
32 | 13 | } |
33 | 13 | if normalized.len() > MAX_DOMAIN_LEN { |
34 | 0 | return Err(RequestError::InvalidArgument("fqdn exceeds maximum length".into())); |
35 | 13 | } |
36 | | |
37 | 13 | let labels: Vec<&str> = normalized.split('.').collect(); |
38 | 13 | validate_labels(&labels)?0 ; |
39 | | |
40 | 13 | Ok(normalized.to_string()) |
41 | 13 | } |
42 | | |
43 | 2 | pub fn normalize_cname_target(input: &str) -> Result<String, RequestError> { |
44 | 2 | let normalized = normalize_fqdn(input)?0 ; |
45 | 2 | Ok(normalized) |
46 | 2 | } |
47 | | |
48 | 10 | pub fn normalize_ttl(ttl: u32, default_ttl: u32) -> u32 { |
49 | 10 | if ttl == 0 { default_ttl4 } else { ttl6 } |
50 | 10 | } |
51 | | |
52 | 12 | pub fn normalize_record_values(record_type: RecordType, values: Vec<String>) -> Result<Vec<String>, RequestError> { |
53 | 12 | match record_type { |
54 | 6 | RecordType::A => normalize_ipv4_values(values), |
55 | 3 | RecordType::Aaaa => normalize_ipv6_values(values), |
56 | 2 | RecordType::Txt => normalize_txt_values(values), |
57 | 1 | RecordType::Cname => normalize_cname_values(values), |
58 | | } |
59 | 12 | } |
60 | | |
61 | 14 | pub fn validate_fqdn_for_record( |
62 | 14 | fqdn: &str, record_type: RecordType, dns_suffix: &str, node_id: &NodeID, |
63 | 14 | ) -> Result<(), RequestError> { |
64 | 14 | match record_type { |
65 | | RecordType::Txt => { |
66 | 3 | if !fqdn.starts_with(ACME_CHALLENGE_PREFIX) { |
67 | 0 | return Err(RequestError::PermissionDenied( |
68 | 0 | "TXT records must target _acme-challenge names".into(), |
69 | 0 | )); |
70 | 3 | } |
71 | 3 | let base = &fqdn[ACME_CHALLENGE_PREFIX.len()..]; |
72 | 3 | if base.is_empty() { |
73 | 0 | return Err(RequestError::PermissionDenied( |
74 | 0 | "_acme-challenge base name is empty".into(), |
75 | 0 | )); |
76 | 3 | } |
77 | 3 | ensure_allowed_suffix(base, dns_suffix)?0 ; |
78 | 3 | ensure_node_owns_fqdn(base, dns_suffix, node_id)?0 ; |
79 | | } |
80 | | RecordType::A | RecordType::Aaaa | RecordType::Cname => { |
81 | 11 | if fqdn.starts_with(ACME_CHALLENGE_PREFIX) { |
82 | 1 | return Err(RequestError::PermissionDenied( |
83 | 1 | "non-TXT records may not target _acme-challenge names".into(), |
84 | 1 | )); |
85 | 10 | } |
86 | 10 | ensure_allowed_suffix(fqdn, dns_suffix)?1 ; |
87 | 9 | ensure_node_owns_fqdn(fqdn, dns_suffix, node_id)?1 ; |
88 | | } |
89 | | } |
90 | | |
91 | 11 | Ok(()) |
92 | 14 | } |
93 | | |
94 | 6 | fn normalize_ipv4_values(values: Vec<String>) -> Result<Vec<String>, RequestError> { |
95 | 6 | let mut set = BTreeSet::new(); |
96 | 8 | for value in values6 { |
97 | 8 | let trimmed = value.trim(); |
98 | 8 | if trimmed.is_empty() { |
99 | 0 | return Err(RequestError::InvalidArgument("A record value is empty".into())); |
100 | 8 | } |
101 | 8 | let addr7 = Ipv4Addr::from_str(trimmed) |
102 | 8 | .map_err(|_| RequestError::InvalidArgument(format!1 ("invalid IPv4 address: {trimmed}")))?1 ; |
103 | 7 | set.insert(addr.to_string()); |
104 | | } |
105 | 5 | Ok(set.into_iter().collect()) |
106 | 6 | } |
107 | | |
108 | 3 | fn normalize_ipv6_values(values: Vec<String>) -> Result<Vec<String>, RequestError> { |
109 | 3 | let mut set = BTreeSet::new(); |
110 | 3 | for value in values { |
111 | 3 | let trimmed = value.trim(); |
112 | 3 | if trimmed.is_empty() { |
113 | 0 | return Err(RequestError::InvalidArgument("AAAA record value is empty".into())); |
114 | 3 | } |
115 | 3 | let addr2 = Ipv6Addr::from_str(trimmed) |
116 | 3 | .map_err(|_| RequestError::InvalidArgument(format!1 ("invalid IPv6 address: {trimmed}")))?1 ; |
117 | 2 | set.insert(addr.to_string()); |
118 | | } |
119 | 2 | Ok(set.into_iter().collect()) |
120 | 3 | } |
121 | | |
122 | 2 | fn normalize_txt_values(values: Vec<String>) -> Result<Vec<String>, RequestError> { |
123 | 2 | let mut set = BTreeSet::new(); |
124 | 2 | for value in values { |
125 | 2 | set.insert(value); |
126 | 2 | } |
127 | 2 | Ok(set.into_iter().collect()) |
128 | 2 | } |
129 | | |
130 | 1 | fn normalize_cname_values(values: Vec<String>) -> Result<Vec<String>, RequestError> { |
131 | 1 | if values.len() > 1 { |
132 | 1 | return Err(RequestError::InvalidArgument( |
133 | 1 | "CNAME must contain exactly one value".into(), |
134 | 1 | )); |
135 | 0 | } |
136 | 0 | if values.is_empty() { |
137 | 0 | return Ok(Vec::new()); |
138 | 0 | } |
139 | 0 | let target = normalize_cname_target(&values[0])?; |
140 | 0 | Ok(vec![target]) |
141 | 1 | } |
142 | | |
143 | 13 | fn validate_labels(labels: &[&str]) -> Result<(), RequestError> { |
144 | 57 | for label in labels13 { |
145 | 57 | if label.is_empty() { |
146 | 0 | return Err(RequestError::InvalidArgument("fqdn has empty label".into())); |
147 | 57 | } |
148 | 57 | if label.len() > MAX_LABEL_LEN { |
149 | 0 | return Err(RequestError::InvalidArgument( |
150 | 0 | "fqdn label exceeds maximum length".into(), |
151 | 0 | )); |
152 | 57 | } |
153 | 57 | if !label |
154 | 57 | .chars() |
155 | 870 | .all57 (|c| c.is_ascii_lowercase() || c88 .is_ascii_digit88 () || c == '-'16 || c == '_'3 ) |
156 | | { |
157 | 0 | return Err(RequestError::InvalidArgument(format!( |
158 | 0 | "fqdn label contains invalid character: {label}" |
159 | 0 | ))); |
160 | 57 | } |
161 | | } |
162 | 13 | Ok(()) |
163 | 13 | } |
164 | | |
165 | 13 | fn ensure_allowed_suffix(fqdn: &str, dns_suffix: &str) -> Result<(), RequestError> { |
166 | 13 | if !fqdn.ends_with(dns_suffix) { |
167 | 1 | return Err(RequestError::PermissionDenied("fqdn is outside allowed suffix".into())); |
168 | 12 | } |
169 | | |
170 | 12 | let prefix = fqdn.strip_suffix(dns_suffix).unwrap_or(""); |
171 | 12 | let prefix = prefix.trim_end_matches('.'); |
172 | 12 | if prefix.is_empty() { |
173 | 0 | return Err(RequestError::PermissionDenied( |
174 | 0 | "fqdn must include node label within allowed suffix".into(), |
175 | 0 | )); |
176 | 12 | } |
177 | | |
178 | 12 | Ok(()) |
179 | 13 | } |
180 | | |
181 | 12 | fn ensure_node_owns_fqdn(fqdn: &str, dns_suffix: &str, node_id: &NodeID) -> Result<(), RequestError> { |
182 | 12 | let prefix = fqdn.strip_suffix(dns_suffix).unwrap_or("").trim_end_matches('.'); |
183 | 12 | if prefix.is_empty() { |
184 | 0 | return Err(RequestError::PermissionDenied( |
185 | 0 | "fqdn must include node label within allowed suffix".into(), |
186 | 0 | )); |
187 | 12 | } |
188 | | |
189 | | // NodeID::as_dns_label is the canonical label used across lyquor-tls. |
190 | 12 | let label = node_id.as_dns_label(); |
191 | 12 | let mut labels = prefix.split('.'); |
192 | 12 | let last = labels.next_back().unwrap_or(""); |
193 | 12 | if last != label { |
194 | 1 | return Err(RequestError::PermissionDenied("fqdn is not owned by node".into())); |
195 | 11 | } |
196 | | |
197 | 11 | Ok(()) |
198 | 12 | } |