1use crate::error::{AxCode, AxError, GateRefusal};
12use crate::secret::SecretSpan;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum EgressTarget {
21 Loopback,
22 Private,
23 Public {
24 host: String,
25 },
26 Connector {
32 label: crate::tool::ServerLabel,
33 },
34}
35
36#[derive(Debug)]
38pub enum EgressOutcome {
39 Allow {
40 first_public_egress: bool,
43 },
44 Deny {
45 refusal: Box<AxError>,
46 },
47}
48
49pub fn egress(
54 spans: &[SecretSpan],
55 target: &EgressTarget,
56 prior_public_egress: bool,
57) -> EgressOutcome {
58 if !spans.is_empty() {
59 let offsets: Vec<String> = spans
60 .iter()
61 .map(|s| format!("{}+{}", s.start, s.len))
62 .collect();
63 return EgressOutcome::Deny {
64 refusal: Box::new(
65 AxError::refusal(
66 AxCode::SecretEgress,
67 "send bytes out",
68 format!(
69 "{} secret-shaped span(s) at {}",
70 spans.len(),
71 offsets.join(", ")
72 ),
73 GateRefusal::new(
74 "credentials leave only as secret: references (C13)",
75 format!("the payload carries {} secret-shaped span(s)", spans.len()),
76 "replace each span with its secret:<realm>/<name> reference; \
77 if a real credential already left, rotation is the only remedy",
78 ),
79 )
80 .with_recovery(
81 "cut the bytes at the offsets above out of the payload and send the \
82 secret:<realm>/<name> reference in their place; `sprawling status --secrets` \
83 lists the references this machine holds",
84 ),
85 ),
86 };
87 }
88 let leaves_the_machine = matches!(
93 target,
94 EgressTarget::Public { .. } | EgressTarget::Connector { .. }
95 );
96 let first_public_egress = leaves_the_machine && !prior_public_egress;
97 EgressOutcome::Allow {
98 first_public_egress,
99 }
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct EgressAllowlist {
107 suffixes: Vec<String>,
108}
109
110impl EgressAllowlist {
111 #[must_use]
115 pub fn new(entries: Vec<String>) -> EgressAllowlist {
116 let mut suffixes: Vec<String> = entries
117 .into_iter()
118 .map(|entry| entry.trim().trim_start_matches('.').to_ascii_lowercase())
119 .filter(|entry| !entry.is_empty())
120 .collect();
121 suffixes.sort();
122 suffixes.dedup();
123 EgressAllowlist { suffixes }
124 }
125
126 #[must_use]
127 pub fn is_empty(&self) -> bool {
128 self.suffixes.is_empty()
129 }
130
131 pub fn entries(&self) -> impl Iterator<Item = &str> {
132 self.suffixes.iter().map(String::as_str)
133 }
134
135 #[must_use]
139 pub fn admits(&self, host: &str) -> bool {
140 let host = host.trim_end_matches('.').to_ascii_lowercase();
141 self.suffixes.iter().any(|allowed| {
142 host == *allowed
143 || host
144 .strip_suffix(allowed.as_str())
145 .is_some_and(|head| head.ends_with('.'))
146 })
147 }
148}
149
150pub fn host_of(url: &str) -> Result<Option<String>, AxError> {
163 let Some((_scheme, rest)) = url.split_once("://") else {
164 return Ok(None);
165 };
166 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
167 let after_user = authority
168 .rsplit_once('@')
169 .map_or(authority, |(_userinfo, host)| host);
170 if after_user.is_empty() {
171 return Ok(None);
172 }
173 if let Some(bracketed) = after_user.strip_prefix('[') {
174 let (inside, _tail) = bracketed.split_once(']').ok_or_else(malformed_host)?;
175 if inside.is_empty() {
176 return Err(malformed_host());
177 }
178 return Ok(Some(inside.to_ascii_lowercase()));
179 }
180 let host = if let Some((candidate, port)) = after_user.rsplit_once(':') {
181 if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
182 candidate
183 } else {
184 after_user
185 }
186 } else {
187 after_user
188 };
189 if host.is_empty() || host.contains(':') {
190 return Err(malformed_host());
191 }
192 Ok(Some(host.to_ascii_lowercase()))
193}
194
195fn malformed_host() -> AxError {
196 AxError::failure(
197 AxCode::InvalidArgs,
198 "read a url",
199 "the url's host is not a host",
200 )
201 .with_recovery("pass an absolute url, for example `https://example.com/page`")
202}
203
204#[must_use]
217pub fn target_of(host: &str) -> EgressTarget {
218 let bare = host
219 .trim()
220 .trim_end_matches('.')
221 .trim_start_matches('[')
222 .trim_end_matches(']')
223 .to_ascii_lowercase();
224 if bare == "localhost" || bare == "::1" {
225 return EgressTarget::Loopback;
226 }
227 let octets = ipv4_octets(&bare);
228 if let Some([first, second, ..]) = octets {
229 if first == 127 {
230 return EgressTarget::Loopback;
231 }
232 let private = first == 10
233 || (first == 192 && second == 168)
234 || (first == 172 && (16..=31).contains(&second))
235 || (first == 169 && second == 254);
236 if private {
237 return EgressTarget::Private;
238 }
239 }
240 if let Some(leading) = ipv6_leading(&bare) {
241 if (leading & 0xfe00) == 0xfc00 || (leading & 0xffc0) == 0xfe80 {
244 return EgressTarget::Private;
245 }
246 }
247 EgressTarget::Public {
248 host: bare.to_owned(),
249 }
250}
251
252fn ipv4_octets(host: &str) -> Option<[u8; 4]> {
256 let mut octets = [0u8; 4];
257 let mut at = 0usize;
258 for part in host.split('.') {
259 let octet: u8 = part.parse().ok()?;
260 let slot = octets.get_mut(at)?;
261 *slot = octet;
262 at = at.saturating_add(1);
263 }
264 if at == 4 { Some(octets) } else { None }
265}
266
267fn ipv6_leading(host: &str) -> Option<u16> {
269 if !host.contains(':') {
270 return None;
271 }
272 let first = host.split(':').next()?;
273 u16::from_str_radix(first, 16).ok()
274}
275
276#[must_use]
286pub fn egress_target(list: &EgressAllowlist, target: &EgressTarget) -> EgressOutcome {
287 if let EgressTarget::Connector { .. } = target {
291 return EgressOutcome::Allow {
292 first_public_egress: true,
293 };
294 }
295 let EgressTarget::Public { host } = target else {
296 return EgressOutcome::Allow {
297 first_public_egress: false,
298 };
299 };
300 if list.admits(host) {
301 return EgressOutcome::Allow {
302 first_public_egress: true,
303 };
304 }
305 let known: Vec<&str> = list.entries().collect();
306 let alternative = if known.is_empty() {
307 "this building reaches nothing public; add a domain to its egress list, \
308 or do the work in a building that has one"
309 .to_owned()
310 } else {
311 format!("reachable domains here: {}", known.join(", "))
312 };
313 EgressOutcome::Deny {
314 refusal: Box::new(
315 AxError::refusal(
316 AxCode::GateDenied,
317 "reach a host outside this building's egress list",
318 host.clone(),
319 GateRefusal::new(
320 "a building reaches only the domains it names",
321 format!("{host} is not one of them"),
322 alternative,
323 ),
324 )
325 .with_recovery(format!(
326 "the egress list lives in the building's `.sprawling/RULES.toml`, \
327 which the User edits; ask the User to add {host} to it"
328 )),
329 ),
330 }
331}
332
333#[cfg(test)]
334#[allow(
335 clippy::unwrap_used,
336 clippy::expect_used,
337 clippy::panic,
338 clippy::indexing_slicing,
339 reason = "test code"
340)]
341mod tests;