ssh_browser/origin/mod.rs
1//! The HTTP origin: what the browser actually talks to.
2//!
3//! The daemon answers two shapes of request on one loopback listener. A proxied
4//! request arrives in absolute form because a PAC sent it here, and its Host is
5//! `<alias>.<suffix>`; that is the path which gives the page a real origin under
6//! the URL the user typed. A direct request arrives by address and exists so the
7//! daemon is usable without touching proxy settings at all.
8//!
9//! Every request starts at the listing cache, not at the remote. One fresh listing
10//! of a parent directory answers four questions locally -- does this name exist, is
11//! it a directory, is it a symlink, and is the copy the browser already holds still
12//! current -- and only a body the cache does not hold costs a round trip.
13
14pub mod guard;
15pub mod mime;
16pub mod pac;
17pub mod range;
18
19use std::collections::{HashMap, HashSet};
20use std::net::SocketAddr;
21use std::sync::atomic::Ordering;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use tokio::sync::RwLock;
26
27use anyhow::{Context, Result, bail, ensure};
28use bytes::Bytes;
29use http_body_util::Full;
30use hyper::header::{
31 ACCEPT_RANGES, CACHE_CONTROL, CONTENT_RANGE, CONTENT_SECURITY_POLICY, CONTENT_TYPE, ETAG, HOST,
32 HeaderName, HeaderValue, IF_NONE_MATCH, IF_RANGE, LOCATION, RANGE,
33};
34use hyper::server::conn::http1;
35use hyper::service::service_fn;
36use hyper::{Method, Request, Response, StatusCode};
37use hyper_util::rt::TokioIo;
38use tokio::net::TcpListener;
39
40use crate::cache::{self, Cache};
41use crate::control::{self, Token};
42use crate::fs::sftp::SftpFs;
43use crate::fs::{Entry, RangeReq, RemoteFs};
44use crate::prefetch;
45use crate::reachable;
46use crate::sftp::wire::Attrs;
47use crate::ssh_config;
48use crate::theme;
49use crate::tls;
50use rustls_pki_types::pem::PemObject;
51
52/// A file worth holding whole. Anything larger is served by range and not cached: a
53/// seek into a video must not pull the entire file, and holding one would evict every
54/// page body that makes a revisit free.
55const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
56
57/// The request headers that change what is served rather than what is found.
58struct Conditions {
59 if_none_match: Option<String>,
60 range: Option<String>,
61 if_range: Option<String>,
62 /// Only ever consulted on the control path, which only a loopback request reaches.
63 control_token: Option<String>,
64 /// What the browser said about who started this request, if a browser started it.
65 ///
66 /// A forbidden header name, so a page can neither set it nor suppress it. See
67 /// `control::from_a_page`.
68 fetch_site: Option<String>,
69}
70
71/// One alias, checked.
72///
73/// The fields are private and [`Alias::new`] is the only way to make one, so there is no
74/// route into the daemon that skips these checks. That matters now that aliases can come
75/// from a configuration file as well as from the command line: two entry points and one
76/// validating constructor is fine, two entry points and two copies of the rules is how the
77/// looser copy becomes the one that gets used.
78#[derive(Debug)]
79pub struct Alias {
80 name: String,
81 host: String,
82 /// Where this alias is rooted, or `None` for the remote's home directory.
83 ///
84 /// Deferred rather than filled in with a guess, because the answer lives on the
85 /// remote. `~` is shell syntax and this transport never runs a shell; expanding it
86 /// here would produce this machine's home directory, which is a different computer's.
87 /// It is resolved once on connecting, by asking.
88 base: Option<String>,
89 named: Named,
90}
91
92/// Where an alias's name came from, which is what failing to open it means.
93///
94/// A name typed for this run is something the reader is standing there waiting on, so a daemon
95/// that started without it would be answering a different question than the one asked. A name
96/// in a config file is what they use in a week: a cluster in maintenance, a laptop off the
97/// VPN, an agent with no key loaded yet. Refusing to start until every one of those answers
98/// makes the daemon useless exactly when it is most wanted -- which is the rule `[[host]]` has
99/// followed all along, written down two hundred lines below this and not applied here.
100#[derive(Clone, Copy, PartialEq, Eq, Debug)]
101pub enum Named {
102 /// Typed on the command line: `ssh-browser serve docs=myhost`.
103 ForThisRun,
104 /// `[[alias]]` in the config file.
105 InTheFile,
106}
107
108impl Alias {
109 pub fn new(name: &str, host: &str, base: Option<&str>) -> Result<Self> {
110 ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
111 // The alias becomes a hostname label, and this is the very function that decides
112 // whether an arriving request's label is acceptable. Asking it, rather than writing
113 // the rule out again, is what stops the two from disagreeing — and they already had:
114 // `-docs` satisfied the copy here and was then refused by `classify` on every single
115 // request, after the daemon had paid for the ssh connection and advertised the route.
116 ensure!(
117 guard::is_label(name),
118 "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
119 );
120 if let Some(base) = base {
121 ensure!(
122 is_base(base),
123 "alias {name:?} needs a base that is an absolute path, or `~`, or `~/` and a path under the home directory with no `..` in it, got {base:?}"
124 );
125 }
126 Ok(Self {
127 name: name.to_string(),
128 host: host.to_string(),
129 base: base.map(str::to_string),
130 named: Named::InTheFile,
131 })
132 }
133
134 /// The same alias, but typed on the command line rather than read out of a file.
135 ///
136 /// Only `parse_alias` calls this, and the asymmetry is the point: failing to open one of
137 /// these stops the daemon, and that is a thing to opt into at one call site rather than a
138 /// default every construction inherits.
139 pub fn for_this_run(self) -> Self {
140 Self {
141 named: Named::ForThisRun,
142 ..self
143 }
144 }
145
146 pub fn name(&self) -> &str {
147 &self.name
148 }
149
150 pub fn host(&self) -> &str {
151 &self.host
152 }
153
154 /// Where this alias is rooted, or `None` for the remote's home directory.
155 pub fn base(&self) -> Option<&str> {
156 self.base.as_deref()
157 }
158
159 pub fn named(&self) -> Named {
160 self.named
161 }
162}
163
164/// One line of the host list: a host ssh knows, and what this daemon is doing with it.
165#[derive(serde::Serialize)]
166struct KnownHost {
167 alias: String,
168 host: String,
169 #[serde(flatten)]
170 settings: ssh_config::Settings,
171 /// Whether this daemon has an alias for it right now.
172 ///
173 /// Named for what it is rather than "connected": what the extension needs to know is
174 /// whether a URL for this alias will answer, and that is a question about routing.
175 served: bool,
176 /// Whether this daemon opens it on its own, every run.
177 ///
178 /// Distinct from `served`, and the difference is the whole feature: `served` is about
179 /// now, `enabled` is about next time. A host opened by hand is served and not enabled; a
180 /// host enabled while its ssh was down is enabled and not served.
181 enabled: bool,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 unresolved: Option<String>,
184}
185
186/// An alias being served right now.
187///
188/// A separate list from the hosts, because it answers a different question and the two do
189/// not line up: an alias need not be named after its host, so a session opened as
190/// `docs=myhost:/srv` matches no row in ssh_config at all. Reporting only the hosts would
191/// leave it being served and visible nowhere, which is the kind of invisible live state
192/// this daemon is supposed not to have.
193#[derive(serde::Serialize)]
194struct OpenAlias {
195 alias: String,
196 host: String,
197 base: String,
198 url: String,
199 /// Remote round trips this session has cost since it opened.
200 ///
201 /// The central claim of this daemon is a round-trip count, and until this was reported
202 /// the counter behind it existed only for unit tests against a fake remote — so nobody
203 /// could check the claim against their own host and their own site, which is the only
204 /// place it can be wrong in a way that matters. Read it twice and subtract.
205 ///
206 /// Monotonic and per session, so it resets when an alias is closed and reopened.
207 trips: u64,
208}
209
210#[derive(serde::Serialize)]
211struct KnownHosts {
212 open: Vec<OpenAlias>,
213 /// Declared aliases that are not connected, and why.
214 ///
215 /// The other half of `open`, and the half that used to not exist: before the daemon could
216 /// run without every alias connected, a name was either being served or the daemon was not
217 /// running. Now one can be neither, and a reader who is not told which is looking at the
218 /// silent failure this project says it does not have.
219 stalled: Vec<StalledAlias>,
220 hosts: Vec<KnownHost>,
221 unusable: Vec<ssh_config::Unusable>,
222 /// What TLS handshakes have done, under https.
223 ///
224 /// Here as well as on `hello` because this is the route the dashboard polls, and the answer
225 /// belongs where somebody is looking. `None` under http, where there are no handshakes.
226 #[serde(skip_serializing_if = "Option::is_none")]
227 tls: Option<control::Handshakes>,
228}
229
230/// How long a failed dial is remembered before another is attempted.
231///
232/// Not a backoff and not tuning. It is the smallest number that makes a remote page unable to
233/// choose how often this machine opens an ssh, while leaving a reload a real retry. See
234/// `session_for`.
235const DIAL_COOLDOWN: Duration = Duration::from_secs(3);
236
237/// What happened the last time an alias was dialled, and when.
238struct Trouble {
239 at: Instant,
240 why: String,
241}
242
243/// A declared alias with no connection behind it.
244#[derive(serde::Serialize)]
245struct StalledAlias {
246 alias: String,
247 host: String,
248 url: String,
249 /// What ssh said the last time this was tried, or `None` if it has not been tried since
250 /// it was stopped.
251 #[serde(skip_serializing_if = "Option::is_none")]
252 why: Option<String>,
253 /// Stopped from the dashboard rather than unreachable. A different thing to do about it:
254 /// one is fixed by a restart, the other by the host coming back.
255 stopped: bool,
256}
257
258/// Whether a configured base is one this daemon can resolve.
259///
260/// `~` is accepted here and nowhere else in the codebase. It is shell syntax, and this
261/// transport never runs a shell, so it is not passed through to anything: it is a
262/// stand-in for an answer only the remote has, substituted in `bind` once the session
263/// exists. Writing the home path out by hand is the alternative, and it means knowing
264/// another machine's account layout in order to name a directory you can already `cd` to.
265///
266/// `..` is refused rather than normalised. `~/..` quietly meaning the parent of the home
267/// directory is the kind of surprise that belongs in a base path least of all, since the
268/// base is the blast radius of every page served under it.
269fn is_base(base: &str) -> bool {
270 if base.starts_with('/') {
271 return true;
272 }
273 let Some(rest) = base.strip_prefix('~') else {
274 return false;
275 };
276 match rest {
277 "" => true,
278 rest => match rest.strip_prefix('/') {
279 Some(under) => {
280 !under.is_empty()
281 && under
282 .split('/')
283 .all(|c| !c.is_empty() && c != "." && c != "..")
284 }
285 None => false,
286 },
287 }
288}
289
290/// The absolute base an alias is rooted at, asking the remote only when the answer needs
291/// asking.
292///
293/// Separated from `bind` because `bind` starts an ssh subprocess, which no test can, and
294/// this is the part of it with a decision in it.
295async fn resolve_base(base: Option<&str>, fs: &SftpFs) -> Result<String> {
296 let under = match base {
297 None | Some("~") => "",
298 Some(b) => match b.strip_prefix("~/") {
299 Some(under) => under,
300 // Already absolute. Nothing to ask the remote, and asking anyway would put an
301 // ssh round trip in front of every startup for no answer.
302 None => return Ok(b.to_string()),
303 },
304 };
305 let home = fs.home().await?;
306 let home = home.trim_end_matches('/');
307 // A home of `/` would otherwise produce `//work`, which is not the same path
308 // everywhere: POSIX leaves a leading double slash implementation-defined.
309 let home = if home.is_empty() { "" } else { home };
310 Ok(match under {
311 "" if home.is_empty() => "/".to_string(),
312 "" => home.to_string(),
313 under => format!("{home}/{under}"),
314 })
315}
316
317/// One alias's session, or nothing if no such alias is open.
318///
319/// The guard is dropped before returning, so nothing a caller does afterwards holds up
320/// another request.
321impl Origin {
322 async fn session(&self, alias: &str) -> Option<Arc<Session>> {
323 self.sessions.read().await.get(alias).cloned()
324 }
325
326 /// Where a site lives, as a reader would type it.
327 ///
328 /// One place, because the scheme is part of the origin: a URL built with the wrong one is not
329 /// a cosmetic slip but a link into a different origin than the one being served.
330 fn site_url(&self, alias: &str) -> String {
331 format!("{}://{alias}.{}/", self.scheme, self.suffix)
332 }
333
334 async fn alias_names(&self) -> Vec<String> {
335 let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
336 names.sort();
337 names
338 }
339
340 /// Remote round trips every open session has cost, added up.
341 async fn round_trips(&self) -> u64 {
342 self.sessions
343 .read()
344 .await
345 .values()
346 .map(|s| s.fs.round_trips())
347 .sum()
348 }
349}
350
351struct Session {
352 /// The ssh_config name this was reached by.
353 ///
354 /// Kept because an alias need not be named after its host — `docs=myhost:/srv` is one
355 /// of each — so without it the only thing that could be reported about a live session
356 /// is a name that appears nowhere in ssh_config.
357 host: String,
358 base: String,
359 fs: SftpFs,
360}
361
362pub struct Origin {
363 suffix: String,
364 port: u16,
365 /// The aliases being served right now.
366 ///
367 /// Behind a lock because the set changes while the daemon runs: a host is opened when
368 /// somebody picks it, not when the daemon starts. Starting six ssh sessions so that a
369 /// popup could list six hosts would make looking at the list cost more than using one.
370 ///
371 /// The values are `Arc`d so a request can take its session and let go of the lock.
372 /// Holding a read guard across the awaits a page costs would block every open for the
373 /// length of a remote read, and `Session` owns the ssh child — dropping one kills the
374 /// connection, so it cannot simply be cloned out.
375 sessions: RwLock<HashMap<String, Arc<Session>>>,
376 cache: Cache,
377 token: Token,
378 /// What a directory listing looks like.
379 ///
380 /// Behind a lock because it is chosen from the dashboard while the daemon runs, and it
381 /// is one setting for every alias: an origin that looked different from its neighbour
382 /// for no reason the reader chose would be a bug rather than a feature.
383 theme: RwLock<String>,
384 /// `http` or `https`. What a site's URL starts with, and whether `CONNECT` is answered.
385 ///
386 /// Held here rather than consulted from the config, because six places build a site URL and
387 /// a seventh that forgot would hand somebody a link to the wrong scheme — which under
388 /// https is not a cosmetic difference but a different origin.
389 scheme: String,
390 /// What TLS handshakes have done, which is the only portable way to learn whether the
391 /// authority is trusted.
392 ///
393 /// Nothing can ask a trust store the question directly and get a portable answer — but a
394 /// handshake *is* the answer. One that completes proves the browser accepted the
395 /// certificate; one that fails at this stage almost always means it did not. So the daemon
396 /// stops guessing and reports what happened.
397 handshakes: Handshakes,
398 /// Which extension is the dashboard, once one has said so.
399 ///
400 /// There is one dashboard, and it is the extension's. This is how the root of the suffix
401 /// knows where to send a browser instead of drawing a second one: the extension names
402 /// itself on `hello`, and the root hands that back as the address to go to.
403 ///
404 /// Only ever an id — never a token, never anything about a host — and only from a caller
405 /// that already holds the control token. Held rather than stored, so a daemon that has
406 /// not been connected to since it started simply has no dashboard to point at, which is
407 /// the truth at that moment.
408 dashboard: RwLock<Option<String>>,
409 /// Every alias this daemon serves, whether or not it is connected.
410 ///
411 /// Fixed after `bind`: aliases come from the command line and the config file, and neither
412 /// changes while the daemon runs. Hosts opened from the dashboard are a different set and
413 /// live in `reachable`.
414 declared: HashMap<String, Declared>,
415 /// Why an alias is not connected, from the last attempt.
416 ///
417 /// Kept so the reason survives the request that discovered it. A reader who opens a site
418 /// and gets `ssh: connect to host ... port 22: Network is unreachable` learns something;
419 /// one who opens the dashboard an hour later and sees a name with no explanation beside it
420 /// does not, and that is the same failure told twice.
421 trouble: RwLock<HashMap<String, Trouble>>,
422 /// Declared aliases stopped from the dashboard, for this run only.
423 ///
424 /// Without this, stopping one would be undone by the next request that reached it, and a
425 /// button that undoes itself is worse than no button. Not persisted, because for a
426 /// declared alias the persistent statement is the config file: a restart serves it again,
427 /// and the answer says so rather than leaving somebody to discover it.
428 stopped: RwLock<HashSet<String>>,
429 /// How long a failed dial is remembered. `DIAL_COOLDOWN` everywhere but in tests.
430 ///
431 /// A field rather than the constant read directly, so a test can set it to zero and watch
432 /// the dial happen again. Left as the constant, the only thing a test can observe is the
433 /// same answer twice -- which is what a latched failure looks like too, so the test would
434 /// pass either way and mean nothing.
435 cooldown: Duration,
436 /// The certificate resolver, when there is one.
437 ///
438 /// Held here as well as inside the TLS configuration, because `/_control/certificate` answers
439 /// what is served for a name — and that has to be the same certificate a handshake gets, from
440 /// the same cache, or the key pin it reports is for something nobody will ever see.
441 certificates: Option<Arc<PerName>>,
442 /// The serving certificate, when there is one.
443 ///
444 /// `None` under http, and that is what a `CONNECT` is refused by: there is no certificate to
445 /// terminate with, and answering `200` and then failing the handshake would tell the browser
446 /// the tunnel was fine.
447 tls: Option<Arc<rustls::ServerConfig>>,
448 /// Which `ssh_config` hosts this daemon opens without being asked.
449 ///
450 /// Never consulted while serving a request. It decides what happens at startup and what
451 /// a toggle does, and nothing else — see `crate::reachable` for why an "open it when a
452 /// request arrives" version would hand any web page the ability to start ssh sessions.
453 reachable: RwLock<reachable::Set>,
454}
455
456/// A listening socket and the origin that will answer on it.
457///
458/// Separate from [`Origin`] so that "the port is ours" is a thing the caller holds rather
459/// than something it hopes for. A caller cannot announce that the daemon is up before it
460/// is, because it has nothing to announce until this exists.
461pub struct Bound {
462 origin: Arc<Origin>,
463 listener: TcpListener,
464}
465
466/// What to tell the reader about a daemon that has just come up.
467///
468/// Returned beside [`Bound`] rather than reachable through it, and that separation is
469/// load-bearing rather than tidy. `Bound` owns the `Origin`, and the `Origin` owns the TLS
470/// configuration, and that owns a private key — so a banner line reached through `Bound` is a
471/// string a static analyser must treat as derived from the key, and it said so: CodeQL flagged
472/// both `eprintln!`s in `main` as cleartext logging of the certificate resolver.
473///
474/// It was wrong about the values — these are host names and paths — and right about the shape.
475/// Handing the caller strings that were never near the key makes the question unanswerable
476/// rather than answered.
477pub struct Startup {
478 routes: Vec<String>,
479 refused: Vec<String>,
480 trust: Option<String>,
481}
482
483impl Startup {
484 /// One line per alias, naming where it actually points.
485 ///
486 /// Only available once bound, which is the point: an alias rooted at the home directory has
487 /// no printable base until the remote has been asked.
488 pub fn routes(&self) -> &[String] {
489 &self.routes
490 }
491
492 /// Enabled hosts that would not connect, and what ssh said about each.
493 ///
494 /// Separate from `routes` so a caller cannot print them as though they were working. Empty
495 /// on an ordinary run; not an error, because the daemon is serving everything else.
496 pub fn refused(&self) -> &[String] {
497 &self.refused
498 }
499
500 /// What to say about the certificate, under https.
501 ///
502 /// `None` under http, where there is nothing to trust. Under https there is always
503 /// something to say, because nothing portable can tell whether the authority is *still*
504 /// trusted — and a daemon that advertised `https://...` and said nothing else left a
505 /// first-time reader at `ERR_CERT_AUTHORITY_INVALID` with no way to guess what to do. That
506 /// was measured by following the banner on a machine that had never run this.
507 pub fn trust(&self) -> Option<&str> {
508 self.trust.as_deref()
509 }
510}
511
512/// One alias this daemon is meant to serve, connected or not.
513///
514/// Separate from `sessions`, which is what is connected *now*, because they answer different
515/// questions and answering both with one map is what made a host that was asleep at breakfast
516/// a name the daemon had never heard of at lunch. The PAC routes this set; the dashboard lists
517/// it; a request against it opens a connection if there is not one already.
518struct Declared {
519 host: String,
520 base: Option<String>,
521 named: Named,
522 /// Held while dialling, so a page's subresources do not each start their own ssh.
523 ///
524 /// A first visit to an alias that is not connected is one navigation and then forty
525 /// requests for stylesheets, scripts and images, all within a few milliseconds and all
526 /// finding no session. Without this they would open forty ssh connections, thirty-nine of
527 /// which lose the race in `adopt` and are dropped -- which is the correct answer arrived at
528 /// by the most expensive route available.
529 dialling: tokio::sync::Mutex<()>,
530}
531
532impl Origin {
533 /// Take the port, then connect every alias.
534 ///
535 /// The port first, deliberately. It is the thing that fails immediately and for a
536 /// reason the operator can do something about — another daemon already has it — and a
537 /// handful of ssh handshakes paid before discovering that is time spent to learn
538 /// nothing.
539 ///
540 /// The aliases are connected here rather than on first use so that the first page
541 /// request does not also pay for an ssh handshake.
542 pub async fn bind(
543 aliases: Vec<Alias>,
544 hosts: reachable::Set,
545 suffix: String,
546 scheme: String,
547 port: u16,
548 token: Token,
549 theme: String,
550 ) -> Result<(Bound, Startup)> {
551 let addr = SocketAddr::from(([127, 0, 0, 1], port));
552 let listener = TcpListener::bind(addr)
553 .await
554 .with_context(|| format!("bind {addr}"))?;
555
556 // Held to the same rule the PAC is, and here rather than only there: a suffix the
557 // PAC would refuse is one no alias URL can ever match, so starting with it produces a
558 // daemon that listens and serves nothing.
559 ensure!(
560 pac::is_suffix(&suffix),
561 "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
562 );
563 // Refused here rather than at the first listing: it is configuration, so it is
564 // refused where the rest of the configuration is.
565 theme::check(&theme)?;
566
567 // The certificate is made before the listener answers anything, so a misconfigured
568 // https mode fails at startup with a reason rather than on the first request with a
569 // handshake error. Under http nothing is generated at all: no key is written for a
570 // feature nobody asked for.
571 let (tls, certificates, trust) = match scheme.as_str() {
572 "http" => (None, None, None),
573 "https" => {
574 let (config, resolver, advice) = serving_config(&suffix)?;
575 (Some(Arc::new(config)), Some(resolver), Some(advice))
576 }
577 other => bail!("scheme {other:?} is not one this daemon serves; use http or https"),
578 };
579
580 let mut declared = HashMap::new();
581 for a in aliases {
582 // Checked where the map is built, so there is no way to reach one with a name
583 // silently missing from it. A caller may have checked earlier and should;
584 // `insert` returning the displaced value is the check that cannot be skipped.
585 ensure!(
586 declared
587 .insert(
588 a.name.clone(),
589 Declared {
590 host: a.host.clone(),
591 base: a.base.clone(),
592 named: a.named,
593 dialling: tokio::sync::Mutex::new(()),
594 },
595 )
596 .is_none(),
597 "alias {:?} is defined twice",
598 a.name
599 );
600 }
601
602 let origin = Arc::new(Self {
603 suffix,
604 scheme,
605 certificates,
606 tls,
607 port,
608 sessions: RwLock::new(HashMap::new()),
609 cache: Cache::default(),
610 token,
611 theme: RwLock::new(theme),
612 reachable: RwLock::new(hosts),
613 handshakes: Handshakes::default(),
614 dashboard: RwLock::new(None),
615 declared,
616 trouble: RwLock::new(HashMap::new()),
617 stopped: RwLock::new(HashSet::new()),
618 cooldown: DIAL_COOLDOWN,
619 });
620
621 // Opened here rather than on first use so that the first page request does not also
622 // pay for an ssh handshake -- and at once rather than in turn, because these are
623 // independent handshakes and in sequence three hosts cost the sum of three round trips
624 // before the daemon answers anything.
625 let (mut routes, refused_aliases) = origin.open_declared().await?;
626
627 // Enabled hosts, on the same terms now: reported and retried rather than fatal. They
628 // used to be the only ones treated that way, with the reason written out beside them,
629 // and the reason was never specific to them.
630 let (opened, mut refused) = origin.open_enabled().await;
631 routes.extend(opened);
632 refused.extend(refused_aliases);
633 refused.sort();
634
635 Ok((
636 Bound { origin, listener },
637 Startup {
638 routes,
639 refused,
640 trust,
641 },
642 ))
643 }
644}
645
646impl Bound {
647 pub async fn serve(self) -> Result<()> {
648 let Bound { origin, listener } = self;
649 let self_ = origin;
650
651 loop {
652 let (stream, _) = listener.accept().await?;
653 let me = Arc::clone(&self_);
654 tokio::spawn(async move {
655 let outer = Arc::clone(&me);
656 let service = service_fn(move |req: Request<hyper::body::Incoming>| {
657 let me = Arc::clone(&outer);
658 async move {
659 if req.method() == Method::CONNECT {
660 return Ok::<_, std::convert::Infallible>(me.tunnel(req));
661 }
662 Ok(me.handle(req).await)
663 }
664 });
665 // Keep-alive is not a nicety here: a page pulls many subresources
666 // and a fresh connection each time would add a local handshake per
667 // request on top of the remote cost.
668 //
669 // `with_upgrades` is what lets the `CONNECT` above hand back the socket. Without
670 // it the response would be sent and the connection then closed, which reads to
671 // the browser as a proxy that accepted the tunnel and dropped it.
672 let _ = http1::Builder::new()
673 .serve_connection(TokioIo::new(stream), service)
674 .with_upgrades()
675 .await;
676 });
677 }
678 }
679}
680
681impl Origin {
682 /// Answer `CONNECT <alias>.<suffix>:443`, then speak TLS inside the socket.
683 ///
684 /// This is the whole of the https mode's plumbing. The browser will not send an https request
685 /// to a proxy in the clear; it asks for a tunnel, and whatever answers inside that tunnel has
686 /// to present a certificate for the name it asked for. So the daemon is both the proxy and
687 /// the server on the other side of it.
688 ///
689 /// The target is checked before the tunnel is granted, and checked by the same `classify` that
690 /// guards every other request. A proxy that tunnels anywhere is an open proxy, and this one
691 /// listens on loopback where every process on the machine can reach it.
692 fn tunnel(self: &Arc<Self>, mut req: Request<hyper::body::Incoming>) -> Response<Full<Bytes>> {
693 // `CONNECT` puts the target in the authority, not the path: `CONNECT host:443 HTTP/1.1`.
694 let Some(authority) = req.uri().authority().map(ToString::to_string) else {
695 return fail(StatusCode::BAD_REQUEST, "CONNECT carries no authority");
696 };
697
698 // The guards run first, before anything about the https mode is consulted. Two reasons:
699 // they are the part that has to hold in either mode, and a daemon that answered "https is
700 // not configured" to a tunnel it would refuse anyway has told the caller something about
701 // its configuration in exchange for nothing.
702 //
703 // Only a name this daemon serves, and only its https port. Tunnelling anywhere else would
704 // turn a loopback listener into a way out of the machine for whatever can reach it —
705 // including a page, through its own subresource loads.
706 let (name, port) = match authority.rsplit_once(':') {
707 Some((name, port)) => (name, port),
708 None => (authority.as_str(), "443"),
709 };
710 if port != "443" {
711 return fail(
712 StatusCode::FORBIDDEN,
713 format!("CONNECT to port {port} is refused; only 443 is tunnelled"),
714 );
715 }
716 if guard::classify(name, "/", &self.suffix, self.port).is_err() {
717 return fail(
718 StatusCode::FORBIDDEN,
719 format!("{name:?} is not a name this daemon serves"),
720 );
721 }
722
723 let Some(config) = self.tls.clone() else {
724 return fail(
725 StatusCode::NOT_IMPLEMENTED,
726 concat!(
727 "this daemon serves http; CONNECT needs the https mode and a certificate. ",
728 "Set scheme = \"https\" and see `ssh-browser trust`.",
729 ),
730 );
731 };
732
733 let me = Arc::clone(self);
734 let upgrade = hyper::upgrade::on(&mut req);
735 tokio::spawn(async move {
736 let Ok(upgraded) = upgrade.await else {
737 return;
738 };
739 let acceptor = tokio_rustls::TlsAcceptor::from(config);
740 let tls = match acceptor.accept(TokioIo::new(upgraded)).await {
741 Ok(tls) => {
742 me.handshakes.completed.fetch_add(1, Ordering::Relaxed);
743 tls
744 }
745 Err(e) => {
746 // Reported, and this used to be silent. The browser shows the reader
747 // `ERR_CERT_AUTHORITY_INVALID` on the page — but the fix is a command, and
748 // commands live in the terminal, which is where nothing was being said.
749 //
750 // Only the first, because a page pulls many subresources and every one of
751 // them fails the same way: the second copy onwards is noise around the one
752 // line that matters.
753 if me.handshakes.failed.fetch_add(1, Ordering::Relaxed) == 0 {
754 eprintln!();
755 eprintln!("a browser refused the certificate: {e}");
756 // Two calls rather than one string with a line continuation in it. Three
757 // separate messages here have now shipped carrying the source file's own
758 // indentation into the middle of a sentence.
759 eprintln!(" almost always the local authority is not trusted yet.");
760 eprintln!(" `ssh-browser trust` prints how to trust it.");
761 }
762 return;
763 }
764 };
765 // Inside the tunnel the requests are ordinary origin-form GETs carrying a `Host`,
766 // so they go through exactly the same handler as the http mode. That is the point of
767 // terminating here rather than proxying onwards: one request path, one set of
768 // guards, and https is a property of the socket rather than a second server.
769 let service = service_fn(move |req: Request<hyper::body::Incoming>| {
770 let me = Arc::clone(&me);
771 async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
772 });
773 let _ = http1::Builder::new()
774 .serve_connection(TokioIo::new(tls), service)
775 .await;
776 });
777
778 // 200 with no body is what the proxy protocol wants; the socket becomes the tunnel.
779 Response::builder()
780 .status(StatusCode::OK)
781 .body(Full::new(Bytes::new()))
782 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "building the tunnel"))
783 }
784}
785
786impl Origin {
787 /// Generic over the body type so a test can drive it without constructing
788 /// hyper's `Incoming`, which only a real connection can produce.
789 pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
790 where
791 B: hyper::body::Body,
792 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
793 {
794 let Some(host) = host_of(&req) else {
795 return fail(StatusCode::BAD_REQUEST, "request carries no Host");
796 };
797 let path = req.uri().path().to_string();
798 let cond = Conditions {
799 if_none_match: header(&req, IF_NONE_MATCH),
800 range: header(&req, RANGE),
801 if_range: header(&req, IF_RANGE),
802 control_token: req
803 .headers()
804 .get(control::TOKEN_HEADER)
805 .and_then(|v| v.to_str().ok())
806 .map(str::to_string),
807 fetch_site: req
808 .headers()
809 .get(control::FETCH_SITE_HEADER)
810 .and_then(|v| v.to_str().ok())
811 .map(str::to_string),
812 };
813 let method = req.method().clone();
814 let query = req.uri().query().map(str::to_string);
815
816 // The body is read for the control prefix and nowhere else. Reading it on every
817 // request would let any caller make the daemon hold memory it has no use for.
818 let control_body = if path.starts_with(control::PATH_PREFIX) {
819 match read_body(req.into_body()).await {
820 Ok(b) => b,
821 Err(e) => return fail(StatusCode::BAD_REQUEST, e),
822 }
823 } else {
824 Bytes::new()
825 };
826
827 match guard::classify(&host, &path, &self.suffix, self.port) {
828 // Refusing by Host is the DNS-rebinding defence, not a malfunction, so
829 // it says why rather than failing blankly.
830 Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
831 Ok(guard::Target::Direct { path }) => {
832 self.direct(&method, path, &cond, query.as_deref(), &control_body)
833 .await
834 }
835 Ok(guard::Target::Alias { alias, path }) => {
836 self.alias(&method, alias, path, &cond, query.as_deref())
837 .await
838 }
839 Ok(guard::Target::Index { path }) => self.index(&method, path).await,
840 }
841 }
842
843 /// The front door: `http(s)://<suffix>/`, listing what is being served.
844 ///
845 /// The same list the loopback listener shows at its root, on a real origin instead. That
846 /// difference is the point: from here a link to a site is a navigation to a *different*
847 /// origin, which is what those links mean. Reached through the loopback listener they are
848 /// links within one origin, because everything there shares one.
849 ///
850 /// Read-only and with no control API, like any alias origin. This is a page the browser can
851 /// be pointed at, so it is held to what a page may do.
852 async fn index(&self, method: &Method, path: &str) -> Response<Full<Bytes>> {
853 if !matches!(*method, Method::GET | Method::HEAD) {
854 return fail(
855 StatusCode::METHOD_NOT_ALLOWED,
856 format!("{method} is not allowed: this origin is read-only"),
857 );
858 }
859 // Only the root. There is nothing else here — every path belongs to a site, and a site
860 // is a different origin — so anything else is a 404 rather than a redirect that would
861 // guess which site was meant.
862 if path != "/" {
863 return fail(
864 StatusCode::NOT_FOUND,
865 format!(
866 "{path:?} is not here: this origin is the list of sites, and each site is its own origin"
867 ),
868 );
869 }
870 // Only here. The loopback listener is what a browser without the extension is told
871 // to use, so sending *it* into an extension page would be sending it somewhere it
872 // cannot go -- and that listener is the one place the fallback is the real answer.
873 let to = self.dashboard.read().await.clone();
874 own_page(self.alias_index(to.as_deref()).await)
875 }
876
877 async fn direct(
878 &self,
879 method: &Method,
880 path: &str,
881 cond: &Conditions,
882 query: Option<&str>,
883 body: &[u8],
884 ) -> Response<Full<Bytes>> {
885 // Reachable only from a loopback Host, which `guard::classify` has already
886 // separated from alias requests. An alias page cannot arrive here.
887 if path.starts_with(control::PATH_PREFIX) {
888 // First, and separately from the token: no page reaches this API at all,
889 // whatever it has got hold of.
890 if control::from_a_page(cond.fetch_site.as_deref()) {
891 return control::text(
892 StatusCode::FORBIDDEN,
893 "the control API is not reachable from a page",
894 );
895 }
896 // The handshake, and the only route that does not need the token -- it is
897 // where the token comes from. Handing it over is safe precisely because the
898 // line above has already established that nothing page-shaped is asking, and
899 // a caller that is not a browser at all could read the token file anyway.
900 //
901 // This is what removes the paste. An extension cannot read a file, so before
902 // this the first run meant copying sixty-four hex characters out of a terminal.
903 if method == Method::GET && control::route_of(path) == "token" {
904 return control::text(StatusCode::OK, self.token.as_str());
905 }
906 // Every other control route goes through the gate, and there is no way past
907 // it. The gate repeats the page check rather than trusting the branch above to
908 // have run, so that no future route can reach it having skipped one.
909 if let Some(refusal) = control::gate(
910 method,
911 cond.fetch_site.as_deref(),
912 cond.control_token.as_deref(),
913 &self.token,
914 ) {
915 return refusal;
916 }
917 return self.control(method, path, query, body).await;
918 }
919
920 if path == "/proxy.pac" {
921 return match pac::script(&self.suffix, self.port) {
922 Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
923 Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
924 };
925 }
926
927 let rest = path.trim_start_matches('/');
928 if rest.is_empty() {
929 return own_page(self.alias_index(None).await);
930 }
931
932 let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
933 self.alias(method, alias, &format!("/{sub}"), cond, query)
934 .await
935 }
936
937 async fn alias(
938 &self,
939 method: &Method,
940 alias: &str,
941 path: &str,
942 cond: &Conditions,
943 query: Option<&str>,
944 ) -> Response<Full<Bytes>> {
945 // The alias origin is read-only, and says so rather than quietly serving a POST
946 // as if it were a GET. The shape of this answer is part of the boundary: there
947 // is no write path on this origin and there will not be one. Writes go through
948 // the control API, which a page served from here cannot reach.
949 if !matches!(*method, Method::GET | Method::HEAD) {
950 return fail(
951 StatusCode::METHOD_NOT_ALLOWED,
952 format!("{method} is not allowed: this origin is read-only"),
953 );
954 }
955
956 // Twice at most, and the second time is not a policy but a consequence.
957 //
958 // A connection can be gone without this process knowing yet: the driver task learns
959 // of it when its own read fails, which is *after* a request has been handed down the
960 // pipe. So a request that arrives in that window is sent into a connection that looks
961 // alive, and comes back `sftp session closed before replying`. Checking liveness first
962 // cannot close that window -- nothing can, from this side.
963 //
964 // What closes it is checking afterwards. If the answer failed and the connection is
965 // now known to be gone, that was not the remote saying no, it was the pipe, and the
966 // request has not been answered at all. Measured against a real host by killing the
967 // ssh under a live daemon: without this a reload returned 502 and only the one after
968 // it succeeded, which is a retry that needs to be done twice to count as one.
969 for attempt in 0..2 {
970 let session = match self.session_for(alias).await {
971 Reached::Open(session) => session,
972 Reached::Unknown => {
973 return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
974 }
975 Reached::Down(why) => {
976 return fail(
977 StatusCode::BAD_GATEWAY,
978 format!(
979 "{alias} is not connected: {why}\n\n\
980 Reload to try again. Nothing needs restarting: the alias is still \
981 served, and the next request opens the ssh afresh. The line above is \
982 what the host said when asked just now."
983 ),
984 );
985 }
986 Reached::Stopped => {
987 return fail(
988 StatusCode::SERVICE_UNAVAILABLE,
989 format!(
990 "{alias} was stopped from the dashboard.\n\n\
991 It is in this daemon's configuration, so restarting serves it again. \
992 Reloading will not: a stop the next request undid would not be a stop."
993 ),
994 );
995 }
996 };
997
998 let res = self.serve_alias(&session, alias, path, cond, query).await;
999 if attempt == 0 && res.status().is_server_error() && !session.fs.is_alive() {
1000 self.evict(alias, &session).await;
1001 continue;
1002 }
1003 return res;
1004 }
1005 unreachable!("the loop returns on its last pass")
1006 }
1007
1008 /// Drop a session, but only the one the caller was holding.
1009 ///
1010 /// Compared by pointer rather than by name: another request may have re-dialled while
1011 /// this one was failing, and removing that fresh session by name would start the same
1012 /// dance over for whoever holds it.
1013 async fn evict(&self, alias: &str, held: &Arc<Session>) {
1014 let mut sessions = self.sessions.write().await;
1015 if sessions.get(alias).is_some_and(|s| Arc::ptr_eq(s, held)) {
1016 sessions.remove(alias);
1017 }
1018 }
1019
1020 /// One attempt at serving, over a connection the caller has already obtained.
1021 async fn serve_alias(
1022 &self,
1023 session: &Session,
1024 alias: &str,
1025 path: &str,
1026 cond: &Conditions,
1027 query: Option<&str>,
1028 ) -> Response<Full<Bytes>> {
1029 let resolved = match guard::resolve(&session.base, path) {
1030 Ok(p) => p,
1031 Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
1032 };
1033
1034 let wants_dir = path.ends_with('/');
1035 let file = if wants_dir {
1036 format!("{resolved}/index.html")
1037 } else {
1038 resolved.clone()
1039 };
1040
1041 // Every component between the alias base and the file, base first. The base
1042 // itself is not checked: it is what the operator configured, and no request
1043 // can change it.
1044 let chain = components(&session.base, &file);
1045 if chain.is_empty() {
1046 return self
1047 .autoindex_of(session, alias, path, &resolved, query)
1048 .await;
1049 }
1050 let last = chain.len() - 1;
1051
1052 // Settled before anything is asked of the remote, because unlike a symlink this needs
1053 // nothing from the remote to decide — and deciding it later would mean asking the
1054 // remote to open `.ssh` in order to then refuse it.
1055 if let Some((_, name)) = chain.iter().find(|(_, n)| hidden(n)) {
1056 return fail(
1057 StatusCode::FORBIDDEN,
1058 format!("refusing {name}: names beginning with a dot are not served"),
1059 );
1060 }
1061
1062 let held = match self.listings_along(session, &chain).await {
1063 Ok(held) => held,
1064 // Whatever ssh said, rather than this daemon's word for not knowing.
1065 Err((at, why)) => {
1066 return fail(
1067 StatusCode::BAD_GATEWAY,
1068 format!("{path}: listing {at} failed: {why}"),
1069 );
1070 }
1071 };
1072
1073 // Symlinks are settled before anything else, so the answer cannot depend on
1074 // whether the target happens to exist: a symlink is refused either way, and
1075 // checking it separately is what lets the write path share exactly this rule.
1076 if let Some(at) = first_symlink(&held, &chain) {
1077 return fail(
1078 StatusCode::FORBIDDEN,
1079 format!("refusing symlink at {at} (its target is not checked)"),
1080 );
1081 }
1082
1083 let mut found_last = None;
1084 for (i, (dir, name)) in chain.iter().enumerate() {
1085 let Some(attrs) = attrs_in(&held, dir, name) else {
1086 // Absent. For a directory request that only means there is no
1087 // index.html, so fall through to a listing of the directory itself.
1088 if i == last && wants_dir {
1089 return self
1090 .autoindex_of(session, alias, path, &resolved, query)
1091 .await;
1092 }
1093 return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
1094 };
1095
1096 if i < last && !attrs.is_dir() {
1097 return fail(
1098 StatusCode::NOT_FOUND,
1099 format!("{path}: {dir}/{name} is not a directory"),
1100 );
1101 }
1102 if i == last {
1103 found_last = Some(attrs);
1104 }
1105 }
1106 let attrs = found_last.expect("the walk assigns on its final iteration");
1107
1108 if attrs.is_dir() {
1109 if wants_dir {
1110 // `<dir>/index.html` is itself a directory. Fall back to a listing.
1111 return self
1112 .autoindex_of(session, alias, path, &resolved, query)
1113 .await;
1114 }
1115 // Without the trailing slash every relative link on the page below
1116 // would resolve one level too high.
1117 return redirect(&format!("{path}/"));
1118 }
1119
1120 let tag = cache::etag(&attrs);
1121
1122 // The conditional GET never leaves this process: the validator came from the
1123 // cached listing, so a browser already holding the current copy is answered
1124 // with zero remote round trips. That is invariant 2.
1125 if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref())
1126 && cache::etag_matches(header, tag)
1127 {
1128 return not_modified(tag);
1129 }
1130
1131 // Size comes from the listing, which is what makes a range answerable without
1132 // first fetching the file to discover how long it is.
1133 let size = attrs.size.unwrap_or(0);
1134 let wanted = match cond.range.as_deref() {
1135 Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
1136 None => range::Resolved::Whole,
1137 };
1138 if wanted == range::Resolved::Unsatisfiable {
1139 return unsatisfiable(size);
1140 }
1141
1142 // A body already held answers a range by slicing, with no round trip at all.
1143 if let Some(body) = self.cache.body(&file, &attrs) {
1144 return respond(&file, body, tag.as_deref(), &wanted, size);
1145 }
1146
1147 // Too large to hold: fetch only what was asked for. This branch is what makes
1148 // seeking in a video possible. Without it a seek pulls the whole file, and
1149 // holding that file would evict every page body that makes a revisit free.
1150 if let range::Resolved::Part { start, end } = wanted
1151 && size > CACHE_WHOLE_MAX
1152 {
1153 let req = RangeReq {
1154 path: file.clone(),
1155 offset: start,
1156 len: end - start + 1,
1157 };
1158 let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
1159 return match got.pop() {
1160 Some(Ok(body)) => partial(
1161 mime::guess(&file),
1162 Bytes::from(body),
1163 tag.as_deref(),
1164 start,
1165 end,
1166 size,
1167 ),
1168 Some(Err(e)) => {
1169 self.cache.forget_listing(&chain[last].0);
1170 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
1171 }
1172 None => fail(
1173 StatusCode::INTERNAL_SERVER_ERROR,
1174 "read_ranges returned no result",
1175 ),
1176 };
1177 }
1178
1179 // The length the listing already gave is what turns this from a poll into one round
1180 // trip. `read_batch` cannot know how long a file is, so it asks for 32 KiB at a time
1181 // until a short read tells it to stop: one round trip per chunk index. `read_ranges`
1182 // is handed the length and issues every chunk before awaiting any, so the file costs
1183 // one however long it is. The prefetcher has always done this; the request a reader
1184 // actually waits on did not.
1185 //
1186 // It is the direct path that needs it most, because the files that reach it are the
1187 // ones the prefetcher could not have warmed: the page itself, which has to be read
1188 // before it can be scanned, and anything a script fetches at runtime. Measured
1189 // against real Documenter output — a 700 KB `index.html` and a 2 MB
1190 // `search_index.js`, neither of them visible to an HTML scan — the page cost 95
1191 // remote round trips before this and 26 after. What remains is listings, which
1192 // expire before a slow page has finished loading; that is a separate problem.
1193 let mut got = match size {
1194 0 => session.fs.read_batch(std::slice::from_ref(&file)).await,
1195 size => {
1196 let req = RangeReq {
1197 path: file.clone(),
1198 offset: 0,
1199 len: size,
1200 };
1201 let mut ranged = session.fs.read_ranges(std::slice::from_ref(&req)).await;
1202 match ranged.pop() {
1203 Some(Ok(body)) if body.len() as u64 == size => vec![Ok(body)],
1204 // Anything else means the listing no longer describes the file, or the
1205 // read failed. Falling back to the poll rather than answering with what
1206 // arrived: a body shorter than the length it is served with is precisely
1207 // the silent truncation this daemon must not produce, and the poll finds
1208 // the real length or the real error. It costs a round trip in a case that
1209 // is a race, and nothing in the case that is not.
1210 _ => session.fs.read_batch(std::slice::from_ref(&file)).await,
1211 }
1212 }
1213 };
1214 match got.pop() {
1215 Some(Ok(body)) => {
1216 let body = Bytes::from(body);
1217 self.cache.put_body(&file, &attrs, body.clone());
1218 // Before answering, not after. The browser will ask for this page's
1219 // subresources six at a time, and each wave it has to discover is a round
1220 // trip; fetching them here costs one and makes the waves cache hits. Waiting
1221 // also makes the invariant a guarantee rather than a race with the browser.
1222 if mime::guess(&file).starts_with("text/html") {
1223 self.warm_subresources(session, path, &body).await;
1224 }
1225 respond(&file, body, tag.as_deref(), &wanted, size)
1226 }
1227 // The listing promised this file and the remote refused it, so the listing
1228 // is wrong. Holding it for the rest of its TTL would repeat the same wrong
1229 // answer.
1230 Some(Err(e)) => {
1231 self.cache.forget_listing(&chain[last].0);
1232 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
1233 }
1234 None => fail(
1235 StatusCode::INTERNAL_SERVER_ERROR,
1236 "read_batch returned no result",
1237 ),
1238 }
1239 }
1240
1241 async fn control(
1242 &self,
1243 method: &Method,
1244 path: &str,
1245 query: Option<&str>,
1246 body: &[u8],
1247 ) -> Response<Full<Bytes>> {
1248 match (method, control::route_of(path)) {
1249 (&Method::GET, "hello") => {
1250 // Where the dashboard is, which the root of the suffix needs and cannot
1251 // discover: a page cannot enumerate extensions, so the extension says.
1252 if let Some(id) = extension_id(query) {
1253 *self.dashboard.write().await = Some(id);
1254 }
1255 let aliases = self.alias_names().await;
1256 control::hello(
1257 &aliases,
1258 &self.suffix,
1259 &self.scheme,
1260 self.round_trips().await,
1261 self.tls.is_some().then(|| control::Handshakes {
1262 completed: self.handshakes.completed.load(Ordering::Relaxed),
1263 failed: self.handshakes.failed.load(Ordering::Relaxed),
1264 }),
1265 )
1266 }
1267 (&Method::GET, "hosts") => self.list_hosts().await,
1268 (&Method::POST, "open") => self.open_host(body).await,
1269 (&Method::POST, "close") => self.close_alias(body).await,
1270 (&Method::POST, "enabled") => self.set_enabled(body).await,
1271 (&Method::GET, "certificate") => self.show_certificate(query),
1272 (&Method::GET, "theme") => self.show_theme().await,
1273 (&Method::POST, "theme") => self.set_theme(body).await,
1274 (&Method::GET, route) => {
1275 control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
1276 }
1277 (_, route) => control::text(
1278 StatusCode::METHOD_NOT_ALLOWED,
1279 format!("{method} is not allowed on {route:?}"),
1280 ),
1281 }
1282 }
1283
1284 /// `GET /_control/hosts`
1285 ///
1286 /// What ssh already knows how to reach, which is the list the extension offers. It
1287 /// comes from `~/.ssh/config` rather than from this daemon's own configuration,
1288 /// because a host you can already `ssh` to is a host you should be able to open
1289 /// without writing it down a second time.
1290 ///
1291 /// Answering this connects to nothing. It is a list of what could be opened, and a
1292 /// daemon that opened six ssh sessions to answer a popup would make looking at the
1293 /// list cost more than using it.
1294 async fn list_hosts(&self) -> Response<Full<Bytes>> {
1295 let found = match ssh_config::read() {
1296 Ok(found) => found,
1297 Err(e) => {
1298 return control::text(
1299 StatusCode::INTERNAL_SERVER_ERROR,
1300 format!("reading ssh_config: {e:#}"),
1301 );
1302 }
1303 };
1304
1305 // Every `ssh -G` at once. One subprocess per host is cheap, but run in sequence
1306 // the list would take the sum of them, and this is the request a reader waits on
1307 // before they can do anything at all.
1308 let described: Vec<_> = found
1309 .hosts
1310 .iter()
1311 .map(|h| {
1312 let host = h.host.clone();
1313 tokio::spawn(async move { ssh_config::describe(&host).await })
1314 })
1315 .collect();
1316
1317 let open = {
1318 let sessions = self.sessions.read().await;
1319 let mut open: Vec<OpenAlias> = sessions
1320 .iter()
1321 .map(|(alias, s)| OpenAlias {
1322 alias: alias.clone(),
1323 host: s.host.clone(),
1324 base: s.base.clone(),
1325 url: self.site_url(alias),
1326 trips: s.fs.round_trips(),
1327 })
1328 .collect();
1329 open.sort_by(|a, b| a.alias.cmp(&b.alias));
1330 open
1331 };
1332 // Read once, before the loop, rather than taking the lock per host.
1333 let enabled: Vec<String> = self
1334 .reachable
1335 .read()
1336 .await
1337 .enabled()
1338 .map(|h| h.name.clone())
1339 .collect();
1340 let mut hosts = Vec::with_capacity(found.hosts.len());
1341 for (h, task) in found.hosts.iter().zip(described) {
1342 // A host ssh cannot describe is still listed, with the reason attached.
1343 // Dropping it would make a misconfigured host look like one that is not in
1344 // the file, and those have different fixes.
1345 let (settings, unresolved) = match task.await {
1346 Ok(Ok(settings)) => (settings, None),
1347 Ok(Err(e)) => (ssh_config::Settings::default(), Some(format!("{e:#}"))),
1348 Err(e) => (ssh_config::Settings::default(), Some(e.to_string())),
1349 };
1350 hosts.push(KnownHost {
1351 alias: h.alias.clone(),
1352 host: h.host.clone(),
1353 settings,
1354 served: open.iter().any(|o| o.alias == h.alias),
1355 enabled: enabled.iter().any(|name| name == &h.alias),
1356 unresolved,
1357 });
1358 }
1359 // Every declared alias that is not in `open`. Read from `declared` rather than from
1360 // the difference of two lists somewhere else, so an alias cannot be absent from both.
1361 let (trouble, stopped) = (self.trouble.read().await, self.stopped.read().await);
1362 let mut stalled: Vec<StalledAlias> = self
1363 .declared
1364 .iter()
1365 .filter(|(name, _)| !open.iter().any(|o| &&o.alias == name))
1366 .map(|(name, d)| StalledAlias {
1367 alias: name.clone(),
1368 host: d.host.clone(),
1369 url: self.site_url(name),
1370 why: trouble.get(name).map(|t| t.why.clone()),
1371 stopped: stopped.contains(name),
1372 })
1373 .collect();
1374 stalled.sort_by(|a, b| a.alias.cmp(&b.alias));
1375 drop((trouble, stopped));
1376
1377 control::json(&KnownHosts {
1378 open,
1379 stalled,
1380 hosts,
1381 unusable: found.unusable,
1382 tls: self.tls.is_some().then(|| control::Handshakes {
1383 completed: self.handshakes.completed.load(Ordering::Relaxed),
1384 failed: self.handshakes.failed.load(Ordering::Relaxed),
1385 }),
1386 })
1387 }
1388
1389 /// `POST /_control/open` -- start serving one of the hosts ssh already knows.
1390 ///
1391 /// This is what replaces configuring an alias before you can look at anything. The
1392 /// host is picked from the list, the daemon connects, and the URL comes back.
1393 ///
1394 /// **Only a host named in ssh_config can be opened.** Not because the token is
1395 /// insufficient, but because "ssh to an arbitrary host on request" is a larger
1396 /// primitive than this needs to be, and the list the extension offers is already the
1397 /// menu. A host that is not on it is a config change, which is a deliberate act.
1398 async fn open_host(&self, body: &[u8]) -> Response<Full<Bytes>> {
1399 #[derive(serde::Deserialize)]
1400 #[serde(deny_unknown_fields)]
1401 struct Ask {
1402 host: String,
1403 /// Absolute, or `~`, or `~/path`. Absent means the home directory.
1404 #[serde(default)]
1405 base: Option<String>,
1406 }
1407
1408 let ask: Ask = match serde_json::from_slice(body) {
1409 Ok(ask) => ask,
1410 Err(e) => {
1411 return control::text(
1412 StatusCode::BAD_REQUEST,
1413 format!("open needs a JSON body naming a host: {e}"),
1414 );
1415 }
1416 };
1417
1418 let found = match ssh_config::read() {
1419 Ok(found) => found,
1420 Err(e) => {
1421 return control::text(
1422 StatusCode::INTERNAL_SERVER_ERROR,
1423 format!("reading ssh_config: {e:#}"),
1424 );
1425 }
1426 };
1427 // Matched against ssh_config rather than trusted, and matched case-insensitively
1428 // because that is how hostnames compare: the extension shows `panza` and the file
1429 // says `Panza`.
1430 let Some(known) = found
1431 .hosts
1432 .iter()
1433 .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
1434 else {
1435 return control::text(
1436 StatusCode::NOT_FOUND,
1437 format!("{:?} is not a host in your ssh_config", ask.host),
1438 );
1439 };
1440
1441 let alias = match Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
1442 Ok(alias) => alias,
1443 Err(e) => return control::text(StatusCode::BAD_REQUEST, format!("{e:#}")),
1444 };
1445
1446 // Already open is an answer, not an error: two tabs asking at once should both
1447 // get the URL. A *different* base is refused, though. Reconnecting under one
1448 // would change what an origin means underneath any page already open in it,
1449 // which is the one thing an origin must not do.
1450 if let Some(open) = self.session(&known.alias).await {
1451 // Asking for no base is asking for no particular one, so an alias already
1452 // open is simply the answer. The popup relies on this: it opens a host by
1453 // naming it, and a host the config file already roots somewhere would
1454 // otherwise answer a plain click with a conflict about a base nobody asked for.
1455 let Some(asked) = alias.base() else {
1456 return self.opened(&known.alias, &known.host, &open.base);
1457 };
1458 // Resolved against the session that is already there, rather than compared as
1459 // written. `~/work` and `/home/souta/work` are the same base, and a check that
1460 // could not tell would either refuse an identical request or -- worse -- accept
1461 // a different one, handing back a URL rooted somewhere the caller did not ask
1462 // for. The round trip is paid on a path that is not a page load.
1463 let wanted = match resolve_base(Some(asked), &open.fs).await {
1464 Ok(base) => base,
1465 Err(e) => {
1466 return control::text(
1467 StatusCode::BAD_GATEWAY,
1468 format!("working out where to root {}: {e:#}", known.alias),
1469 );
1470 }
1471 };
1472 if wanted != open.base {
1473 return control::text(
1474 StatusCode::CONFLICT,
1475 format!(
1476 "{} is already open at {}, and {} is not the same place; a second base would change what that origin means underneath any page open in it",
1477 known.alias, open.base, wanted
1478 ),
1479 );
1480 }
1481 return self.opened(&known.alias, &known.host, &open.base);
1482 }
1483
1484 let fs = match SftpFs::connect(&known.host).await {
1485 Ok(fs) => fs,
1486 Err(e) => {
1487 // The reason is passed through rather than flattened to "could not
1488 // connect". It is ssh's, and ssh's reasons are the ones with a fix in
1489 // them: a jump host that is down, a key that is not loaded, a name that
1490 // does not resolve.
1491 return control::text(
1492 StatusCode::BAD_GATEWAY,
1493 format!("ssh to {}: {e:#}", known.host),
1494 );
1495 }
1496 };
1497 let base = match resolve_base(alias.base(), &fs).await {
1498 Ok(base) => base,
1499 Err(e) => {
1500 return control::text(
1501 StatusCode::BAD_GATEWAY,
1502 format!("working out where to root {}: {e:#}", known.alias),
1503 );
1504 }
1505 };
1506
1507 // Inserted under the write lock, and a session that lost the race is dropped
1508 // rather than replacing the winner. Dropping it closes that ssh child, which is
1509 // the right end for a connection nothing is using; replacing the winner would
1510 // close one that requests are already going through.
1511 let session = {
1512 let mut sessions = self.sessions.write().await;
1513 Arc::clone(sessions.entry(known.alias.clone()).or_insert_with(|| {
1514 Arc::new(Session {
1515 host: known.host.clone(),
1516 base,
1517 fs,
1518 })
1519 }))
1520 };
1521 self.opened(&known.alias, &known.host, &session.base)
1522 }
1523
1524 /// `POST /_control/close` -- stop serving an alias.
1525 ///
1526 /// The other half of `open`, and what makes changing where an alias is rooted possible
1527 /// at all: reopening under a second base is refused while the first is live, because
1528 /// it would change what an origin means underneath any page open in it. Closing first
1529 /// makes that an act somebody chose rather than something that happened to them.
1530 ///
1531 /// Also the only way to give back an ssh connection without stopping the daemon.
1532 async fn close_alias(&self, body: &[u8]) -> Response<Full<Bytes>> {
1533 #[derive(serde::Deserialize)]
1534 #[serde(deny_unknown_fields)]
1535 struct Ask {
1536 alias: String,
1537 }
1538
1539 let ask: Ask = match serde_json::from_slice(body) {
1540 Ok(ask) => ask,
1541 Err(e) => {
1542 return control::text(
1543 StatusCode::BAD_REQUEST,
1544 format!("close needs a JSON body naming an alias: {e}"),
1545 );
1546 }
1547 };
1548
1549 // Removed under the write lock, so two callers cannot both believe they closed it.
1550 // Dropping the `Arc` is what ends the ssh session, and a request already in flight
1551 // holds one — so the connection goes when the last reader is done with it rather
1552 // than out from under them.
1553 // Marked before the session goes, so no request can slip between the two and re-open
1554 // what is being closed. Only for declared aliases: a host opened from the dashboard is
1555 // not in `declared`, so removing its session is already the whole of stopping it.
1556 if self.declared.contains_key(&ask.alias) {
1557 self.stopped.write().await.insert(ask.alias.clone());
1558 }
1559 let gone = self.sessions.write().await.remove(&ask.alias);
1560 match gone {
1561 Some(session) => {
1562 #[derive(serde::Serialize)]
1563 struct Closed<'a> {
1564 alias: &'a str,
1565 host: &'a str,
1566 base: &'a str,
1567 }
1568 control::json(&Closed {
1569 alias: &ask.alias,
1570 host: &session.host,
1571 base: &session.base,
1572 })
1573 }
1574 // Distinguished from success on purpose. "Closed something" and "there was
1575 // nothing to close" look identical to a caller that is told neither, and the
1576 // second usually means the alias was spelled wrong.
1577 None => control::text(
1578 StatusCode::NOT_FOUND,
1579 format!("no alias named {:?} is open", ask.alias),
1580 ),
1581 }
1582 }
1583
1584 /// Connect one host and work out where it is rooted.
1585 ///
1586 /// Owns its arguments and borrows nothing, so it can run in a task and several of them can
1587 /// run at once. Does not touch the session map: dialling and adopting are separated so that
1588 /// the concurrent path at startup and the one-at-a-time path behind a toggle share the part
1589 /// that talks to ssh, rather than each having a copy of it to drift.
1590 async fn dial(alias: String, host: String, base: Option<String>) -> Result<Session> {
1591 let fs = SftpFs::connect(&host)
1592 .await
1593 .with_context(|| format!("ssh to {host}"))?;
1594 let resolved = resolve_base(base.as_deref(), &fs)
1595 .await
1596 .with_context(|| format!("working out where to root {alias}"))?;
1597 Ok(Session {
1598 host,
1599 base: resolved,
1600 fs,
1601 })
1602 }
1603
1604 /// Put a connected session in the map, or keep the one that got there first.
1605 ///
1606 /// A session that lost the race is dropped rather than replacing the winner. Dropping it
1607 /// closes that ssh child, which is the right end for a connection nothing is using;
1608 /// replacing the winner would close one that requests are already going through.
1609 ///
1610 /// Unless the winner is dead, in which case it is replaced. Otherwise a reconnection could
1611 /// never land: `live` evicts the corpse, this would put the new session behind it, and the
1612 /// alias would be permanently unreachable by a daemon that was reconnecting correctly.
1613 async fn adopt(&self, alias: &str, session: Session) -> Arc<Session> {
1614 let mut sessions = self.sessions.write().await;
1615 match sessions.get(alias) {
1616 Some(held) if held.fs.is_alive() => Arc::clone(held),
1617 _ => {
1618 let session = Arc::new(session);
1619 sessions.insert(alias.to_string(), Arc::clone(&session));
1620 session
1621 }
1622 }
1623 }
1624
1625 async fn connect(&self, alias: &str, host: &str, base: Option<&str>) -> Result<Arc<Session>> {
1626 let session = Self::dial(
1627 alias.to_string(),
1628 host.to_string(),
1629 base.map(str::to_string),
1630 )
1631 .await?;
1632 Ok(self.adopt(alias, session).await)
1633 }
1634
1635 /// Open every declared alias, at once, and say which ones would not.
1636 ///
1637 /// Returns the routes that came up and a line per one that did not. Only a name typed for
1638 /// this run is an error: the reader is standing there waiting on it, so starting without it
1639 /// would answer a different question than the one asked. A name in a config file is
1640 /// reported and left declared, which is what makes it retryable.
1641 async fn open_declared(&self) -> Result<(Vec<String>, Vec<String>)> {
1642 let mut dialling = tokio::task::JoinSet::new();
1643 for (name, d) in &self.declared {
1644 let (name, host, base) = (name.clone(), d.host.clone(), d.base.clone());
1645 dialling.spawn(async move {
1646 let got = Self::dial(name.clone(), host, base).await;
1647 (name, got)
1648 });
1649 }
1650
1651 let (mut routes, mut refused) = (Vec::new(), Vec::new());
1652 while let Some(finished) = dialling.join_next().await {
1653 let (name, got) = finished.context("dialling an alias")?;
1654 let d = &self.declared[&name];
1655 match got {
1656 Ok(session) => {
1657 // Built from the resolved base, so what is announced is where requests
1658 // will actually go. Formatting it from the alias beforehand would print
1659 // the word "home" and leave the reader to find out which directory that
1660 // was.
1661 routes.push(format!(
1662 " {}://{name}.{}/ -> {}:{}",
1663 self.scheme, self.suffix, session.host, session.base
1664 ));
1665 self.adopt(&name, session).await;
1666 }
1667 Err(e) => {
1668 if d.named == Named::ForThisRun {
1669 return Err(e).with_context(|| {
1670 format!("alias {name} -> ssh host {}, named for this run", d.host)
1671 });
1672 }
1673 // Said now and remembered, because the reader will meet it again when they
1674 // open the site and the two should agree.
1675 let why = format!("{e:#}");
1676 refused.push(format!(" {name} could not be opened: {why}"));
1677 self.trouble.write().await.insert(
1678 name,
1679 Trouble {
1680 at: Instant::now(),
1681 why,
1682 },
1683 );
1684 }
1685 }
1686 }
1687 routes.sort();
1688 Ok((routes, refused))
1689 }
1690}
1691
1692/// What asking for an alias's connection found.
1693///
1694/// Four answers because there are four situations, and an `Option` can carry two. Collapsing
1695/// them is how a host that was asleep became indistinguishable from a name nobody ever wrote
1696/// down -- which is the bug this whole path exists to end, so the shape says it.
1697enum Reached {
1698 Open(Arc<Session>),
1699 /// Declared, and the ssh would not come up. Carries what ssh said.
1700 Down(String),
1701 /// Declared, and stopped from the dashboard for this run.
1702 Stopped,
1703 /// Not an alias this daemon serves.
1704 Unknown,
1705}
1706
1707impl Origin {
1708 /// The connection for an alias, opening one if there is not a live one already.
1709 ///
1710 /// This is the retry. There is no timer and no background loop: a reader who reloads has
1711 /// asked for exactly one more attempt, which is the right number, and a host that is down
1712 /// does not get dialled every thirty seconds by a daemon nobody is using.
1713 ///
1714 /// Three outcomes, and they are different answers. A live session serves. A declared alias
1715 /// with no live session is dialled here and either serves or says why. A name that was
1716 /// never declared is not an alias at all.
1717 async fn session_for(&self, alias: &str) -> Reached {
1718 if let Some(live) = self.live(alias).await {
1719 return Reached::Open(live);
1720 }
1721 let Some(d) = self.declared.get(alias) else {
1722 return Reached::Unknown;
1723 };
1724 // Asked before dialling, so stopping something does not cost an ssh handshake to
1725 // discover.
1726 if self.stopped.read().await.contains(alias) {
1727 return Reached::Stopped;
1728 }
1729
1730 // One dial per alias at a time, and the check again inside it. A first visit is one
1731 // navigation and then every subresource on the page, all arriving before the first ssh
1732 // has finished; without this each of them starts its own.
1733 let _dialling = d.dialling.lock().await;
1734 if let Some(live) = self.live(alias).await {
1735 return Reached::Open(live);
1736 }
1737
1738 // A failure is remembered for a few seconds, and inside that window this answers from
1739 // the memory rather than dialling again.
1740 //
1741 // Because every request to an alias origin arrives through the proxy, and a page can
1742 // make them: `<img src="http://docs.ssh-browser/x">` in a loop is a remote document
1743 // deciding how often this machine opens an ssh. It cannot name a host that was not
1744 // declared -- `declared` is fixed at startup and is not `ssh_config` -- so the worst it
1745 // reaches is a host the reader already asked to have served. But "already yours" is not
1746 // "free", and one attempt per request is a rate somebody else chooses.
1747 //
1748 // Short enough that reloading is still a retry, which is the whole point of the retry
1749 // being a reload: nobody reads a failure and reloads inside three seconds.
1750 if let Some(t) = self.trouble.read().await.get(alias)
1751 && t.at.elapsed() < self.cooldown
1752 {
1753 return Reached::Down(t.why.clone());
1754 }
1755
1756 match Self::dial(alias.to_string(), d.host.clone(), d.base.clone()).await {
1757 Ok(session) => {
1758 self.trouble.write().await.remove(alias);
1759 Reached::Open(self.adopt(alias, session).await)
1760 }
1761 Err(e) => {
1762 let why = format!("{e:#}");
1763 self.trouble.write().await.insert(
1764 alias.to_string(),
1765 Trouble {
1766 at: Instant::now(),
1767 why: why.clone(),
1768 },
1769 );
1770 Reached::Down(why)
1771 }
1772 }
1773 }
1774
1775 /// The session for an alias, if there is one and it is still connected.
1776 ///
1777 /// A dead one is dropped here rather than returned. Holding it would mean answering every
1778 /// request with `sftp session is gone` until somebody restarted the daemon, which is the
1779 /// one failure mode a reconnecting daemon must not have.
1780 async fn live(&self, alias: &str) -> Option<Arc<Session>> {
1781 if self.sessions.read().await.get(alias)?.fs.is_alive() {
1782 return self.sessions.read().await.get(alias).cloned();
1783 }
1784 let mut sessions = self.sessions.write().await;
1785 // Checked again under the write lock: another request may have replaced it since, and
1786 // evicting the replacement would start this over.
1787 if sessions.get(alias).is_some_and(|s| !s.fs.is_alive()) {
1788 sessions.remove(alias);
1789 }
1790 sessions.get(alias).cloned()
1791 }
1792
1793 /// Open every enabled host, at once, and say which ones would not.
1794 ///
1795 /// At once rather than in turn: these are independent ssh handshakes, and in sequence six
1796 /// hosts would cost the sum of six round-trip times before the daemon answered anything.
1797 async fn open_enabled(&self) -> (Vec<String>, Vec<String>) {
1798 let wanted: Vec<reachable::Host> = self.reachable.read().await.enabled().cloned().collect();
1799 if wanted.is_empty() {
1800 return (Vec::new(), Vec::new());
1801 }
1802
1803 // Looked up in ssh_config rather than dialled by the name in the file, for two reasons
1804 // and the second is the important one.
1805 //
1806 // The name in the file is the *label* — lowercase, because it becomes a hostname — and
1807 // the ssh_config `Host` it came from need not be spelled the same. A file saying
1808 // `panza` for a config that says `Panza` produced `Could not resolve hostname panza` on
1809 // every start, while enabling it in the first place had worked: that path had the
1810 // ssh_config entry in hand and this one only had the label.
1811 //
1812 // And it is the same gate `open` and `enabled` have. Without it this is a path that
1813 // ssh's to whatever names are in a file, with no check that ssh has ever heard of them
1814 // — a second, looser door into the one thing this daemon is careful about.
1815 let known = match ssh_config::read() {
1816 Ok(found) => found.hosts,
1817 Err(e) => {
1818 let mut refused: Vec<String> = wanted
1819 .iter()
1820 .map(|h| {
1821 format!(
1822 " {} is enabled but ssh_config could not be read: {e:#}",
1823 h.name
1824 )
1825 })
1826 .collect();
1827 refused.sort();
1828 return (Vec::new(), refused);
1829 }
1830 };
1831
1832 let mut dialling = tokio::task::JoinSet::new();
1833 let mut refused = Vec::new();
1834 for host in wanted {
1835 let Some(entry) = entry_for(&known, &host.name) else {
1836 // Named and gone: the ssh_config entry was renamed or removed since this was
1837 // turned on. Said rather than retried silently, because the fix is in a file
1838 // the reader owns.
1839 refused.push(format!(
1840 " {} is enabled but is no longer a host in your ssh_config",
1841 host.name
1842 ));
1843 continue;
1844 };
1845 let (label, target) = (entry.alias.clone(), entry.host.clone());
1846 dialling.spawn(async move {
1847 let got = Self::dial(label.clone(), target, host.base.clone()).await;
1848 (label, got)
1849 });
1850 }
1851
1852 let mut opened = Vec::new();
1853 while let Some(finished) = dialling.join_next().await {
1854 let (name, got) = match finished {
1855 Ok(pair) => pair,
1856 // The task itself failed rather than the ssh in it -- a panic. Reported the same
1857 // way, because from here it is the same fact: this host is not being served and
1858 // the reader has to be told which one.
1859 Err(e) => {
1860 refused.push(format!(" an enabled host could not be opened: {e}"));
1861 continue;
1862 }
1863 };
1864 match got {
1865 Ok(session) => {
1866 let base = session.base.clone();
1867 self.adopt(&name, session).await;
1868 opened.push(format!(
1869 " {}://{name}.{}/ -> {name}:{base}",
1870 self.scheme, self.suffix
1871 ));
1872 }
1873 // ssh's own words, not "could not connect". ssh's reasons are the ones with a
1874 // fix in them: a jump host that is down, a key that is not loaded, a name that
1875 // does not resolve.
1876 Err(e) => refused.push(format!(" {name} is enabled but did not answer: {e:#}")),
1877 }
1878 }
1879 opened.sort();
1880 refused.sort();
1881 (opened, refused)
1882 }
1883
1884 fn opened(&self, alias: &str, host: &str, base: &str) -> Response<Full<Bytes>> {
1885 #[derive(serde::Serialize)]
1886 struct Opened<'a> {
1887 alias: &'a str,
1888 host: &'a str,
1889 base: &'a str,
1890 url: String,
1891 }
1892 control::json(&Opened {
1893 alias,
1894 host,
1895 base,
1896 url: self.site_url(alias),
1897 })
1898 }
1899
1900 /// `POST /_control/enabled` -- open a host every run, or stop.
1901 ///
1902 /// The difference from `open` is that this is remembered. `open` serves a host until the
1903 /// daemon stops; this says to open it next time too, which is what turns "click the host,
1904 /// then use the URL" into "use the URL".
1905 ///
1906 /// Turning one on connects it now as well, because a setting that only took effect after a
1907 /// restart would be indistinguishable from one that did not work. Turning one off closes it
1908 /// now, for the same reason in reverse: a host still answering after you switched it off
1909 /// reads as the switch having failed.
1910 async fn set_enabled(&self, body: &[u8]) -> Response<Full<Bytes>> {
1911 #[derive(serde::Deserialize)]
1912 #[serde(deny_unknown_fields)]
1913 struct Ask {
1914 host: String,
1915 enabled: bool,
1916 /// Where to root it, for the first time it is turned on.
1917 #[serde(default)]
1918 base: Option<String>,
1919 }
1920
1921 let ask: Ask = match serde_json::from_slice(body) {
1922 Ok(ask) => ask,
1923 Err(e) => {
1924 return control::text(
1925 StatusCode::BAD_REQUEST,
1926 format!("enabled needs a JSON body naming a host and whether it is on: {e}"),
1927 );
1928 }
1929 };
1930
1931 // The same gate `open` has, and for the same reason: "ssh to an arbitrary host on
1932 // request" is a larger primitive than this needs to be, and the ssh_config list is
1933 // already the menu. Checked before anything is remembered, so a typo does not leave a
1934 // name in the file that will be retried at every start forever.
1935 let found = match ssh_config::read() {
1936 Ok(found) => found,
1937 Err(e) => {
1938 return control::text(
1939 StatusCode::INTERNAL_SERVER_ERROR,
1940 format!("reading ssh_config: {e:#}"),
1941 );
1942 }
1943 };
1944 let Some(known) = found
1945 .hosts
1946 .iter()
1947 .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
1948 else {
1949 return control::text(
1950 StatusCode::NOT_FOUND,
1951 format!("{:?} is not a host in your ssh_config", ask.host),
1952 );
1953 };
1954
1955 // Held to the same rules an alias is, before it is written down anywhere. A name that
1956 // cannot be a hostname label would be remembered and then refused on every request.
1957 if let Err(e) = Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
1958 return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
1959 }
1960
1961 if ask.enabled {
1962 // Connected before it is remembered. A host that cannot be reached is not written
1963 // into the file, so the answer is the same failure `open` would give rather than a
1964 // silent "saved" followed by a URL that does not work.
1965 if self.session(&known.alias).await.is_none()
1966 && let Err(e) = self
1967 .connect(&known.alias, &known.host, ask.base.as_deref())
1968 .await
1969 {
1970 return control::text(StatusCode::BAD_GATEWAY, format!("{e:#}"));
1971 }
1972 } else {
1973 self.sessions.write().await.remove(&known.alias);
1974 }
1975
1976 let remembered = {
1977 let mut set = self.reachable.write().await;
1978 set.set(&known.alias, ask.enabled, ask.base.clone());
1979 // Best effort, and reported beside the result rather than instead of it: failing to
1980 // write a file under the state directory must not undo a change that has already
1981 // taken effect.
1982 reachable::remember(&set).is_ok()
1983 };
1984
1985 #[derive(serde::Serialize)]
1986 struct Switched<'a> {
1987 host: &'a str,
1988 enabled: bool,
1989 remembered: bool,
1990 url: Option<String>,
1991 }
1992 control::json(&Switched {
1993 host: &known.alias,
1994 enabled: ask.enabled,
1995 remembered,
1996 url: ask.enabled.then(|| self.site_url(&known.alias)),
1997 })
1998 }
1999
2000 /// `GET /_control/certificate?name=<alias>.<suffix>` -- what is served for that name.
2001 ///
2002 /// Two reasons this exists rather than being an implementation detail.
2003 ///
2004 /// A reader being asked to trust a root may reasonably want to see what it signs before
2005 /// deciding, and "run openssl on this file" is a poor answer to that when the file is a root
2006 /// and the question is about a leaf.
2007 ///
2008 /// And it makes the https mode testable without a trust store. A browser can be told to
2009 /// accept one specific public key for one launch — Chromium's
2010 /// `--ignore-certificate-errors-spki-list` takes exactly the `pin` below — which means the
2011 /// whole path can be exercised in CI, where installing a root is not an option. Measured:
2012 /// with the pin, an https alias origin reports `isSecureContext` with service workers,
2013 /// `crypto.subtle` and `caches`, and nothing on the machine changes.
2014 fn show_certificate(&self, query: Option<&str>) -> Response<Full<Bytes>> {
2015 let Some(certificates) = self.certificates.as_ref() else {
2016 return control::text(
2017 StatusCode::NOT_IMPLEMENTED,
2018 "this daemon serves http, so there is no certificate",
2019 );
2020 };
2021
2022 // The name has to be asked for, because there is a certificate per name and no single
2023 // "the" certificate. Defaulting to something would answer a question nobody asked.
2024 let Some(name) = query.and_then(|q| {
2025 q.split('&')
2026 .find_map(|pair| pair.strip_prefix("name="))
2027 .map(str::to_string)
2028 }) else {
2029 return control::text(
2030 StatusCode::BAD_REQUEST,
2031 "certificate needs a name, e.g. ?name=docs.ssh-browser",
2032 );
2033 };
2034
2035 // Through the resolver's own cache, so this is the certificate a handshake gets rather
2036 // than a second one that happens to be valid. Minting a fresh one here is what the first
2037 // version did, and the pin it reported was for a certificate nobody would ever be served.
2038 let Some(minted) = certificates.certificate_for(&name) else {
2039 return control::text(
2040 StatusCode::BAD_REQUEST,
2041 format!("{name:?} is not a name this daemon can vouch for"),
2042 );
2043 };
2044
2045 #[derive(serde::Serialize)]
2046 struct Served<'a> {
2047 name: &'a str,
2048 certificate: &'a str,
2049 /// Base64 of the SHA-256 of the certificate's `SubjectPublicKeyInfo`.
2050 ///
2051 /// The form a browser takes for a one-launch pin, so it can be handed straight to
2052 /// `--ignore-certificate-errors-spki-list` without anybody computing a digest — and
2053 /// without trusting a root to try the https mode at all.
2054 pin: &'a str,
2055 authority: &'a str,
2056 }
2057 control::json(&Served {
2058 name: &name,
2059 certificate: &minted.certificate_pem,
2060 pin: &minted.pin,
2061 authority: certificates.authority.certificate_pem(),
2062 })
2063 }
2064
2065 /// `GET /_control/theme` -- what listings look like, and what else they could.
2066 async fn show_theme(&self) -> Response<Full<Bytes>> {
2067 #[derive(serde::Serialize)]
2068 struct Choice<'a> {
2069 name: &'a str,
2070 label: &'a str,
2071 /// `light`, `dark`, or `system`, so the dashboard can group them.
2072 variant: &'a str,
2073 }
2074 #[derive(serde::Serialize)]
2075 struct Themes<'a> {
2076 current: &'a str,
2077 themes: Vec<Choice<'a>>,
2078 }
2079 // The list comes from the daemon rather than being written out again in the
2080 // dashboard. Two copies of it is how a theme gets added and stays invisible.
2081 control::json(&Themes {
2082 current: &self.theme.read().await,
2083 themes: theme::all()
2084 .iter()
2085 .map(|t| Choice {
2086 name: &t.name,
2087 label: &t.label,
2088 variant: t.variant,
2089 })
2090 .collect(),
2091 })
2092 }
2093
2094 /// `POST /_control/theme` -- choose one, and remember it.
2095 async fn set_theme(&self, body: &[u8]) -> Response<Full<Bytes>> {
2096 #[derive(serde::Deserialize)]
2097 #[serde(deny_unknown_fields)]
2098 struct Ask {
2099 name: String,
2100 }
2101 let ask: Ask = match serde_json::from_slice(body) {
2102 Ok(ask) => ask,
2103 Err(e) => {
2104 return control::text(
2105 StatusCode::BAD_REQUEST,
2106 format!("theme needs a JSON body naming one: {e}"),
2107 );
2108 }
2109 };
2110 // Checked before anything is changed, so a typo leaves the daemon as it was rather
2111 // than half-moved to a theme that does not exist.
2112 if let Err(e) = theme::check(&ask.name) {
2113 return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
2114 }
2115
2116 *self.theme.write().await = ask.name.clone();
2117 // Remembered on a best effort. Failing to write a file under the runtime directory
2118 // must not undo a change the reader can already see on the next listing, so it is
2119 // reported beside the result rather than instead of it.
2120 let remembered = theme::remember(&ask.name).is_ok();
2121 #[derive(serde::Serialize)]
2122 struct Chose<'a> {
2123 current: &'a str,
2124 remembered: bool,
2125 }
2126 control::json(&Chose {
2127 current: &ask.name,
2128 remembered,
2129 })
2130 }
2131
2132 async fn autoindex_of(
2133 &self,
2134 session: &Session,
2135 alias: &str,
2136 path: &str,
2137 resolved: &str,
2138 query: Option<&str>,
2139 ) -> Response<Full<Bytes>> {
2140 // Taken from the resolved path rather than from the request, so the tree shows a
2141 // filename as it is spelled on disk rather than percent-escaped. `resolved` always
2142 // begins with the base, because that is what resolving it against the base means.
2143 let rel = resolved
2144 .strip_prefix(&session.base)
2145 .unwrap_or("")
2146 .to_string();
2147 let entries = match self.listing_of(session, resolved).await {
2148 Ok(entries) => entries,
2149 Err(e) => return fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
2150 };
2151 let sites = self.sites_among(session, resolved, &entries).await;
2152
2153 // `?ls` is one level of the same tree, as the HTML fragment that goes inside it.
2154 // It is what the tree fetches when a folder is expanded.
2155 //
2156 // A fragment rather than JSON so that there is exactly one thing that knows how a
2157 // row is written. A JSON reply would mean a second renderer in the page's script,
2158 // in another language, which is two places for a class name to be spelled and one
2159 // of them to be spelled wrong.
2160 //
2161 // It is not a new capability either: a page under this alias can already read every
2162 // path under it, and this says no more than the listing below does.
2163 if query == Some("ls") {
2164 let mut out = String::new();
2165 render_level(&mut out, &rel, &rows_of(&entries, &sites), &[]);
2166 return plain_ok("text/html; charset=utf-8", Bytes::from(out));
2167 }
2168
2169 // The ancestors are already in the cache: the walk that resolved this path warmed
2170 // every one of them to check for symlinks. So a tree opened four levels down costs
2171 // no more round trips than the listing it replaces.
2172 let mut levels = Vec::new();
2173 let mut at = session.base.clone();
2174 for part in rel.split('/').filter(|p| !p.is_empty()) {
2175 if let Some(entries) = self.cache.listing_entries(&at) {
2176 let here = at.strip_prefix(&session.base).unwrap_or("").to_string();
2177 // Only the level the reader is standing in is scanned for sites, so only it
2178 // can mark them. Scanning every level would multiply the one extra round
2179 // trip by the depth of the path, which is the thing this is careful not to
2180 // do; expanding a folder scans it, so a mark appears where you look.
2181 levels.push((here, rows_of(&entries, &HashSet::new())));
2182 }
2183 at.push('/');
2184 at.push_str(part);
2185 }
2186 levels.push((rel.clone(), rows_of(&entries, &sites)));
2187
2188 plain_ok(
2189 "text/html; charset=utf-8",
2190 Bytes::from(autoindex(alias, &rel, &levels, &self.theme.read().await)),
2191 )
2192 }
2193
2194 /// A directory's entries, from the cache when they are there.
2195 async fn listing_of(&self, session: &Session, dir: &str) -> Result<Vec<Entry>> {
2196 if let Some(entries) = self.cache.listing_entries(dir) {
2197 return Ok(entries);
2198 }
2199 let entries = session.fs.list_dir(dir).await?;
2200 self.cache.put_listing(dir, &entries);
2201 Ok(entries)
2202 }
2203
2204 /// Which of these subdirectories are themselves sites.
2205 ///
2206 /// A directory holding an `index.html` is served *as* that page, so it is a site rather
2207 /// than a folder, and saying so is what souta actually asked for. Grouping the HTML in
2208 /// one listing does not find a Pinax board, because a board is `out/ft_demo/index.html`
2209 /// and the directory you are standing in has no HTML in it at all.
2210 ///
2211 /// One extra round trip, because every listing is issued together -- not one per
2212 /// subdirectory. It is spent on a directory listing and never on a page load, so the
2213 /// round-trip invariant for serving a page is untouched.
2214 ///
2215 /// It is also not purely a cost: the listings it fetches are the ones the next click
2216 /// needs, so stepping into any of these subdirectories afterwards costs nothing.
2217 async fn sites_among(
2218 &self,
2219 session: &Session,
2220 dir: &str,
2221 entries: &[Entry],
2222 ) -> HashSet<String> {
2223 /// Beyond this, the scan is buying less than it costs: a directory with hundreds of
2224 /// subdirectories is not one somebody is scanning by eye for a report.
2225 const MAX_SCAN: usize = 64;
2226
2227 let names: Vec<&str> = entries
2228 .iter()
2229 .filter(|e| e.attrs.is_dir() && e.name != "." && e.name != ".." && !hidden(&e.name))
2230 .map(|e| e.name.as_str())
2231 .take(MAX_SCAN)
2232 .collect();
2233 if names.is_empty() {
2234 return HashSet::new();
2235 }
2236
2237 let paths: Vec<String> = names.iter().map(|n| format!("{dir}/{n}")).collect();
2238 // Already-known listings are not asked for again. Going back up a level is the
2239 // ordinary case and would otherwise re-list every sibling.
2240 let missing: Vec<String> = paths
2241 .iter()
2242 .filter(|p| self.cache.listing_entries(p).is_none())
2243 .cloned()
2244 .collect();
2245 if !missing.is_empty() {
2246 for (path, got) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
2247 if let Ok(entries) = got {
2248 self.cache.put_listing(path, &entries);
2249 }
2250 // A subdirectory that cannot be listed is simply not a site. It is not an
2251 // error for this page: the reader asked for the directory they are in, and
2252 // a permission problem one level down is theirs to meet when they click.
2253 }
2254 }
2255
2256 names
2257 .iter()
2258 .zip(paths.iter())
2259 .filter(|(_, path)| {
2260 self.cache.listing_entries(path).is_some_and(|listing| {
2261 listing
2262 .iter()
2263 .any(|e| e.name == "index.html" && !e.attrs.is_dir())
2264 })
2265 })
2266 .map(|(name, _)| (*name).to_string())
2267 .collect()
2268 }
2269
2270 /// Read what an HTML page is about to ask for, in one batch.
2271 ///
2272 /// One round trip to list the directories they live in, then one batch of reads — and
2273 /// neither grows with the number of subresources. When they sit beside the document, which
2274 /// is what a generated report looks like, the listing is already held and the listing round
2275 /// disappears.
2276 ///
2277 /// Two at most, and it really is two. The reads go through `read_ranges` rather than
2278 /// `read_batch` precisely so that this holds: `read_batch` has to poll in 32 KiB chunks
2279 /// because it does not know how long a file is, which made a one-megabyte bundle
2280 /// thirty-two round trips here. The listing already says how long each one is.
2281 ///
2282 /// Every reference goes through the same resolution and the same symlink rule as a real
2283 /// request, on purpose. A page is untrusted input, and a prefetcher that skipped those
2284 /// checks could be told to read a file the operator's configuration says is out of
2285 /// bounds. Serving it would still be refused, but reading it is already the wrong act.
2286 ///
2287 /// Failures are dropped in silence here, which is the one place in this codebase that is
2288 /// right: a reference that cannot be read is about to be requested for real, and that
2289 /// request reports the failure properly. Saying anything now would be guessing at whether
2290 /// the reader was going to care.
2291 async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
2292 let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
2293 if refs.is_empty() {
2294 return;
2295 }
2296 // The directory the document is in, in URL terms, which is what a relative reference
2297 // on the page is relative to.
2298 let dir_of_doc = match doc_path.rsplit_once('/') {
2299 Some((head, _)) => head,
2300 None => "",
2301 };
2302
2303 // Resolved first, so that a reference climbing out of the base is gone before it can
2304 // contribute a directory to list.
2305 let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
2306 for r in &refs {
2307 let url = if r.starts_with('/') {
2308 r.clone()
2309 } else {
2310 format!("{dir_of_doc}/{r}")
2311 };
2312 let Ok(resolved) = guard::resolve(&session.base, &url) else {
2313 continue;
2314 };
2315 let chain = components(&session.base, &resolved);
2316 if chain.is_empty() {
2317 continue;
2318 }
2319 // The same rule the request path applies, applied here too — a page naming
2320 // `.ssh/id_ed25519` in an `<img src>` must not get it read into the cache on the
2321 // strength of the request that would refuse it never being made.
2322 if chain.iter().any(|(_, n)| hidden(n)) {
2323 continue;
2324 }
2325 // Checked against what is already known before anything new is listed. Without
2326 // this a page could get a directory behind a symlink listed purely by naming it,
2327 // and the symlink rule exists precisely so that the daemon does not go there.
2328 // The check runs again after the listings, for components not yet known.
2329 if self.first_symlink_cached(&chain).is_some() {
2330 continue;
2331 }
2332 // And every directory this reference would cause to be listed has to be one the
2333 // cache can already prove is not behind a symlink. `first_symlink` alone is not
2334 // enough: it sees only what is cached, so a symlink one level below the deepest
2335 // listing held is invisible to it and would be opened by the very batch meant to
2336 // discover it.
2337 if !chain
2338 .iter()
2339 .all(|(dir, _)| self.listable(&session.base, dir))
2340 {
2341 continue;
2342 }
2343 wanted.push((resolved, chain));
2344 }
2345
2346 let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
2347 // Prefetching only ever makes a page faster, so a directory that would not list is
2348 // not an error for the request that triggered it: the subresource is fetched the
2349 // ordinary way afterwards and fails, or does not, on its own terms.
2350 let held = self.held_listings(session, &all).await;
2351
2352 let mut to_read = Vec::new();
2353 for (resolved, chain) in &wanted {
2354 if first_symlink(&held, chain).is_some() {
2355 continue;
2356 }
2357 let (dir, name) = &chain[chain.len() - 1];
2358 let Some(attrs) = attrs_in(&held, dir, name) else {
2359 continue;
2360 };
2361 if attrs.is_dir() {
2362 continue;
2363 }
2364 // The size has to be known, and not merely defaulted to zero, because it is what
2365 // the read below asks for. A listing that did not report one leaves nothing to
2366 // ask for, and requesting zero bytes would cache an empty body for a file that
2367 // has contents.
2368 let Some(size) = attrs.size else {
2369 continue;
2370 };
2371 // Nothing to warm at zero, and warming it is where a listing that lies about the
2372 // size does damage: a ranged read asks for exactly what it was told, so a file
2373 // reported as empty is fetched as empty and then served that way. A real empty
2374 // file loses nothing by being read on request.
2375 //
2376 // A file too large to hold, at the other end, would be read only to be declined
2377 // by the cache and read again by the real request anyway.
2378 if size == 0 || size > CACHE_WHOLE_MAX {
2379 continue;
2380 }
2381 if self.cache.body(resolved, &attrs).is_some() {
2382 continue;
2383 }
2384 to_read.push((resolved.clone(), attrs, size));
2385 }
2386 if to_read.is_empty() {
2387 return;
2388 }
2389
2390 // `read_ranges` rather than `read_batch`, because the size is already known.
2391 //
2392 // `read_batch` cannot know how long a file is, so it polls in 32 KiB chunks until it
2393 // sees a short read: one round trip per chunk index, which makes a one-megabyte
2394 // bundle thirty-two of them. `read_ranges` is handed the length, so it computes every
2395 // chunk before issuing any and the whole file costs one. The listing this function
2396 // already depends on is what supplies the length, so nothing extra is asked for.
2397 let reqs: Vec<RangeReq> = to_read
2398 .iter()
2399 .map(|(path, _, size)| RangeReq {
2400 path: path.clone(),
2401 offset: 0,
2402 len: *size,
2403 })
2404 .collect();
2405
2406 for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
2407 let Ok(body) = got else {
2408 continue;
2409 };
2410 // Short of what the listing promised means the file changed underneath us. The
2411 // cache key records the old size, so holding a body that no longer matches it
2412 // would serve the next reader a length the bytes do not have. Leaving it out
2413 // costs one prefetch; the real request reads it afresh.
2414 if body.len() as u64 != *size {
2415 continue;
2416 }
2417 self.cache.put_body(path, attrs, Bytes::from(body));
2418 }
2419 }
2420
2421 /// Fetch every ancestor listing not already held, in one batch.
2422 ///
2423 /// One round trip regardless of depth, which is the whole reason `list_dirs` is a batch
2424 /// rather than a loop. A directory that cannot be listed is simply left absent from the
2425 /// cache; the caller diagnoses that against the path the request actually named.
2426 /// Every directory along a path, taken out of the cache once and then held.
2427 ///
2428 /// Held, rather than looked up again as the walk goes. The cache has a two-second TTL,
2429 /// so asking whether a listing is there and then asking for the listing are two
2430 /// questions with a gap between them, and a request arriving on the boundary got `true`
2431 /// for the first and `false` for the second. That produced a 404 reading "cannot list"
2432 /// about a directory that plainly existed, on roughly one e2e run in six. Taking the
2433 /// entries once removes the gap rather than narrowing it.
2434 ///
2435 /// A failure carries the remote's own reason out. It used to be dropped and reported as
2436 /// "cannot list", which is this daemon saying it does not know rather than ssh saying
2437 /// why — the difference between a message somebody can act on and one they cannot.
2438 async fn listings_along(
2439 &self,
2440 session: &Session,
2441 chain: &[(String, String)],
2442 ) -> Result<HashMap<String, Vec<Entry>>, (String, String)> {
2443 let mut held: HashMap<String, Vec<Entry>> = HashMap::new();
2444 let mut missing: Vec<String> = Vec::new();
2445 for (dir, _) in chain {
2446 if held.contains_key(dir) {
2447 continue;
2448 }
2449 match self.cache.listing_entries(dir) {
2450 Some(entries) => {
2451 held.insert(dir.clone(), entries);
2452 }
2453 // Deduplicated because the prefetcher passes the chains of many files at
2454 // once and several of them normally share a directory. Listing one twice in
2455 // a batch costs no extra round trip, but it does cost the remote the work.
2456 None if !missing.contains(dir) => missing.push(dir.clone()),
2457 None => {}
2458 }
2459 }
2460 if missing.is_empty() {
2461 return Ok(held);
2462 }
2463 for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
2464 match result {
2465 Ok(entries) => {
2466 self.cache.put_listing(dir, &entries);
2467 held.insert(dir.clone(), entries);
2468 }
2469 // Absence is not a failure to report. A component that is not there, or
2470 // that is a file being used as a directory, is a 404 and the walk says so
2471 // on its own — answering 502 would blame the remote for a path the reader
2472 // got wrong. Anything else is the remote refusing, and that reason travels.
2473 Err(e) if crate::fs::is_absent(&e) => {}
2474 Err(e) => return Err((dir.clone(), format!("{e:#}"))),
2475 }
2476 }
2477 Ok(held)
2478 }
2479
2480 /// Can this directory be listed without asking the remote to walk through a symlink?
2481 ///
2482 /// True only when every step from the alias base down to it is already known — from a
2483 /// listing already held — to be a real directory. A step that is not known yet is not
2484 /// assumed safe, because SFTP v3 `OPENDIR` has no `O_NOFOLLOW`: asking the remote to
2485 /// list a path *is* asking it to follow whatever symlinks are in that path, and the
2486 /// answer arrives too late to un-ask. The base itself is operator configuration, not
2487 /// something a request reaches, so it is the one directory taken on trust.
2488 fn listable(&self, base: &str, dir: &str) -> bool {
2489 if dir.trim_end_matches('/') == base.trim_end_matches('/') {
2490 return true;
2491 }
2492 components(base, dir).iter().all(|(parent, name)| {
2493 self.cache
2494 .attrs_of(parent, name)
2495 .is_some_and(|a| a.is_dir() && !a.is_symlink())
2496 })
2497 }
2498
2499 /// The first component of a chain that is a symlink, if any.
2500 ///
2501 /// Shared between reading and writing deliberately. A write that reached through a
2502 /// symlinked directory could place a file outside the alias base entirely, which is
2503 /// strictly worse than reading through one, so the two must not be able to drift apart.
2504 /// The same walk for the write path, over listings held for the same reason.
2505 ///
2506 /// A failure leaves the symlink check with nothing to check, and the write then fails
2507 /// with the remote's own reason. There is no better answer to give from here.
2508 async fn held_listings(
2509 &self,
2510 session: &Session,
2511 chain: &[(String, String)],
2512 ) -> HashMap<String, Vec<Entry>> {
2513 self.listings_along(session, chain)
2514 .await
2515 .unwrap_or_default()
2516 }
2517}
2518
2519impl Origin {
2520 /// The symlink check over what is *already* cached and nothing more.
2521 ///
2522 /// The prefetcher runs this before it lists anything, so that a page cannot get a
2523 /// directory behind a symlink listed purely by naming it. Deliberately not the held
2524 /// version: the question here is what is known without asking.
2525 fn first_symlink_cached(&self, chain: &[(String, String)]) -> Option<String> {
2526 chain.iter().find_map(|(dir, name)| {
2527 self.cache
2528 .attrs_of(dir, name)
2529 .filter(Attrs::is_symlink)
2530 .map(|_| format!("{dir}/{name}"))
2531 })
2532 }
2533}
2534
2535/// One entry's attrs, out of the listings this request is holding.
2536fn attrs_in(held: &HashMap<String, Vec<Entry>>, dir: &str, name: &str) -> Option<Attrs> {
2537 held.get(dir)
2538 .and_then(|entries| entries.iter().find(|e| e.name == name))
2539 .map(|e| e.attrs)
2540}
2541
2542fn first_symlink(held: &HashMap<String, Vec<Entry>>, chain: &[(String, String)]) -> Option<String> {
2543 chain.iter().find_map(|(dir, name)| {
2544 attrs_in(held, dir, name)
2545 .filter(Attrs::is_symlink)
2546 .map(|_| format!("{dir}/{name}"))
2547 })
2548}
2549
2550/// The `?dashboard=` an extension names itself with on `hello`, if it is one.
2551///
2552/// Strict to the point of being boring, because this ends up inside a URL in a `<script>` on
2553/// the daemon's own page. Chrome derives an extension id as thirty-two letters from `a` to
2554/// `p` and nothing else, so anything else is not an id and is dropped rather than escaped:
2555/// there is no string that passes this and means something other than an extension.
2556fn extension_id(query: Option<&str>) -> Option<String> {
2557 let id = query?
2558 .split('&')
2559 .find_map(|p| p.strip_prefix("dashboard="))?;
2560 (id.len() == 32 && id.bytes().all(|b| b.is_ascii_lowercase() && b <= b'p'))
2561 .then(|| id.to_string())
2562}
2563
2564/// What the dashboard looks like, compiled in.
2565///
2566/// Shared with the extension, which links the same file out of `dist/` — see the note at the
2567/// top of it. The root of the suffix and the dashboard are one page in two places, and one
2568/// stylesheet is what keeps them one page.
2569const DASHBOARD_CSS: &str = include_str!("../../assets/dashboard.css");
2570
2571impl Origin {
2572 /// The root of the suffix, and the root of the loopback listener: the front door.
2573 ///
2574 /// There is one dashboard and it is the extension's, so the first thing this does is hand
2575 /// a browser over to it. Drawing a second one here was the alternative and it is the wrong
2576 /// one twice over: the halves that matter — the hosts you can serve, serving one, stopping
2577 /// one — are control-API calls that no page reaches, and two drawings of one page is two
2578 /// things to keep identical.
2579 ///
2580 /// What is left below the hand-over is the fallback, and it is only ever seen by something
2581 /// that cannot follow it: a local tool with `--proxy`, a daemon nothing has connected to
2582 /// yet, or a suffix the extension was not built with. It says what is being served and
2583 /// nothing that is not already public.
2584 async fn alias_index(&self, dashboard: Option<&str>) -> String {
2585 let mut sites: Vec<(String, String, String)> = self
2586 .sessions
2587 .read()
2588 .await
2589 .iter()
2590 .map(|(alias, s)| (alias.clone(), s.host.clone(), s.base.clone()))
2591 .collect();
2592 sites.sort();
2593
2594 // Declared and not connected. Listed rather than left out: a reader who can see
2595 // `panza` in their config file and not on the page whose whole job is saying what is
2596 // served has been told nothing, and that is the silent failure under another name.
2597 let (trouble, stopped) = (self.trouble.read().await, self.stopped.read().await);
2598 let mut down: Vec<(String, String)> = self
2599 .declared
2600 .iter()
2601 .filter(|(name, _)| !sites.iter().any(|(open, ..)| open == *name))
2602 .map(|(name, d)| {
2603 let why = if stopped.contains(name) {
2604 "stopped from the dashboard; a restart serves it again".to_string()
2605 } else {
2606 trouble
2607 .get(name)
2608 .map(|t| t.why.clone())
2609 .unwrap_or_else(|| format!("{} has not answered", d.host))
2610 };
2611 (name.clone(), why)
2612 })
2613 .collect();
2614 down.sort();
2615 drop((trouble, stopped));
2616
2617 let mut s = String::from(
2618 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
2619 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
2620 <title>ssh-browser</title><style>",
2621 );
2622 s.push_str(DASHBOARD_CSS);
2623 s.push_str("</style>");
2624
2625 // In the head, so nothing below is ever painted on the way past. The id is thirty-two
2626 // letters or it is not here at all, which is what makes putting it inside a script
2627 // safe without escaping -- see `extension_id`.
2628 if let Some(id) = dashboard {
2629 s.push_str("<script>location.replace(\"chrome-extension://");
2630 s.push_str(id);
2631 s.push_str("/dashboard.html\")</script>");
2632 }
2633 s.push_str("</head><body><h1>ssh-browser</h1><div id=\"view\">");
2634
2635 if let Some(id) = dashboard {
2636 // Read only by somebody the browser refused that navigation to, which it does
2637 // unless the extension lists this exact origin in `web_accessible_resources` --
2638 // so a custom suffix arrives here. Same address, for a reader to take by hand.
2639 s.push_str("<p class=\"note\">The dashboard is at <a href=\"chrome-extension://");
2640 s.push_str(id);
2641 s.push_str("/dashboard.html\">chrome-extension://");
2642 s.push_str(id);
2643 s.push_str("/dashboard.html</a>.</p>");
2644 }
2645
2646 s.push_str("<h2>sites</h2>");
2647
2648 if sites.is_empty() {
2649 s.push_str(
2650 "<p class=\"empty\">Nothing is being served yet. \
2651 Open the ssh-browser dashboard to pick a host.</p>",
2652 );
2653 } else {
2654 s.push_str("<ul>");
2655 for (alias, host, base) in &sites {
2656 let href = escape(&self.site_url(alias));
2657 s.push_str("<li><a data-alias=\"");
2658 // The same attribute the dashboard's card carries, so one selector finds the
2659 // card on either page -- which is how the two are checked against each other.
2660 s.push_str(&escape(alias));
2661 s.push_str("\" href=\"");
2662 s.push_str(&href);
2663 s.push_str("\"><div class=\"name\">");
2664 s.push_str(&escape(alias));
2665 s.push_str("</div><div class=\"url\">");
2666 s.push_str(&href);
2667 s.push_str("</div><div class=\"where\">");
2668 s.push_str(&escape(&format!("{host}:{base}")));
2669 s.push_str("</div></a></li>");
2670 }
2671 s.push_str("</ul>");
2672 }
2673
2674 if !down.is_empty() {
2675 s.push_str("<h2>not connected</h2><ul>");
2676 for (alias, why) in &down {
2677 // Still a link. Opening it is the retry -- a request is what dials -- so the
2678 // thing to do about it is the thing that was already there.
2679 s.push_str("<li><a data-alias=\"");
2680 s.push_str(&escape(alias));
2681 s.push_str("\" href=\"");
2682 s.push_str(&escape(&self.site_url(alias)));
2683 s.push_str("\"><div class=\"name\">");
2684 s.push_str(&escape(alias));
2685 s.push_str("</div><div class=\"bad-host\">");
2686 s.push_str(&escape(why));
2687 s.push_str("</div></a></li>");
2688 }
2689 s.push_str("</ul>");
2690 }
2691
2692 s.push_str(
2693 "<p class=\"note\">Each of these is its own origin, which is what this page is a \
2694 list of. Opening one that is not connected is what retries it. Serving a host and \
2695 stopping one happen in the dashboard: those go through the daemon's control API, \
2696 and no page reaches that.</p>\
2697 </div></body></html>",
2698 );
2699 s
2700 }
2701}
2702
2703/// Every step from the alias base down to the file, as `(directory to list, name to
2704/// check inside it)`, base first.
2705///
2706/// The base is the first directory listed and is never itself a checked name: it is
2707/// operator configuration, not something a request reaches.
2708fn components(base: &str, file: &str) -> Vec<(String, String)> {
2709 let base = base.trim_end_matches('/');
2710 let relative = file
2711 .strip_prefix(base)
2712 .unwrap_or("")
2713 .trim_start_matches('/');
2714
2715 let mut out = Vec::new();
2716 let mut dir = base.to_string();
2717 for name in relative.split('/').filter(|s| !s.is_empty()) {
2718 out.push((dir.clone(), name.to_string()));
2719 dir = format!("{dir}/{name}");
2720 }
2721 out
2722}
2723
2724/// A control body is a host name or a theme name, not a file upload.
2725///
2726/// `Limited` errors once the cap is passed rather than truncating, so a body that was too
2727/// large cannot be quietly parsed as a shorter one.
2728const MAX_CONTROL_BODY: usize = 256 * 1024;
2729
2730async fn read_body<B>(body: B) -> Result<Bytes, String>
2731where
2732 B: hyper::body::Body,
2733 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
2734{
2735 use http_body_util::{BodyExt, Limited};
2736 Limited::new(body, MAX_CONTROL_BODY)
2737 .collect()
2738 .await
2739 .map(|collected| collected.to_bytes())
2740 .map_err(|e| format!("reading the request body: {e}"))
2741}
2742
2743fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
2744 req.headers()
2745 .get(name)
2746 .and_then(|v| v.to_str().ok())
2747 .map(str::to_string)
2748}
2749
2750/// Serve a body already in hand, whole or sliced.
2751fn respond(
2752 file: &str,
2753 body: Bytes,
2754 tag: Option<&str>,
2755 wanted: &range::Resolved,
2756 size: u64,
2757) -> Response<Full<Bytes>> {
2758 match wanted {
2759 range::Resolved::Part { start, end } => {
2760 // Clamped against the body actually held rather than the advertised size,
2761 // so a listing that disagrees with the file cannot panic the slice.
2762 let lo = usize::try_from(*start)
2763 .unwrap_or(usize::MAX)
2764 .min(body.len());
2765 let hi = usize::try_from(end.saturating_add(1))
2766 .unwrap_or(usize::MAX)
2767 .min(body.len())
2768 .max(lo);
2769 partial(
2770 mime::guess(file),
2771 body.slice(lo..hi),
2772 tag,
2773 *start,
2774 *end,
2775 size,
2776 )
2777 }
2778 _ => served(mime::guess(file), body, tag),
2779 }
2780}
2781
2782fn partial(
2783 content_type: &str,
2784 body: Bytes,
2785 tag: Option<&str>,
2786 start: u64,
2787 end: u64,
2788 size: u64,
2789) -> Response<Full<Bytes>> {
2790 let mut b = Response::builder()
2791 .status(StatusCode::PARTIAL_CONTENT)
2792 .header(CONTENT_TYPE, content_type)
2793 .header(CACHE_CONTROL, "no-cache")
2794 .header(ACCEPT_RANGES, "bytes")
2795 .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
2796 if let Some(tag) = tag {
2797 b = b.header(ETAG, tag);
2798 }
2799 b.body(Full::new(body))
2800 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
2801}
2802
2803/// A 416 has to carry the real size, or a client cannot work out what it should have
2804/// asked for instead.
2805fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
2806 Response::builder()
2807 .status(StatusCode::RANGE_NOT_SATISFIABLE)
2808 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
2809 .header(CONTENT_RANGE, format!("bytes */{size}"))
2810 .body(Full::new(Bytes::from_static(b"range not satisfiable")))
2811 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
2812}
2813
2814fn host_of<B>(req: &Request<B>) -> Option<String> {
2815 // A proxied request has an absolute-form target; a direct one only has the
2816 // header. Prefer the header, since that is what the browser actually sent.
2817 req.headers()
2818 .get(HOST)
2819 .and_then(|v| v.to_str().ok())
2820 .map(str::to_string)
2821 .or_else(|| req.uri().host().map(str::to_string))
2822}
2823
2824fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
2825 let mut b = Response::builder()
2826 .status(StatusCode::OK)
2827 .header(CONTENT_TYPE, content_type)
2828 // `no-cache` means revalidate, not "do not store". With an ETag attached
2829 // that revalidation is a 304 answered from the listing cache, so the
2830 // browser keeps its copy and the remote is never touched.
2831 .header(CACHE_CONTROL, "no-cache")
2832 // Advertised on every full response: a client that does not know ranges are
2833 // available will never try to seek.
2834 .header(ACCEPT_RANGES, "bytes");
2835 if let Some(tag) = tag {
2836 b = b.header(ETAG, tag);
2837 }
2838 b.body(Full::new(body))
2839 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
2840}
2841
2842/// For responses with no validator to offer: the PAC, the alias index, a listing.
2843fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
2844 served(content_type, body, None)
2845}
2846
2847/// One of the daemon's own pages, which no other page may frame.
2848///
2849/// Everything else served here is somebody's file, and whether it may be framed is their
2850/// business. This is ours, and it is the one page that says what is being served and where
2851/// it is rooted. A page on `evil.<suffix>` cannot read it — different origin, and no CORS
2852/// header is sent — but without this it could put it under a transparent overlay, which is
2853/// the attack that does not need to read anything.
2854fn own_page(body: String) -> Response<Full<Bytes>> {
2855 let mut res = plain_ok("text/html; charset=utf-8", Bytes::from(body));
2856 res.headers_mut().insert(
2857 CONTENT_SECURITY_POLICY,
2858 HeaderValue::from_static("frame-ancestors 'none'"),
2859 );
2860 res
2861}
2862
2863/// No `Last-Modified` anywhere, deliberately.
2864///
2865/// Emitting it would oblige us to honour `If-Modified-Since`, whose comparison is
2866/// second-resolution -- the same resolution SFTP reports mtime at, which is exactly
2867/// where it stops being able to tell two versions apart. The ETag carries the same
2868/// information without that ambiguity, so it is the only validator offered.
2869fn not_modified(tag: &str) -> Response<Full<Bytes>> {
2870 Response::builder()
2871 .status(StatusCode::NOT_MODIFIED)
2872 .header(ETAG, tag)
2873 .header(CACHE_CONTROL, "no-cache")
2874 .body(Full::new(Bytes::new()))
2875 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
2876}
2877
2878fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
2879 Response::builder()
2880 .status(status)
2881 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
2882 .body(Full::new(Bytes::from(detail.into())))
2883 .expect("a plain-text body with static headers always builds")
2884}
2885
2886fn redirect(to: &str) -> Response<Full<Bytes>> {
2887 Response::builder()
2888 .status(StatusCode::MOVED_PERMANENTLY)
2889 .header(LOCATION, to)
2890 .body(Full::new(Bytes::new()))
2891 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
2892}
2893
2894/// Listing for a directory that has no index.html.
2895/// A name this daemon will not serve.
2896///
2897/// The ssh_config entry a remembered name refers to.
2898///
2899/// Matched on either spelling, because the two are not always the same thing: what is written
2900/// down is the URL *label*, which is lowercase because it becomes a hostname, and the
2901/// `Host` in ssh_config it came from may be `Panza` where the label is `panza`.
2902///
2903/// Getting this wrong is not a near miss. Dialling the label produced `Could not resolve
2904/// hostname panza` on every start, while turning the host on had worked a moment earlier —
2905/// because that path had the ssh_config entry in hand and startup had only the name. No test
2906/// against a fake remote could have found it; running it twice did.
2907fn entry_for<'a>(known: &'a [ssh_config::Host], name: &str) -> Option<&'a ssh_config::Host> {
2908 known
2909 .iter()
2910 .find(|h| h.alias == name || h.host.eq_ignore_ascii_case(name))
2911}
2912
2913/// Completed and failed TLS handshakes since this daemon started.
2914///
2915/// Two counters rather than a single "is it trusted" flag, because the two facts are different
2916/// and a reader needs both: a failure says the step was missed, and a success says it was taken.
2917/// A flag would have to pick one moment to believe.
2918#[derive(Debug, Default)]
2919struct Handshakes {
2920 completed: std::sync::atomic::AtomicU64,
2921 failed: std::sync::atomic::AtomicU64,
2922}
2923
2924/// A rustls configuration that mints a certificate for whatever name the handshake asks for.
2925///
2926/// Per name rather than one wildcard, because a wildcard does not work: `*.ssh-browser` is
2927/// refused by Chromium with `ERR_CERT_COMMON_NAME_INVALID`, since the suffix is not a known
2928/// registry and a wildcard directly beneath one reads as covering a whole top-level domain. A
2929/// certificate naming the alias outright is accepted, and the origin is then a real secure
2930/// context — service workers, `crypto.subtle` and all.
2931fn serving_config(suffix: &str) -> Result<(rustls::ServerConfig, Arc<PerName>, String)> {
2932 let (authority, found) = tls::load_or_create_reporting(suffix)?;
2933 let resolver = Arc::new(PerName {
2934 authority: Arc::new(authority),
2935 minted: Mutex::new(HashMap::new()),
2936 });
2937
2938 // Said on every https run, not only the first. Nothing portable can ask a trust store
2939 // whether this is still in it, and the failure when it is not — `ERR_CERT_AUTHORITY_INVALID`
2940 // on a URL the daemon itself just advertised — contains no hint that a command exists. A
2941 // returning reader can skim two lines; a first-time reader cannot recover without them.
2942 let path = tls::certificate_path()
2943 .map(|p| p.display().to_string())
2944 .unwrap_or_else(|| "the state directory".to_string());
2945 // A list of lines rather than one string with escapes in it. The last attempt at the latter
2946 // shipped a banner carrying the source file's own indentation into the middle of a sentence.
2947 let lines: Vec<String> = match found {
2948 tls::Found::Created => vec![
2949 "this run made a local certificate authority, and nothing trusts it yet.".to_string(),
2950 "Until it is, the browser will refuse every page here:".to_string(),
2951 String::new(),
2952 " ssh-browser trust".to_string(),
2953 String::new(),
2954 "prints the command for your platform, and the one that undoes it.".to_string(),
2955 format!("The certificate is {path}"),
2956 ],
2957 tls::Found::Existing => vec![
2958 "https: if the browser refuses a page, the local authority is not trusted.".to_string(),
2959 "`ssh-browser trust` prints how.".to_string(),
2960 format!("The certificate is {path}"),
2961 ],
2962 };
2963 let advice = lines.join("\n");
2964
2965 // No client authentication: the browser is not asked to prove anything, because nothing here
2966 // decides what to serve based on who is asking. The token does that, on the control API.
2967 let mut config = rustls::ServerConfig::builder()
2968 .with_no_client_auth()
2969 .with_cert_resolver(Arc::clone(&resolver) as Arc<dyn rustls::server::ResolvesServerCert>);
2970 // The browser asked for https, so http/1.1 is what is spoken inside the tunnel. Advertised
2971 // rather than left to chance: without it a browser may negotiate h2, which nothing here
2972 // serves, and the failure is a dead connection rather than a refusal.
2973 config.alpn_protocols = vec![b"http/1.1".to_vec()];
2974 Ok((config, resolver, advice))
2975}
2976
2977/// Signs a certificate the first time each name is asked for, and remembers it.
2978///
2979/// Remembered because a handshake must not wait on key generation twice for the same site, and
2980/// because a browser opening a page and its subresources will handshake more than once.
2981///
2982/// The cache is also the *only* source of truth for what is being served, which
2983/// `/_control/certificate` reads. Minting a second certificate to answer that question is what the
2984/// first version did, and it produced a key pin for a certificate no browser would ever see: the
2985/// right length, the right shape, and never a match. The e2e caught it on the first run.
2986struct PerName {
2987 authority: Arc<tls::Authority>,
2988 minted: Mutex<HashMap<String, Minted>>,
2989}
2990
2991/// One name's certificate, in all three forms anything here needs.
2992#[derive(Clone)]
2993struct Minted {
2994 key: Arc<rustls::sign::CertifiedKey>,
2995 certificate_pem: String,
2996 /// Base64 of the SHA-256 of the public key info, which is what a browser takes as a pin.
2997 pin: String,
2998}
2999
3000impl PerName {
3001 /// The certificate for this name, signing one if there is not one yet.
3002 ///
3003 /// `None` for a name the authority may not vouch for, which is also what a handshake gets.
3004 fn certificate_for(&self, name: &str) -> Option<Minted> {
3005 if let Some(found) = self
3006 .minted
3007 .lock()
3008 .ok()
3009 .and_then(|held| held.get(name).cloned())
3010 {
3011 return Some(found);
3012 }
3013
3014 // `leaf_for` refuses anything that is not a single label under the suffix, so this is also
3015 // the point where a request for somebody else's name stops. The name constraint in the
3016 // authority would stop it again at the verifier, but failing here means never signing it.
3017 let leaf = self.authority.leaf_for(name).ok()?;
3018 let certs =
3019 rustls_pki_types::CertificateDer::pem_slice_iter(leaf.certificate_pem.as_bytes())
3020 .collect::<std::result::Result<Vec<_>, _>>()
3021 .ok()?;
3022 let key = rustls_pki_types::PrivateKeyDer::from_pem_slice(leaf.key_pem.as_bytes()).ok()?;
3023 let signing = rustls::crypto::ring::default_provider()
3024 .key_provider
3025 .load_private_key(key)
3026 .ok()?;
3027 let minted = Minted {
3028 key: Arc::new(rustls::sign::CertifiedKey::new(certs, signing)),
3029 pin: tls::spki_pin(&leaf.certificate_pem).ok()?,
3030 certificate_pem: leaf.certificate_pem,
3031 };
3032
3033 // Whoever got here first wins, and the loser's certificate is dropped rather than
3034 // replacing it. Two valid certificates for one name would both work, but only one of them
3035 // is the one `/_control/certificate` reported.
3036 if let Ok(mut held) = self.minted.lock() {
3037 return Some(held.entry(name.to_string()).or_insert(minted).clone());
3038 }
3039 Some(minted)
3040 }
3041}
3042
3043impl std::fmt::Debug for PerName {
3044 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3045 // Hand-written because `Authority` holds a private key and deriving this would put it one
3046 // `{:?}` away from a log line.
3047 f.debug_struct("PerName")
3048 .field("suffix", &self.authority.suffix())
3049 .finish()
3050 }
3051}
3052
3053impl rustls::server::ResolvesServerCert for PerName {
3054 fn resolve(
3055 &self,
3056 hello: rustls::server::ClientHello<'_>,
3057 ) -> Option<Arc<rustls::sign::CertifiedKey>> {
3058 // No SNI means no name to vouch for. Returning nothing fails the handshake, which is the
3059 // honest answer: a certificate for a name the client did not ask about would be rejected
3060 // by the client anyway, and more confusingly.
3061 let name = hello.server_name()?;
3062 Some(self.certificate_for(name)?.key)
3063 }
3064}
3065
3066/// Anything beginning with a dot. An alias base is one origin, so a page under it can read
3067/// everything else under it with `fetch` — the base is the blast radius. On a home directory
3068/// almost everything worth stealing sits behind a dot: `.ssh`, `.aws`, `.netrc`, a `.git`
3069/// whose remote URL carries a token. Refusing them costs a reader nearly nothing, and it is
3070/// what makes pointing an alias at a home directory a reasonable thing to do at all.
3071fn hidden(name: &str) -> bool {
3072 name.starts_with('.')
3073}
3074
3075/// One entry of a directory, ready to be written into a row.
3076struct Row {
3077 name: String,
3078 dir: bool,
3079 /// Holds an `index.html`, so it is a site rather than a folder.
3080 site: bool,
3081 /// Absent for a directory, whose own size is its bookkeeping rather than its contents'.
3082 size: Option<String>,
3083 modified: Option<String>,
3084 /// Which colour its marker takes.
3085 kind: &'static str,
3086}
3087
3088/// The rows of one directory, sorted and with the dot-names already gone.
3089fn rows_of(entries: &[Entry], sites: &HashSet<String>) -> Vec<Row> {
3090 let mut visible: Vec<&Entry> = entries
3091 .iter()
3092 // `.` and `..` are already gone by the time a path resolves; these are the real
3093 // dot-names. Listing what the next click would be refused is worse than silence.
3094 .filter(|e| e.name != "." && e.name != ".." && !hidden(&e.name))
3095 .collect();
3096 visible.sort_by(|a, b| (rank(a, sites), &a.name).cmp(&(rank(b, sites), &b.name)));
3097
3098 visible
3099 .into_iter()
3100 .map(|e| {
3101 let dir = e.attrs.is_dir();
3102 Row {
3103 name: e.name.clone(),
3104 dir,
3105 site: dir && sites.contains(&e.name),
3106 size: if dir {
3107 None
3108 } else {
3109 e.attrs.size.map(human_size)
3110 },
3111 modified: e.attrs.mtime.map(utc_stamp),
3112 kind: if dir { "dir" } else { family(&e.name) },
3113 }
3114 })
3115 .collect()
3116}
3117
3118/// Where an entry sorts, before its name is considered.
3119///
3120/// Directories first and no headings over them — souta's call, and it is how a file tree
3121/// has worked since long before anybody wrote one down. Within each half the thing you came
3122/// to open rises: a directory that *is* a page, and then an HTML file.
3123///
3124/// A Pinax board is `out/ft_demo/index.html`, so the directory holding it is what has to
3125/// rise. Sorting the HTML alone would never move anything, because the directory you are
3126/// standing in has no HTML in it at all.
3127fn rank(e: &Entry, sites: &HashSet<String>) -> (u8, u8) {
3128 if e.attrs.is_dir() {
3129 (0, u8::from(!sites.contains(&e.name)))
3130 } else {
3131 (1, u8::from(!is_page(&e.name)))
3132 }
3133}
3134
3135fn is_page(name: &str) -> bool {
3136 matches!(extension_of(name).as_deref(), Some("html" | "htm"))
3137}
3138
3139/// Which colour an entry's marker takes.
3140///
3141/// Families rather than extensions, because the point is to be readable without being read:
3142/// a `.toml` and a `.png` should not look the same, but `.toml` and `.json` may. This is
3143/// the one thing an editor's file tree does that a plain list does not.
3144fn family(name: &str) -> &'static str {
3145 match extension_of(name).as_deref() {
3146 Some("html" | "htm") => "k-page",
3147 Some("md" | "txt" | "rst" | "tex" | "bib" | "pdf" | "org" | "adoc") => "k-doc",
3148 Some("json" | "toml" | "yaml" | "yml" | "csv" | "tsv" | "xml" | "ini" | "lock") => "k-data",
3149 Some(
3150 "rs" | "jl" | "py" | "ts" | "js" | "mjs" | "sh" | "c" | "h" | "cpp" | "go" | "rb"
3151 | "lua" | "css" | "scss" | "lean" | "hs" | "java" | "kt" | "swift" | "sql",
3152 ) => "k-code",
3153 Some(
3154 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "mp4" | "webm"
3155 | "mov" | "mp3" | "wav",
3156 ) => "k-media",
3157 _ => "k-plain",
3158 }
3159}
3160
3161/// The lowercased extension, taken from the name.
3162fn extension_of(name: &str) -> Option<String> {
3163 let dot = name.rfind('.')?;
3164 // A leading dot is a hidden name rather than an extension, and a trailing one is not an
3165 // extension at all. Neither is served, but neither should be labelled as a type either.
3166 if dot == 0 || dot + 1 == name.len() {
3167 return None;
3168 }
3169 Some(name[dot + 1..].to_ascii_lowercase())
3170}
3171
3172/// Bytes, the way a file manager shows them.
3173///
3174/// Binary multiples with the labels that actually mean them. Calling 1024 bytes `kB` is the
3175/// lie everyone tells, and this is a tool for people who would notice.
3176fn human_size(n: u64) -> String {
3177 const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
3178 if n < 1024 {
3179 return format!("{n} B");
3180 }
3181 let mut v = n as f64 / 1024.0;
3182 let mut unit = 0;
3183 while v >= 1024.0 && unit + 1 < UNITS.len() {
3184 v /= 1024.0;
3185 unit += 1;
3186 }
3187 // One decimal below ten and none above, so a column of sizes stays a column:
3188 // `9.4 MiB` and `312 MiB`, not `312.0 MiB`.
3189 if v < 10.0 {
3190 format!("{v:.1} {}", UNITS[unit])
3191 } else {
3192 format!("{v:.0} {}", UNITS[unit])
3193 }
3194}
3195
3196/// `2026-09-13 05:44`, in UTC.
3197///
3198/// UTC because it is the only thing that can be said truthfully. SFTP reports seconds since
3199/// the epoch and says nothing about a zone; the remote's zone is not something this
3200/// transport can ask for, and using *this* machine's would stamp a file with an offset
3201/// belonging to a different computer. The column says so once, in the footer.
3202fn utc_stamp(secs: u32) -> String {
3203 let secs = i64::from(secs);
3204 let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
3205 let rest = secs.rem_euclid(86_400);
3206 let (hh, mm) = (rest / 3600, (rest % 3600) / 60);
3207 format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
3208}
3209
3210/// Howard Hinnant's `civil_from_days`: exact for every day this could be handed, and it
3211/// needs no calendar crate. Adding a dependency to print a date in a directory listing
3212/// would be a poor trade in a daemon that reads other people's filesystems.
3213fn civil_from_days(z: i64) -> (i64, u32, u32) {
3214 let z = z + 719_468;
3215 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
3216 let doe = (z - era * 146_097) as u64;
3217 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
3218 let y = yoe as i64 + era * 400;
3219 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3220 let mp = (5 * doy + 2) / 153;
3221 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
3222 let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
3223 (if m <= 2 { y + 1 } else { y }, m, d)
3224}
3225
3226/// The listing's layout.
3227///
3228/// Written entirely against the custom properties a theme supplies, so a new palette is a
3229/// new theme rather than a second copy of these rules. See `crate::theme`.
3230///
3231/// An editor's explorer: one line per entry, a twisty on the folders, an indent guide per
3232/// level, and a coloured chip for the type. souta asked for this twice — a flat list of one
3233/// directory is a listing, and what makes an explorer is the tree.
3234const LISTING_CSS: &str = "\
3235*{box-sizing:border-box}\
3236html{background:var(--bg)}\
3237body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;margin:0}\
3238header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
3239display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
3240header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
3241header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
3242font-size:11px;overflow-wrap:anywhere}\
3243#tree{padding:4px 0 40px}\
3244ul{list-style:none;margin:0;padding:0}\
3245li ul{border-left:1px solid var(--line);margin-left:15px}\
3246li>ul{display:none}\
3247li.open>ul{display:block}\
3248.row{align-items:center;color:inherit;display:grid;gap:6px;\
3249grid-template-columns:14px 14px 1fr auto auto;line-height:22px;padding-right:12px;\
3250text-decoration:none;white-space:nowrap}\
3251.row:hover{background:var(--hover)}\
3252.row.here{background:var(--sel)}\
3253.row.here .size,.row.here .when{color:var(--dim)}\
3254.row:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}\
3255.tw{color:var(--dim);font-size:11px;line-height:22px;text-align:center;\
3256transition:transform .1s linear}\
3257li.open>.row .tw{transform:rotate(90deg)}\
3258.ico{border-radius:2px;height:9px;justify-self:center;width:9px}\
3259.dir>.ico{background:var(--dim);border-radius:1px 3px 3px 3px}\
3260.site>.ico{background:var(--accent);border-radius:1px 3px 3px 3px}\
3261.site>.name{color:var(--accent)}\
3262.k-page>.ico{background:var(--k-page)}\
3263.k-page>.name{color:var(--k-page)}\
3264.k-doc>.ico{background:var(--k-doc)}\
3265.k-data>.ico{background:var(--k-data)}\
3266.k-code>.ico{background:var(--k-code)}\
3267.k-media>.ico{background:var(--k-media)}\
3268.k-plain>.ico{background:var(--k-plain)}\
3269.name{overflow:hidden;text-overflow:ellipsis}\
3270.size,.when{color:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
3271font-size:11px;font-variant-numeric:tabular-nums}\
3272.size{text-align:right}\
3273.row.busy .tw{opacity:.4}\
3274.row.failed .when{color:var(--k-page)}\
3275.empty{color:var(--faint);padding:10px 16px}\
3276@media(max-width:620px){.when{display:none}}";
3277
3278/// Expanding a folder, and nothing else.
3279///
3280/// The daemon's own page, so its script is the daemon's too — nothing is ever added to a
3281/// document the reader came for. It is small because the server renders the rows: this asks
3282/// for a level and puts it where it goes.
3283///
3284/// Without it every level costs a page load, and the tree still works that way: every row is
3285/// a real link to a real URL, so a browser with no script at all walks the tree one
3286/// directory at a time, exactly as the old listing did.
3287const LISTING_JS: &str = "\
3288const tree=document.getElementById('tree');\
3289tree.addEventListener('click',async e=>{\
3290const row=e.target.closest('a.row');\
3291if(!row||row.dataset.dir!=='1')return;\
3292e.preventDefault();\
3293const li=row.parentElement;\
3294if(li.querySelector(':scope>ul')){li.classList.toggle('open');mark(row);return;}\
3295row.classList.add('busy');\
3296try{\
3297const res=await fetch(row.getAttribute('href')+'?ls');\
3298if(!res.ok)throw new Error(res.status);\
3299li.insertAdjacentHTML('beforeend',await res.text());\
3300li.classList.add('open');mark(row);\
3301}catch(err){row.classList.add('failed');\
3302row.querySelector('.when').textContent='could not be listed: '+err.message;}\
3303finally{row.classList.remove('busy');}\
3304});\
3305function mark(row){\
3306for(const other of tree.querySelectorAll('a.row.here'))other.classList.remove('here');\
3307row.classList.add('here');\
3308history.replaceState(null,'',row.getAttribute('href'));\
3309document.querySelector('header span').textContent=\
3310decodeURIComponent(new URL(row.href).pathname);\
3311}";
3312
3313/// One level of the tree: a `<ul>` of rows, with the one on the path already expanded.
3314///
3315/// `open` is the rest of the path from here down, so a level knows which of its folders the
3316/// reader is inside. Empty means nothing below is expanded, which is what `?ls` hands back
3317/// for a folder somebody has just clicked.
3318fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec<Row>)]) {
3319 out.push_str("<ul>");
3320 for row in rows {
3321 let here = format!("{path}/{}", row.name);
3322 let deeper = open.first().filter(|(next, _)| *next == here);
3323
3324 out.push_str(if deeper.is_some() {
3325 "<li class=\"open\">"
3326 } else {
3327 "<li>"
3328 });
3329 out.push_str("<a class=\"row ");
3330 out.push_str(match (row.dir, row.site) {
3331 // A directory holding an `index.html` is served *as* that page, so it is marked
3332 // as somewhere to read rather than somewhere to look.
3333 (true, true) => "site",
3334 (true, false) => "dir",
3335 (false, _) => row.kind,
3336 });
3337 // The deepest expanded folder is where the reader is, so the tree opens with it
3338 // selected the way an explorer shows the file you have open.
3339 if deeper.is_some() && open.len() == 1 {
3340 out.push_str(" here");
3341 }
3342 out.push_str("\" href=\"");
3343 out.push_str(path);
3344 out.push('/');
3345 out.push_str(&url_escape(&row.name));
3346 if row.dir {
3347 out.push('/');
3348 }
3349 // Read by the script to tell a folder from a file without picking through classes.
3350 out.push_str(if row.dir {
3351 "\" data-dir=\"1\"><span class=\"tw\">\u{25b8}</span>"
3352 } else {
3353 "\"><span class=\"tw\"></span>"
3354 });
3355 out.push_str("<span class=\"ico\"></span><span class=\"name\">");
3356 out.push_str(&escape(&row.name));
3357 out.push_str("</span><span class=\"size\">");
3358 out.push_str(row.size.as_deref().unwrap_or(""));
3359 out.push_str("</span><span class=\"when\">");
3360 out.push_str(row.modified.as_deref().unwrap_or(""));
3361 out.push_str("</span></a>");
3362
3363 if let Some((next, rows)) = deeper {
3364 render_level(out, next, rows, &open[1..]);
3365 }
3366 out.push_str("</li>");
3367 }
3368 out.push_str("</ul>");
3369}
3370
3371/// A directory, as a tree.
3372///
3373/// `levels` runs from the alias base down to where the reader is, each already sorted, so
3374/// the page opens with the whole path expanded and the rest of every level beside it. They
3375/// come out of the cache the path walk already filled, so the depth costs no round trips.
3376fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) -> String {
3377 let shown = if rel.is_empty() { "/" } else { rel };
3378 let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
3379 s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
3380 s.push_str(&escape(&format!("{shown} \u{b7} {alias}")));
3381 s.push_str("</title><style>");
3382 // The palette first, then the layout that reads it.
3383 s.push_str(&theme::css_for(theme));
3384 s.push_str(LISTING_CSS);
3385 s.push_str("</style></head><body><header><b>");
3386 s.push_str(&escape(alias));
3387 s.push_str("</b><span>");
3388 s.push_str(&escape(shown));
3389 s.push_str("</span></header><div id=\"tree\">");
3390
3391 match levels.split_first() {
3392 Some(((path, rows), rest)) if !rows.is_empty() => render_level(&mut s, path, rows, rest),
3393 // An alias whose base holds nothing. Saying so beats a blank page, which reads as
3394 // something having gone wrong.
3395 _ => s.push_str("<p class=\"empty\">This directory is empty.</p>"),
3396 }
3397
3398 s.push_str("</div><script>");
3399 s.push_str(LISTING_JS);
3400 s.push_str("</script></body></html>");
3401 s
3402}
3403
3404/// Remote filenames are untrusted input that lands inside our own origin, so the
3405/// listing escapes them. Skipping this would be self-inflicted XSS.
3406fn escape(s: &str) -> String {
3407 s.replace('&', "&")
3408 .replace('<', "<")
3409 .replace('>', ">")
3410 .replace('"', """)
3411}
3412
3413/// HTML-escaping is not enough inside an href: a space or a hash in a filename
3414/// would still produce a broken or a wrong link.
3415fn url_escape(s: &str) -> String {
3416 let mut out = String::with_capacity(s.len());
3417 for b in s.bytes() {
3418 match b {
3419 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3420 out.push(b as char);
3421 }
3422 _ => out.push_str(&format!("%{b:02X}")),
3423 }
3424 }
3425 out
3426}
3427
3428#[cfg(test)]
3429mod tests {
3430 use super::*;
3431 use crate::sftp::wire::Attrs;
3432 use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
3433 use http_body_util::{BodyExt, Empty};
3434
3435 const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3436
3437 /// A real one, as Chrome derives them: thirty-two letters, none past `p`.
3438 const AN_EXTENSION: &str = "ndilofdepphikcodahklfjggbacbbhcc";
3439
3440 #[test]
3441 fn an_extension_names_itself() {
3442 assert_eq!(
3443 extension_id(Some(&format!("dashboard={AN_EXTENSION}"))).as_deref(),
3444 Some(AN_EXTENSION),
3445 );
3446 assert_eq!(
3447 extension_id(Some(&format!("x=1&dashboard={AN_EXTENSION}"))).as_deref(),
3448 Some(AN_EXTENSION),
3449 );
3450 }
3451
3452 #[test]
3453 fn nothing_named_is_nothing_stored() {
3454 assert_eq!(extension_id(None), None);
3455 assert_eq!(extension_id(Some("")), None);
3456 assert_eq!(extension_id(Some("dashboard=")), None);
3457 }
3458
3459 /// The letters `q` to `z`, digits, and capitals are not in the alphabet Chrome uses, so a
3460 /// string containing them is not an id. Kept as a test because the alphabet is the whole
3461 /// reason the value needs no escaping where it is used.
3462 #[test]
3463 fn a_string_outside_the_alphabet_is_not_an_id() {
3464 for bad in [
3465 "ndilofdepphikcodahklfjggbacbbhcz", // z
3466 "ndilofdepphikcodahklfjggbacbbhc1", // a digit
3467 "ndilofdepphikcodahklfjggbacbbhcC", // a capital
3468 ] {
3469 assert_eq!(
3470 extension_id(Some(&format!("dashboard={bad}"))),
3471 None,
3472 "{bad}"
3473 );
3474 }
3475 }
3476
3477 #[test]
3478 fn a_wrong_length_is_not_an_id() {
3479 for bad in [&AN_EXTENSION[..31], &format!("{AN_EXTENSION}a")[..]] {
3480 assert_eq!(
3481 extension_id(Some(&format!("dashboard={bad}"))),
3482 None,
3483 "{bad}"
3484 );
3485 }
3486 }
3487
3488 /// The point of the whole check. This lands inside a `<script>` on the daemon's own page,
3489 /// so anything that could close the string and keep going has to be refused rather than
3490 /// escaped -- escaping is a thing one can forget, and a fixed alphabet is not.
3491 #[test]
3492 fn nothing_that_could_end_the_script_gets_through() {
3493 for bad in [
3494 "a\");alert(1);//aaaaaaaaaaaaaaaa",
3495 "aaaaaaaaaaaaaaaa</script><script>",
3496 "../../../../../../../../../etc/p",
3497 ] {
3498 assert_eq!(
3499 extension_id(Some(&format!("dashboard={bad}"))),
3500 None,
3501 "{bad}"
3502 );
3503 }
3504 }
3505
3506 async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
3507 res.into_body()
3508 .collect()
3509 .await
3510 .expect("a Full body always collects")
3511 .to_bytes()
3512 }
3513
3514 /// A request arriving by address rather than through the PAC.
3515 fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
3516 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
3517 if let Some(t) = token {
3518 b = b.header(control::TOKEN_HEADER, t);
3519 }
3520 b.body(Empty::<Bytes>::new()).expect("request builds")
3521 }
3522
3523 /// A loopback request carrying no token, and whatever the browser would have said
3524 /// about who started it.
3525 fn from_site(path: &str, site: Option<&str>) -> Request<Empty<Bytes>> {
3526 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
3527 if let Some(site) = site {
3528 b = b.header(control::FETCH_SITE_HEADER, site);
3529 }
3530 b.body(Empty::<Bytes>::new()).expect("request builds")
3531 }
3532
3533 fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
3534 let mut b = Request::builder()
3535 .method(Method::POST)
3536 .uri(path)
3537 .header(HOST, "127.0.0.1:7391");
3538 if let Some(t) = token {
3539 b = b.header(control::TOKEN_HEADER, t);
3540 }
3541 b.body(Full::new(Bytes::from(body.to_string())))
3542 .expect("request builds")
3543 }
3544
3545 fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
3546 Request::builder()
3547 .uri(format!("http://docs.ssh-browser{path}"))
3548 .header(HOST, "docs.ssh-browser")
3549 .header(RANGE, range)
3550 .body(Empty::new())
3551 .expect("request builds")
3552 }
3553
3554 /// Build an origin over an in-memory remote. The session is a real `SftpFs`, so
3555 /// the round trips counted below are the same ones production would pay.
3556 async fn origin_with(remote: FakeRemote) -> Origin {
3557 origin_with_cache(remote, Cache::default()).await
3558 }
3559
3560 async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
3561 origin_over(remote.spawn().await, cache)
3562 }
3563
3564 /// The same origin, over a connection the caller made -- so it can also end it.
3565 fn origin_over(fs: SftpFs, cache: Cache) -> Origin {
3566 let mut sessions = HashMap::new();
3567 sessions.insert(
3568 "docs".to_string(),
3569 Arc::new(Session {
3570 host: "nowhere".to_string(),
3571 base: "/srv".to_string(),
3572 fs,
3573 }),
3574 );
3575 Origin {
3576 suffix: "ssh-browser".to_string(),
3577 // http, because these tests drive `handle` directly and never open a socket. An
3578 // https origin here would only change the URLs printed in a listing.
3579 scheme: "http".to_string(),
3580 certificates: None,
3581 tls: None,
3582 port: 7391,
3583 sessions: RwLock::new(sessions),
3584 cache,
3585 theme: RwLock::new(theme::DEFAULT.to_string()),
3586 token: Token::from_hex(TEST_TOKEN),
3587 // Empty on purpose: these tests build their session map directly, so nothing here
3588 // should be opening anything behind their backs.
3589 reachable: RwLock::new(reachable::Set::default()),
3590 handshakes: Handshakes::default(),
3591 dashboard: RwLock::new(None),
3592 // Declared as well as connected, because that is what production looks like and
3593 // because the reconnect path reads it: an alias whose session dies has to be
3594 // re-dialled from somewhere, and a test whose `docs` was connected but never
3595 // declared would exercise the half of `session_for` that cannot retry.
3596 declared: HashMap::from([(
3597 "docs".to_string(),
3598 Declared {
3599 host: "nowhere".to_string(),
3600 base: Some("/srv".to_string()),
3601 named: Named::InTheFile,
3602 dialling: tokio::sync::Mutex::new(()),
3603 },
3604 )]),
3605 trouble: RwLock::new(HashMap::new()),
3606 stopped: RwLock::new(HashSet::new()),
3607 cooldown: DIAL_COOLDOWN,
3608 }
3609 }
3610
3611 fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
3612 let mut b = Request::builder()
3613 .uri(format!("http://docs.ssh-browser{path}"))
3614 .header(HOST, "docs.ssh-browser");
3615 if let Some(tag) = if_none_match {
3616 b = b.header(IF_NONE_MATCH, tag);
3617 }
3618 b.body(Empty::new()).expect("request builds")
3619 }
3620
3621 fn get_on(alias: &str, path: &str) -> Request<Empty<Bytes>> {
3622 Request::builder()
3623 .uri(format!("http://{alias}.ssh-browser{path}"))
3624 .header(HOST, format!("{alias}.ssh-browser"))
3625 .body(Empty::new())
3626 .expect("request builds")
3627 }
3628
3629 async fn trips(origin: &Origin) -> u64 {
3630 origin
3631 .sessions
3632 .read()
3633 .await
3634 .values()
3635 .map(|s| s.fs.round_trips())
3636 .sum()
3637 }
3638
3639 fn one_page() -> FakeRemote {
3640 FakeRemote::new()
3641 .dir("/srv", vec![("a.html", file_attrs(5, 100))])
3642 .file("/srv/a.html", b"hello")
3643 }
3644
3645 /// A page with subresources in a sibling directory, which is the shape a generated
3646 /// report has: one HTML file and an `assets/` beside it.
3647 fn page_with_subresources(n: usize) -> FakeRemote {
3648 let mut html = String::from(
3649 "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
3650 );
3651 for i in 0..n {
3652 html.push_str(&format!("<img src=\"assets/{i}.png\">"));
3653 }
3654 html.push_str("</body></html>");
3655
3656 let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
3657 assets.extend((0..n).map(|i| format!("{i}.png")));
3658
3659 let mut remote = FakeRemote::new()
3660 .dir(
3661 "/srv",
3662 vec![
3663 ("index.html", file_attrs(html.len() as u64, 100)),
3664 ("assets", dir_attrs()),
3665 ],
3666 )
3667 .dir(
3668 "/srv/assets",
3669 assets
3670 .iter()
3671 .map(|name| (name.as_str(), file_attrs(3, 1)))
3672 .collect(),
3673 )
3674 .file("/srv/index.html", html.as_bytes());
3675 for name in &assets {
3676 remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
3677 }
3678 remote
3679 }
3680
3681 /// The subresource half of invariant 1, which is about the browser rather than the
3682 /// remote. HTTP/1.1 allows six connections per origin, so forty subresources are seven
3683 /// waves of requests and each wave the browser has to discover is a round trip.
3684 ///
3685 /// Asking for them one at a time is the worst case any browser can produce. If that
3686 /// costs nothing, no arrangement of waves can cost anything either.
3687 #[tokio::test]
3688 async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
3689 const N: usize = 40;
3690 let origin = origin_with(page_with_subresources(N)).await;
3691
3692 let res = origin.handle(get("/index.html", None)).await;
3693 assert_eq!(res.status(), StatusCode::OK);
3694
3695 let before = trips(&origin).await;
3696 for i in 0..N {
3697 let path = format!("/assets/{i}.png");
3698 let res = origin.handle(get(&path, None)).await;
3699 assert_eq!(res.status(), StatusCode::OK, "{path}");
3700 assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
3701 }
3702 for name in ["style.css", "app.js"] {
3703 let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
3704 assert_eq!(res.status(), StatusCode::OK, "{name}");
3705 }
3706
3707 assert_eq!(
3708 trips(&origin).await - before,
3709 0,
3710 "reading the page's own references is what makes these free"
3711 );
3712 }
3713
3714 /// And the page itself does not get more expensive as it gains subresources: the
3715 /// listings are one batch and the reads are another, whatever the count.
3716 #[tokio::test]
3717 async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
3718 async fn cost(n: usize) -> u64 {
3719 let origin = origin_with(page_with_subresources(n)).await;
3720 let before = trips(&origin).await;
3721 let res = origin.handle(get("/index.html", None)).await;
3722 assert_eq!(res.status(), StatusCode::OK);
3723 trips(&origin).await - before
3724 }
3725 assert_eq!(cost(4).await, cost(40).await);
3726 }
3727
3728 /// One HTML page naming whatever it likes, for the two tests below. The page is
3729 /// untrusted input, and prefetching is the first thing in this daemon that acts on what
3730 /// a page says rather than on what the reader asked for.
3731 fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
3732 let mut html = String::from("<!doctype html><html><body>");
3733 for r in refs {
3734 html.push_str(&format!("<img src=\"{r}\">"));
3735 }
3736 html.push_str("</body></html>");
3737
3738 let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
3739 entries.extend(extra);
3740 FakeRemote::new()
3741 .dir("/srv", entries)
3742 .file("/srv/index.html", html.as_bytes())
3743 }
3744
3745 async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
3746 let origin = origin_with(page_referring_to(refs, extra)).await;
3747 let before = trips(&origin).await;
3748 let res = origin.handle(get("/index.html", None)).await;
3749 assert_eq!(res.status(), StatusCode::OK);
3750 trips(&origin).await - before
3751 }
3752
3753 /// A reference that climbs out of the alias base must not be read. The check is the
3754 /// same `resolve` the request path uses, not a second copy that could drift from it.
3755 #[tokio::test]
3756 async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
3757 let baseline = cost_of_serving(&[], vec![]).await;
3758 assert_eq!(
3759 cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
3760 baseline,
3761 "an escaping reference is gone before anything is listed or read"
3762 );
3763 }
3764
3765 /// Nor through a symlink — and not even as far as listing it. A page that could get the
3766 /// directory a symlink points at listed would have defeated the rule by naming it.
3767 #[tokio::test]
3768 async fn a_page_cannot_prefetch_through_a_symlink() {
3769 let link = || vec![("link", symlink_attrs())];
3770 let baseline = cost_of_serving(&[], link()).await;
3771 assert_eq!(
3772 cost_of_serving(&["link/inside.png"], link()).await,
3773 baseline,
3774 "the symlink is known from the listing the page itself needed"
3775 );
3776
3777 // And the ordinary request for it is still refused, which is the guarantee the
3778 // prefetcher is being held to rather than a separate one.
3779 let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
3780 assert_eq!(
3781 origin.handle(get("/index.html", None)).await.status(),
3782 StatusCode::OK
3783 );
3784 assert_eq!(
3785 origin.handle(get("/link/inside.png", None)).await.status(),
3786 StatusCode::FORBIDDEN
3787 );
3788 }
3789
3790 /// The hole the shallow symlink test did not cover: a symlink one level below the
3791 /// deepest listing the cache holds.
3792 ///
3793 /// `first_symlink` can only see what is cached, so at the moment the batch is assembled
3794 /// it has no opinion about `assets/link` — and the batch that would tell it includes the
3795 /// symlink's own path. SFTP v3 `OPENDIR` has no `O_NOFOLLOW`, so the remote resolves it
3796 /// and hands back a listing of wherever it points. Nothing is ever served through it,
3797 /// but the daemon has already read it, which is the act the alias base exists to forbid.
3798 ///
3799 /// Round trips cannot detect this — `list_dirs` is one flush however many directories
3800 /// are in it — so the assertion is on what the cache ends up holding.
3801 #[tokio::test]
3802 async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
3803 let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
3804 let origin = origin_with(
3805 FakeRemote::new()
3806 .dir(
3807 "/srv",
3808 vec![
3809 ("index.html", file_attrs(html.len() as u64, 100)),
3810 ("assets", dir_attrs()),
3811 ],
3812 )
3813 .dir("/srv/assets", vec![("link", symlink_attrs())])
3814 // What the remote returns once it has followed the symlink for us.
3815 .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
3816 .file("/srv/index.html", html.as_bytes())
3817 .file("/srv/assets/link/secret.txt", b"elsewhere"),
3818 )
3819 .await;
3820
3821 assert_eq!(
3822 origin.handle(get("/index.html", None)).await.status(),
3823 StatusCode::OK
3824 );
3825 assert!(
3826 !origin.cache.has_listing("/srv/assets/link"),
3827 "the daemon listed the directory a symlink points at"
3828 );
3829
3830 // And the ordinary request for it is still refused, so closing the prefetch route
3831 // did not quietly become the only thing stopping it.
3832 assert_eq!(
3833 origin
3834 .handle(get("/assets/link/secret.txt", None))
3835 .await
3836 .status(),
3837 StatusCode::FORBIDDEN
3838 );
3839 }
3840
3841 /// The other half: a reference one level down is still prefetched, because the listing
3842 /// the page's own request already fetched proves that step is a real directory. Closing
3843 /// the hole above must not turn prefetching off for the ordinary `assets/` layout.
3844 #[tokio::test]
3845 async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
3846 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
3847 let origin = origin_with(
3848 FakeRemote::new()
3849 .dir(
3850 "/srv",
3851 vec![
3852 ("index.html", file_attrs(html.len() as u64, 100)),
3853 ("assets", dir_attrs()),
3854 ],
3855 )
3856 .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
3857 .file("/srv/index.html", html.as_bytes())
3858 .file("/srv/assets/x.png", b"xxx"),
3859 )
3860 .await;
3861
3862 assert_eq!(
3863 origin.handle(get("/index.html", None)).await.status(),
3864 StatusCode::OK
3865 );
3866 let before = trips(&origin).await;
3867 let res = origin.handle(get("/assets/x.png", None)).await;
3868 assert_eq!(res.status(), StatusCode::OK);
3869 assert_eq!(&body_of(res).await[..], b"xxx");
3870 assert_eq!(
3871 trips(&origin).await - before,
3872 0,
3873 "a subdirectory one level down must still be warmed"
3874 );
3875 }
3876
3877 /// A subresource larger than one read chunk costs the same as a small one.
3878 ///
3879 /// This is what `read_ranges` buys over `read_batch` here: a read whose length is known
3880 /// can have all its chunks issued together, and a read whose length is not has to poll.
3881 /// Before, a bundle of any real size cost one round trip per 32 KiB — invisible to every
3882 /// other test, because they all use three-byte fixtures.
3883 #[tokio::test]
3884 async fn a_large_subresource_costs_what_a_small_one_costs() {
3885 async fn cost(bytes: usize) -> u64 {
3886 let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
3887 let origin = origin_with(
3888 FakeRemote::new()
3889 .dir(
3890 "/srv",
3891 vec![
3892 ("index.html", file_attrs(html.len() as u64, 100)),
3893 ("assets", dir_attrs()),
3894 ],
3895 )
3896 .dir(
3897 "/srv/assets",
3898 vec![("big.bin", file_attrs(bytes as u64, 1))],
3899 )
3900 .file("/srv/index.html", html.as_bytes())
3901 .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
3902 )
3903 .await;
3904
3905 let before = trips(&origin).await;
3906 assert_eq!(
3907 origin.handle(get("/index.html", None)).await.status(),
3908 StatusCode::OK
3909 );
3910 let spent = trips(&origin).await - before;
3911
3912 // And it really was warmed, so the comparison is between two prefetches rather
3913 // than between a prefetch and a skip.
3914 let at = trips(&origin).await;
3915 let res = origin.handle(get("/assets/big.bin", None)).await;
3916 assert_eq!(res.status(), StatusCode::OK);
3917 assert_eq!(body_of(res).await.len(), bytes);
3918 assert_eq!(
3919 trips(&origin).await - at,
3920 0,
3921 "{bytes} bytes should have been held"
3922 );
3923
3924 spent
3925 }
3926
3927 // Either side of the 32 KiB chunk, and well past it.
3928 assert_eq!(cost(1024).await, cost(200 * 1024).await);
3929 }
3930
3931 /// And so does a file asked for directly, which is the one a reader waits on.
3932 ///
3933 /// The test above covers the prefetcher. The direct path had the same defect and no test,
3934 /// and it is the path that matters more: the files that reach it are exactly the ones a
3935 /// prefetch could not have warmed — the page itself, which has to be read before it can
3936 /// be scanned, and anything a script fetches at runtime.
3937 ///
3938 /// Found by pointing `e2e/probe.mjs` at real Documenter output, where a 700 KB
3939 /// `index.html` and a 2 MB `search_index.js` between them took the page to 95 remote
3940 /// round trips, and 26 once this was fixed. No unit test here could have found it,
3941 /// because every fixture in this file is three bytes long.
3942 #[tokio::test]
3943 async fn a_large_file_asked_for_directly_costs_what_a_small_one_costs() {
3944 async fn cost(bytes: usize) -> u64 {
3945 let origin = origin_with(
3946 FakeRemote::new()
3947 .dir("/srv", vec![("big.bin", file_attrs(bytes as u64, 1))])
3948 .file("/srv/big.bin", &vec![b'x'; bytes]),
3949 )
3950 .await;
3951
3952 let before = trips(&origin).await;
3953 let res = origin.handle(get("/big.bin", None)).await;
3954 assert_eq!(res.status(), StatusCode::OK);
3955 // Every byte, not merely a successful status: a range read that stopped early
3956 // would otherwise pass this as a cheap request.
3957 assert_eq!(body_of(res).await.len(), bytes);
3958 trips(&origin).await - before
3959 }
3960
3961 assert_eq!(cost(1024).await, cost(500 * 1024).await);
3962 }
3963
3964 /// A listing that understates a file's length must not turn into an empty `200`.
3965 ///
3966 /// The prefetch reads a range, and a range is exactly as long as it was told to be. A
3967 /// listing reporting zero bytes for a file that has some would therefore cache an empty
3968 /// body — and the reader would be served it, because the cache is consulted first. This
3969 /// is the failure mode `CONTRIBUTING.md` names, arriving through a new door.
3970 ///
3971 /// Caught by the fake reporting a size of zero where a size was not set, which is what a
3972 /// real listing does when it is wrong rather than silent.
3973 #[tokio::test]
3974 async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
3975 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
3976 let sizeless = Attrs {
3977 permissions: Some(0o100644),
3978 mtime: Some(1),
3979 ..Attrs::default()
3980 };
3981 let origin = origin_with(
3982 FakeRemote::new()
3983 .dir(
3984 "/srv",
3985 vec![
3986 ("index.html", file_attrs(html.len() as u64, 100)),
3987 ("assets", dir_attrs()),
3988 ],
3989 )
3990 .dir("/srv/assets", vec![("x.png", sizeless)])
3991 .file("/srv/index.html", html.as_bytes())
3992 .file("/srv/assets/x.png", b"xxx"),
3993 )
3994 .await;
3995
3996 assert_eq!(
3997 origin.handle(get("/index.html", None)).await.status(),
3998 StatusCode::OK
3999 );
4000 let res = origin.handle(get("/assets/x.png", None)).await;
4001 assert_eq!(res.status(), StatusCode::OK);
4002 assert_eq!(
4003 &body_of(res).await[..],
4004 b"xxx",
4005 "the real request must still serve the whole file"
4006 );
4007 }
4008
4009 /// A subresource over the hold-whole limit is skipped rather than read and discarded.
4010 #[tokio::test]
4011 async fn an_oversized_subresource_is_not_prefetched() {
4012 async fn cost(size: u64) -> u64 {
4013 let html =
4014 "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
4015 let origin = origin_with(
4016 FakeRemote::new()
4017 .dir(
4018 "/srv",
4019 vec![
4020 ("index.html", file_attrs(html.len() as u64, 100)),
4021 ("assets", dir_attrs()),
4022 ],
4023 )
4024 .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
4025 .file("/srv/index.html", html.as_bytes())
4026 .file("/srv/assets/film.mp4", b"xxx"),
4027 )
4028 .await;
4029 let before = trips(&origin).await;
4030 assert_eq!(
4031 origin.handle(get("/index.html", None)).await.status(),
4032 StatusCode::OK
4033 );
4034 trips(&origin).await - before
4035 }
4036
4037 // The listing is fetched either way; only the read differs. A film the cache would
4038 // decline must not be pulled across the network first to find that out.
4039 let read_it = cost(3).await;
4040 let skipped = cost(CACHE_WHOLE_MAX + 1).await;
4041 assert!(
4042 skipped < read_it,
4043 "an oversized subresource cost {skipped} against {read_it} for a small one"
4044 );
4045 }
4046
4047 /// The port is taken before any host is connected.
4048 ///
4049 /// This ordering is the whole of what a previous change set out to fix, and nothing
4050 /// tested it: every other test here builds an `Origin` directly and never goes through
4051 /// `bind` at all. A regression that put the ssh handshakes first would pass the entire
4052 /// suite, and would cost a full set of connections before reporting the one failure an
4053 /// operator can actually act on.
4054 ///
4055 /// Cheap to check without any ssh infrastructure, precisely because the port failing
4056 /// first means the host is never reached: the error naming the bind and *not* naming the
4057 /// host is the evidence.
4058 #[tokio::test]
4059 async fn the_port_is_taken_before_any_host_is_connected() {
4060 let held = TcpListener::bind(("127.0.0.1", 0))
4061 .await
4062 .expect("a free port");
4063 let port = held.local_addr().expect("its address").port();
4064
4065 const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
4066 let result = Origin::bind(
4067 vec![Alias::new("docs", NOWHERE, Some("/srv")).expect("a valid alias")],
4068 reachable::Set::default(),
4069 "ssh-browser".to_string(),
4070 "http".to_string(),
4071 port,
4072 Token::from_hex(TEST_TOKEN),
4073 theme::DEFAULT.to_string(),
4074 )
4075 .await;
4076
4077 let Err(e) = result else {
4078 panic!("binding a port that is already held must fail");
4079 };
4080 let text = format!("{e:#}");
4081 assert!(
4082 text.contains(&format!("bind 127.0.0.1:{port}")),
4083 "the error should name the port, got: {text}"
4084 );
4085 assert!(
4086 !text.contains(NOWHERE),
4087 "the ssh host was reached before the port was taken: {text}"
4088 );
4089 }
4090
4091 /// What makes a home directory a reasonable base: the things worth stealing there are
4092 /// behind a dot, and a dot is refused at any depth.
4093 #[tokio::test]
4094 async fn a_dot_name_is_never_served() {
4095 let origin = origin_with(
4096 FakeRemote::new()
4097 .dir(
4098 "/srv",
4099 vec![
4100 ("Vault", dir_attrs()),
4101 (".ssh", dir_attrs()),
4102 (".netrc", file_attrs(9, 1)),
4103 ],
4104 )
4105 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
4106 .dir("/srv/Vault", vec![(".git", dir_attrs())])
4107 .dir("/srv/Vault/.git", vec![("config", file_attrs(9, 1))])
4108 .file("/srv/.ssh/id_ed25519", b"a-secret-")
4109 .file("/srv/.netrc", b"a-secret-")
4110 .file("/srv/Vault/.git/config", b"a-secret-"),
4111 )
4112 .await;
4113
4114 for path in [
4115 "/.ssh/id_ed25519",
4116 "/.netrc",
4117 // At depth, and behind a directory that is itself perfectly ordinary.
4118 "/Vault/.git/config",
4119 // The directory itself, not only what is under it.
4120 "/.ssh/",
4121 ] {
4122 assert_eq!(
4123 origin.handle(get(path, None)).await.status(),
4124 StatusCode::FORBIDDEN,
4125 "{path}"
4126 );
4127 }
4128 }
4129
4130 /// And they are not advertised either. Listing what the next click would refuse is worse
4131 /// than not listing it.
4132 #[tokio::test]
4133 async fn a_listing_does_not_mention_dot_names() {
4134 let origin = origin_with(FakeRemote::new().dir(
4135 "/srv",
4136 vec![
4137 ("Vault", dir_attrs()),
4138 (".ssh", dir_attrs()),
4139 (".obsidian", dir_attrs()),
4140 ],
4141 ))
4142 .await;
4143
4144 let body = body_of(origin.handle(get("/", None)).await).await;
4145 let listing = String::from_utf8_lossy(&body);
4146 assert!(listing.contains("Vault"), "the ordinary entry is listed");
4147 assert!(!listing.contains(".ssh"), "got: {listing}");
4148 assert!(!listing.contains(".obsidian"), "got: {listing}");
4149 }
4150
4151 /// The prefetcher must not become the way around it. A page is untrusted input, and this
4152 /// is the one part of the daemon that acts on what a page says.
4153 #[tokio::test]
4154 async fn a_page_cannot_prefetch_a_dot_name() {
4155 let html = "<!doctype html><html><body><img src=\".ssh/id_ed25519\"></body></html>";
4156 let origin = origin_with(
4157 FakeRemote::new()
4158 .dir(
4159 "/srv",
4160 vec![
4161 ("index.html", file_attrs(html.len() as u64, 100)),
4162 (".ssh", dir_attrs()),
4163 ],
4164 )
4165 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
4166 .file("/srv/index.html", html.as_bytes())
4167 .file("/srv/.ssh/id_ed25519", b"a-secret-"),
4168 )
4169 .await;
4170
4171 assert_eq!(
4172 origin.handle(get("/index.html", None)).await.status(),
4173 StatusCode::OK
4174 );
4175 assert!(
4176 !origin.cache.has_listing("/srv/.ssh"),
4177 "the page got the daemon to list a directory it will not serve"
4178 );
4179 assert_eq!(
4180 origin.handle(get("/.ssh/id_ed25519", None)).await.status(),
4181 StatusCode::FORBIDDEN
4182 );
4183 }
4184
4185 /// The same single file, four directories down.
4186 fn deep_tree() -> FakeRemote {
4187 FakeRemote::new()
4188 .dir("/srv", vec![("a", dir_attrs())])
4189 .dir("/srv/a", vec![("b", dir_attrs())])
4190 .dir("/srv/a/b", vec![("c", dir_attrs())])
4191 .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
4192 .file("/srv/a/b/c/d.html", b"deep!")
4193 }
4194
4195 fn entry(name: &str, dir: bool) -> Entry {
4196 Entry {
4197 name: name.to_string(),
4198 attrs: Attrs {
4199 permissions: Some(if dir { 0o040755 } else { 0o100644 }),
4200 ..Attrs::default()
4201 },
4202 }
4203 }
4204
4205 /// One directory as a tree with nothing above it, which is every test that is not about
4206 /// the ancestors or the site scan. Both of those need a remote; these do not.
4207 fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
4208 let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
4209 autoindex(alias, rel, &levels, theme::DEFAULT)
4210 }
4211
4212 #[test]
4213 fn a_hostile_filename_cannot_inject_script_into_our_origin() {
4214 let page = listing("docs", "", &[entry("<script>alert(1)</script>", false)]);
4215 assert!(!page.contains("<script>alert"));
4216 assert!(page.contains("<script>"));
4217 }
4218
4219 /// Directories first and no headings, which is souta's call. Within the files the HTML
4220 /// rises, which is the other half of what they asked for.
4221 #[test]
4222 fn directories_come_first_and_pages_lead_the_files() {
4223 let page = listing(
4224 "docs",
4225 "",
4226 &[
4227 entry("b.txt", false),
4228 entry("z-dir", true),
4229 entry("a.txt", false),
4230 entry("report.html", false),
4231 ],
4232 );
4233 let dir = page.find("z-dir").expect("dir listed");
4234 let html = page.find("report.html").expect("page listed");
4235 let a = page.find("a.txt").expect("a listed");
4236 let b = page.find("b.txt").expect("b listed");
4237 assert!(
4238 dir < html,
4239 "directories come first, whatever they are called"
4240 );
4241 assert!(html < a, "then the pages, ahead of the other files");
4242 assert!(a < b, "and the rest by name");
4243 // No headings at all. They are what souta called 「みずらい」.
4244 assert!(!page.contains("<h2"), "{page}");
4245 }
4246
4247 /// A page is decided by what the name *is*, so a file whose extension merely contains
4248 /// `html` is an ordinary file. `.htm` is the one other spelling worth accepting.
4249 #[test]
4250 fn only_html_counts_as_a_page() {
4251 let page = listing(
4252 "docs",
4253 "",
4254 &[
4255 entry("a.htm", false),
4256 entry("b.html.bak", false),
4257 entry("c.xhtml", false),
4258 ],
4259 );
4260 let htm = page.find("a.htm").expect("htm listed");
4261 let bak = page.find("b.html.bak").expect("bak listed");
4262 let xhtml = page.find("c.xhtml").expect("xhtml listed");
4263 assert!(htm < bak && htm < xhtml, "only the .htm leads: {page}");
4264 // And it is coloured as one, which is the only signal left now that the headings
4265 // are gone.
4266 assert!(
4267 page.contains("class=\"row k-page\" href=\"/a.htm\""),
4268 "{page}"
4269 );
4270 }
4271
4272 #[test]
4273 fn hrefs_are_url_escaped() {
4274 let page = listing("docs", "", &[entry("a b#c.html", false)]);
4275 assert!(page.contains("href=\"/a%20b%23c.html\""));
4276 }
4277
4278 /// The header says where you are. An explorer does not make you read the address bar
4279 /// to know which folder you are looking at.
4280 #[test]
4281 fn the_header_names_the_alias_and_where_you_are() {
4282 let page = listing("panza", "/Vault/infra", &[]);
4283 assert!(page.contains("<b>panza</b>"), "{page}");
4284 assert!(page.contains("<span>/Vault/infra</span>"), "{page}");
4285 }
4286
4287 /// The point of a tree rather than a listing: every level of the path is open at once,
4288 /// with the rest of each level beside it, and the deepest is the one selected.
4289 ///
4290 /// It costs no round trips beyond the listing it replaces, because the walk that
4291 /// resolved the path warmed every ancestor to check it for symlinks — see
4292 /// `the_tree_costs_what_one_directory_cost`.
4293 #[tokio::test]
4294 async fn the_whole_path_is_expanded_and_the_deepest_is_selected() {
4295 let origin = origin_with(
4296 FakeRemote::new()
4297 .dir("/srv", vec![("a", dir_attrs()), ("elsewhere", dir_attrs())])
4298 .dir("/srv/a", vec![("b", dir_attrs()), ("sibling", dir_attrs())])
4299 .dir("/srv/a/b", vec![("leaf.txt", file_attrs(3, 1))])
4300 .dir("/srv/elsewhere", vec![])
4301 .dir("/srv/a/sibling", vec![]),
4302 )
4303 .await;
4304
4305 let body = String::from_utf8(
4306 body_of(origin.handle(get("/a/b/", None)).await)
4307 .await
4308 .to_vec(),
4309 )
4310 .expect("utf-8");
4311
4312 // Both levels of the path are open...
4313 assert!(body.contains("<li class=\"open\">"), "{body}");
4314 assert!(body.contains("href=\"/a/\""), "{body}");
4315 // ...the deepest is the one marked as where the reader is...
4316 assert!(body.contains("row dir here\" href=\"/a/b/\""), "{body}");
4317 // ...what is inside it is rendered...
4318 assert!(body.contains("leaf.txt"), "{body}");
4319 // ...and so is everything beside it on the way down, which is what makes this a
4320 // tree rather than one directory at a time.
4321 assert!(body.contains("elsewhere"), "{body}");
4322 assert!(body.contains("sibling"), "{body}");
4323 }
4324
4325 /// The tree is four levels of listing, and it must cost what one level cost. Every
4326 /// ancestor was already fetched to check it for symlinks, so showing them is free; a
4327 /// version that went and asked again would pay for the depth twice.
4328 #[tokio::test]
4329 async fn the_tree_costs_what_one_directory_cost() {
4330 let deep = origin_with(deep_tree()).await;
4331 let before = trips(&deep).await;
4332 assert_eq!(
4333 deep.handle(get("/a/b/c/", None)).await.status(),
4334 StatusCode::OK
4335 );
4336 let four = trips(&deep).await - before;
4337
4338 let shallow = origin_with(one_page()).await;
4339 let before = trips(&shallow).await;
4340 assert_eq!(
4341 shallow.handle(get("/", None)).await.status(),
4342 StatusCode::OK
4343 );
4344 let one = trips(&shallow).await - before;
4345
4346 // The slack absorbs a flush of fire-and-forget CLOSE requests landing on either
4347 // side of the measurement. Asking per level would cost about four times as many.
4348 assert!(
4349 four <= one + 2,
4350 "a tree four deep cost {four} round trips against {one} for one directory"
4351 );
4352 }
4353
4354 /// What the script asks for when a folder is expanded: the same level, as the fragment
4355 /// that goes inside it. One renderer, so the two cannot disagree about what a row is.
4356 #[tokio::test]
4357 async fn asking_for_one_level_answers_with_its_rows() {
4358 let origin = origin_with(
4359 FakeRemote::new()
4360 .dir("/srv", vec![("sub", dir_attrs())])
4361 .dir("/srv/sub", vec![("inner.md", file_attrs(4, 1))]),
4362 )
4363 .await;
4364
4365 let req = Request::builder()
4366 .uri("http://docs.ssh-browser/sub/?ls")
4367 .header(HOST, "docs.ssh-browser")
4368 .body(Empty::<Bytes>::new())
4369 .expect("request builds");
4370 let res = origin.handle(req).await;
4371 assert_eq!(res.status(), StatusCode::OK);
4372
4373 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4374 // A fragment, so it can be inserted where it belongs rather than replacing a page.
4375 assert!(body.starts_with("<ul>"), "{body}");
4376 assert!(!body.contains("<html"), "{body}");
4377 // Built against the level it was asked about, so the href works from anywhere.
4378 assert!(body.contains("href=\"/sub/inner.md\""), "{body}");
4379 }
4380
4381 /// It adds no capability. Everything `?ls` says is already in the page it belongs to,
4382 /// and a dot-name is refused here exactly as it is everywhere else.
4383 #[tokio::test]
4384 async fn asking_for_one_level_does_not_mention_dot_names() {
4385 let origin = origin_with(
4386 FakeRemote::new()
4387 .dir("/srv", vec![("sub", dir_attrs())])
4388 .dir(
4389 "/srv/sub",
4390 vec![("shown.md", file_attrs(4, 1)), (".hidden", dir_attrs())],
4391 ),
4392 )
4393 .await;
4394
4395 let req = Request::builder()
4396 .uri("http://docs.ssh-browser/sub/?ls")
4397 .header(HOST, "docs.ssh-browser")
4398 .body(Empty::<Bytes>::new())
4399 .expect("request builds");
4400 let body =
4401 String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
4402 assert!(body.contains("shown.md"), "{body}");
4403 assert!(!body.contains(".hidden"), "{body}");
4404 }
4405
4406 #[test]
4407 fn sizes_read_the_way_a_file_manager_shows_them() {
4408 assert_eq!(human_size(0), "0 B");
4409 assert_eq!(human_size(999), "999 B");
4410 assert_eq!(human_size(1024), "1.0 KiB");
4411 assert_eq!(human_size(1536), "1.5 KiB");
4412 // One decimal below ten and none above, so a column of them stays a column.
4413 assert_eq!(human_size(10 * 1024 * 1024), "10 MiB");
4414 assert_eq!(human_size(9_961_472), "9.5 MiB");
4415 assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
4416 }
4417
4418 /// Checked against dates that are known independently of the algorithm, including the
4419 /// epoch itself and a leap day, which is where a calendar implementation goes wrong.
4420 #[test]
4421 fn timestamps_are_the_utc_civil_date() {
4422 assert_eq!(utc_stamp(0), "1970-01-01 00:00");
4423 assert_eq!(utc_stamp(86_399), "1970-01-01 23:59");
4424 assert_eq!(utc_stamp(86_400), "1970-01-02 00:00");
4425 // 2000-02-29, a leap day in a century year that is a leap year.
4426 assert_eq!(utc_stamp(951_782_400), "2000-02-29 00:00");
4427 // 2100 is divisible by 4 and by 100 but not by 400, so it is *not* a leap year and
4428 // the day after 2100-02-28 is 2100-03-01. Getting this wrong is the classic way a
4429 // hand-rolled calendar fails, and the two constants below are one day apart.
4430 assert_eq!(utc_stamp(4_107_456_000), "2100-02-28 00:00");
4431 assert_eq!(utc_stamp(4_107_542_400), "2100-03-01 00:00");
4432 assert_eq!(utc_stamp(1_757_745_840), "2025-09-13 06:44");
4433 }
4434
4435 /// A listing of nothing says so. An empty page with a heading over it reads as a
4436 /// failure rather than as an empty directory.
4437 #[test]
4438 fn an_empty_directory_says_it_is_empty() {
4439 let page = listing("docs", "/nothing", &[]);
4440 assert!(page.contains("This directory is empty"), "{page}");
4441 }
4442
4443 #[test]
4444 fn the_component_chain_walks_from_the_base_down() {
4445 assert_eq!(
4446 components("/srv", "/srv/a/b/c.html"),
4447 vec![
4448 ("/srv".to_string(), "a".to_string()),
4449 ("/srv/a".to_string(), "b".to_string()),
4450 ("/srv/a/b".to_string(), "c.html".to_string()),
4451 ]
4452 );
4453 assert_eq!(
4454 components("/srv", "/srv/index.html"),
4455 vec![("/srv".to_string(), "index.html".to_string())]
4456 );
4457 // A trailing slash on the base must not produce an empty first component.
4458 assert_eq!(
4459 components("/srv/", "/srv/a.html"),
4460 vec![("/srv".to_string(), "a.html".to_string())]
4461 );
4462 // The file *is* the base: nothing between them to check.
4463 assert!(components("/srv", "/srv").is_empty());
4464 }
4465
4466 /// Invariant 2. The listing and the body are both held, so the second request
4467 /// has nothing left to ask the remote.
4468 #[tokio::test]
4469 async fn a_revisit_costs_no_remote_round_trips() {
4470 let origin = origin_with(one_page()).await;
4471
4472 let first = origin.handle(get("/a.html", None)).await;
4473 assert_eq!(first.status(), StatusCode::OK);
4474 let after_first = trips(&origin).await;
4475 assert!(after_first > 0, "the first request has to fetch something");
4476
4477 let second = origin.handle(get("/a.html", None)).await;
4478 assert_eq!(second.status(), StatusCode::OK);
4479 assert_eq!(
4480 trips(&origin).await,
4481 after_first,
4482 "a revisit must be answered entirely from cache"
4483 );
4484 }
4485
4486 /// Invariant 2 through the browser's own validator: the ETag came from the
4487 /// cached listing, so the 304 is decided inside this process.
4488 #[tokio::test]
4489 async fn a_conditional_get_is_answered_without_the_remote() {
4490 let origin = origin_with(one_page()).await;
4491
4492 let first = origin.handle(get("/a.html", None)).await;
4493 let tag = first
4494 .headers()
4495 .get(ETAG)
4496 .expect("a validator is offered")
4497 .to_str()
4498 .expect("ascii")
4499 .to_string();
4500 let after_first = trips(&origin).await;
4501
4502 let second = origin.handle(get("/a.html", Some(&tag))).await;
4503 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
4504 assert_eq!(
4505 trips(&origin).await,
4506 after_first,
4507 "a 304 must not touch the remote"
4508 );
4509 }
4510
4511 /// A name the listing does not contain needs no fetch to answer.
4512 #[tokio::test]
4513 async fn a_missing_file_is_a_404_from_the_cached_listing() {
4514 let origin = origin_with(one_page()).await;
4515
4516 // Warm the listing.
4517 origin.handle(get("/a.html", None)).await;
4518 let warm = trips(&origin).await;
4519
4520 let missing = origin.handle(get("/nope.html", None)).await;
4521 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
4522 assert_eq!(
4523 trips(&origin).await,
4524 warm,
4525 "a 404 for a listed-but-absent name must cost nothing"
4526 );
4527 }
4528
4529 /// The guard SECURITY.md promises, decided from the listing rather than from a
4530 /// REALPATH per request.
4531 #[tokio::test]
4532 async fn a_symlink_is_refused() {
4533 let origin = origin_with(
4534 FakeRemote::new()
4535 .dir("/srv", vec![("link.html", symlink_attrs())])
4536 .file("/srv/link.html", b"whatever the target is"),
4537 )
4538 .await;
4539
4540 let res = origin.handle(get("/link.html", None)).await;
4541 assert_eq!(res.status(), StatusCode::FORBIDDEN);
4542 }
4543
4544 /// The listing knows it is a directory, so this costs no failed open first.
4545 #[tokio::test]
4546 async fn a_directory_without_a_trailing_slash_redirects() {
4547 let origin = origin_with(
4548 FakeRemote::new()
4549 .dir("/srv", vec![("sub", dir_attrs())])
4550 .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
4551 )
4552 .await;
4553
4554 let res = origin.handle(get("/sub", None)).await;
4555 assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
4556 assert_eq!(
4557 res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
4558 Some("/sub/")
4559 );
4560 }
4561
4562 /// A listing that promises a file the remote then refuses must not be kept, or
4563 /// the same wrong answer is served for a whole TTL.
4564 #[tokio::test]
4565 async fn a_listing_proven_wrong_is_forgotten() {
4566 // Listed, but no body declared: the open fails.
4567 let origin =
4568 origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
4569 .await;
4570
4571 let res = origin.handle(get("/ghost.html", None)).await;
4572 assert_eq!(res.status(), StatusCode::NOT_FOUND);
4573 assert!(
4574 !origin.cache.has_listing("/srv"),
4575 "a listing contradicted by the remote must be dropped"
4576 );
4577 }
4578
4579 /// A directory with no index.html is listed rather than 404'd.
4580 #[tokio::test]
4581 async fn a_directory_without_an_index_is_listed() {
4582 let origin =
4583 origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
4584
4585 let res = origin.handle(get("/", None)).await;
4586 assert_eq!(res.status(), StatusCode::OK);
4587 assert_eq!(
4588 res.headers()
4589 .get(CONTENT_TYPE)
4590 .and_then(|v| v.to_str().ok()),
4591 Some("text/html; charset=utf-8")
4592 );
4593 }
4594
4595 /// The hole SECURITY.md used to describe. `/link/inside.html` names a file that
4596 /// exists and is not itself a symlink, but every route to it passes through one.
4597 #[tokio::test]
4598 async fn a_symlinked_directory_higher_up_the_path_is_refused() {
4599 let origin = origin_with(
4600 FakeRemote::new()
4601 .dir("/srv", vec![("link", symlink_attrs())])
4602 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
4603 .file("/srv/link/inside.html", b"hi"),
4604 )
4605 .await;
4606
4607 let res = origin.handle(get("/link/inside.html", None)).await;
4608 assert_eq!(res.status(), StatusCode::FORBIDDEN);
4609 }
4610
4611 /// Depth must not buy itself round trips. Every ancestor listing is issued
4612 /// together, so a path four deep costs what a path one deep costs.
4613 #[tokio::test]
4614 async fn a_deep_path_costs_what_a_shallow_one_costs() {
4615 let deep = origin_with(deep_tree()).await;
4616 assert_eq!(
4617 deep.handle(get("/a/b/c/d.html", None)).await.status(),
4618 StatusCode::OK
4619 );
4620
4621 let shallow = origin_with(one_page()).await;
4622 assert_eq!(
4623 shallow.handle(get("/a.html", None)).await.status(),
4624 StatusCode::OK
4625 );
4626
4627 let (d, sh) = (trips(&deep).await, trips(&shallow).await);
4628 // The slack absorbs one flush of fire-and-forget CLOSE requests landing on
4629 // either side of the measurement. A walk that listed one ancestor at a time
4630 // would cost about three times as many at this depth, and worse deeper.
4631 assert!(
4632 d <= sh + 2,
4633 "depth 4 cost {d} round trips against depth 1's {sh}"
4634 );
4635 }
4636
4637 /// A component that exists but is not a directory.
4638 #[tokio::test]
4639 async fn a_file_used_as_a_directory_is_a_404() {
4640 let origin = origin_with(one_page()).await;
4641 let res = origin.handle(get("/a.html/b.html", None)).await;
4642 assert_eq!(res.status(), StatusCode::NOT_FOUND);
4643 }
4644
4645 /// A deep path is served, not merely checked: the walk must not lose the file it
4646 /// was walking towards.
4647 #[tokio::test]
4648 async fn a_deep_path_serves_its_body() {
4649 let origin = origin_with(deep_tree()).await;
4650 let res = origin.handle(get("/a/b/c/d.html", None)).await;
4651 assert_eq!(res.status(), StatusCode::OK);
4652 assert_eq!(
4653 res.headers()
4654 .get(CONTENT_TYPE)
4655 .and_then(|v| v.to_str().ok()),
4656 Some("text/html; charset=utf-8")
4657 );
4658 }
4659
4660 /// A range out of a body already held costs nothing: the slice happens here.
4661 #[tokio::test]
4662 async fn a_range_is_sliced_out_of_the_cached_body() {
4663 let origin = origin_with(one_page()).await;
4664 assert_eq!(
4665 origin.handle(get("/a.html", None)).await.status(),
4666 StatusCode::OK
4667 );
4668 let warm = trips(&origin).await;
4669
4670 let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
4671 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
4672 assert_eq!(
4673 res.headers()
4674 .get(CONTENT_RANGE)
4675 .and_then(|v| v.to_str().ok()),
4676 Some("bytes 1-3/5")
4677 );
4678 assert_eq!(&body_of(res).await[..], b"ell");
4679 assert_eq!(
4680 trips(&origin).await,
4681 warm,
4682 "slicing a held body must cost no round trip"
4683 );
4684 }
4685
4686 /// A range on a file not yet held still works, and the file ends up held.
4687 #[tokio::test]
4688 async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
4689 let origin = origin_with(one_page()).await;
4690
4691 let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
4692 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
4693 assert_eq!(&body_of(res).await[..], b"he");
4694
4695 let warm = trips(&origin).await;
4696 let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
4697 assert_eq!(&body_of(again).await[..], b"llo");
4698 assert_eq!(
4699 trips(&origin).await,
4700 warm,
4701 "a small file fetched for a range should be held whole"
4702 );
4703 }
4704
4705 /// The 416 has to name the real size, or a client cannot correct itself.
4706 #[tokio::test]
4707 async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
4708 let origin = origin_with(one_page()).await;
4709 let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
4710 assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
4711 assert_eq!(
4712 res.headers()
4713 .get(CONTENT_RANGE)
4714 .and_then(|v| v.to_str().ok()),
4715 Some("bytes */5")
4716 );
4717 }
4718
4719 /// A client that is not told ranges exist will never seek.
4720 #[tokio::test]
4721 async fn a_full_response_advertises_ranges() {
4722 let origin = origin_with(one_page()).await;
4723 let res = origin.handle(get("/a.html", None)).await;
4724 assert_eq!(
4725 res.headers()
4726 .get(ACCEPT_RANGES)
4727 .and_then(|v| v.to_str().ok()),
4728 Some("bytes")
4729 );
4730 }
4731
4732 /// The validator on offer is weak, so `If-Range` cannot be honoured. The whole
4733 /// representation is the specified answer, not a 412 and not a 206.
4734 #[tokio::test]
4735 async fn if_range_yields_the_whole_file() {
4736 let origin = origin_with(one_page()).await;
4737 let req = Request::builder()
4738 .uri("http://docs.ssh-browser/a.html")
4739 .header(HOST, "docs.ssh-browser")
4740 .header(RANGE, "bytes=1-3")
4741 .header(IF_RANGE, "W/\"64-5\"")
4742 .body(Empty::<Bytes>::new())
4743 .expect("request builds");
4744
4745 let res = origin.handle(req).await;
4746 assert_eq!(res.status(), StatusCode::OK);
4747 assert_eq!(&body_of(res).await[..], b"hello");
4748 }
4749
4750 /// The branch that makes a video seekable: a file too big to hold is fetched by
4751 /// range and not cached, so a seek does not pull the whole thing.
4752 #[tokio::test]
4753 async fn a_large_file_is_served_by_range_and_not_held() {
4754 let body: Vec<u8> = (0..64u8).collect();
4755 let origin = origin_with(
4756 FakeRemote::new()
4757 // Declared far larger than the cache threshold; the body behind it is
4758 // small because what is under test is the branch, not the bytes.
4759 .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
4760 .file("/srv/big.bin", &body),
4761 )
4762 .await;
4763
4764 let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
4765 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
4766 assert_eq!(&body_of(res).await[..], &body[0..10]);
4767
4768 let after = trips(&origin).await;
4769 let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
4770 assert_eq!(&body_of(second).await[..], &body[10..20]);
4771 assert!(
4772 trips(&origin).await > after,
4773 "a file over the threshold must not be held"
4774 );
4775 }
4776
4777 /// The boundary, from the side that matters. A page served under an alias origin
4778 /// names the control path and gets a file lookup, not the control router: the 404
4779 /// proves it was never routed there. A 401 would mean the router saw it.
4780 #[tokio::test]
4781 async fn an_alias_origin_has_no_control_api_on_it() {
4782 let origin = origin_with(one_page()).await;
4783 let res = origin.handle(get("/_control/hello", None)).await;
4784 assert_eq!(res.status(), StatusCode::NOT_FOUND);
4785 assert_ne!(
4786 res.status(),
4787 StatusCode::UNAUTHORIZED,
4788 "a 401 would mean the control router was reached from an alias origin"
4789 );
4790 }
4791
4792 /// Even with the right token in hand, an alias origin must not route to control.
4793 /// This is the case a compromised page would actually try.
4794 #[tokio::test]
4795 async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
4796 let origin = origin_with(one_page()).await;
4797 let req = Request::builder()
4798 .uri("http://docs.ssh-browser/_control/hello")
4799 .header(HOST, "docs.ssh-browser")
4800 .header(control::TOKEN_HEADER, TEST_TOKEN)
4801 .body(Empty::<Bytes>::new())
4802 .expect("request builds");
4803 assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
4804 }
4805
4806 /// There is no write path on the read side, and a POST is told so rather than being
4807 /// quietly served as a GET.
4808 #[tokio::test]
4809 async fn the_alias_origin_refuses_writes() {
4810 let origin = origin_with(one_page()).await;
4811 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
4812 let req = Request::builder()
4813 .method(method.clone())
4814 .uri("http://docs.ssh-browser/a.html")
4815 .header(HOST, "docs.ssh-browser")
4816 .body(Empty::<Bytes>::new())
4817 .expect("request builds");
4818 assert_eq!(
4819 origin.handle(req).await.status(),
4820 StatusCode::METHOD_NOT_ALLOWED,
4821 "{method} should be refused on the read-only origin"
4822 );
4823 }
4824 }
4825
4826 #[tokio::test]
4827 async fn the_control_api_answers_on_loopback_with_the_token() {
4828 let origin = origin_with(one_page()).await;
4829 let res = origin
4830 .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
4831 .await;
4832 assert_eq!(res.status(), StatusCode::OK);
4833 let body = body_of(res).await;
4834 let text = String::from_utf8_lossy(&body);
4835 assert!(
4836 text.contains("\"protocol\""),
4837 "hello must negotiate: {text}"
4838 );
4839 assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
4840 }
4841
4842 #[tokio::test]
4843 async fn the_control_api_refuses_loopback_without_the_token() {
4844 let origin = origin_with(one_page()).await;
4845 assert_eq!(
4846 origin
4847 .handle(loopback("/_control/hello", None))
4848 .await
4849 .status(),
4850 StatusCode::UNAUTHORIZED
4851 );
4852 assert_eq!(
4853 origin
4854 .handle(loopback("/_control/hello", Some("wrong")))
4855 .await
4856 .status(),
4857 StatusCode::UNAUTHORIZED
4858 );
4859 }
4860
4861 /// The direct browsing path still works alongside the control prefix.
4862 #[tokio::test]
4863 async fn the_loopback_path_still_serves_files() {
4864 let origin = origin_with(one_page()).await;
4865 let res = origin.handle(loopback("/docs/a.html", None)).await;
4866 assert_eq!(res.status(), StatusCode::OK);
4867 assert_eq!(&body_of(res).await[..], b"hello");
4868 }
4869
4870 /// The form souta asked for: bring the home directory into the config rather than
4871 /// writing out another machine's account layout by hand.
4872 #[tokio::test]
4873 async fn a_base_may_be_written_relative_to_the_home_directory() {
4874 let fs = FakeRemote::new().home("/home/souta").spawn().await;
4875 assert_eq!(
4876 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
4877 "/home/souta/work"
4878 );
4879 }
4880
4881 /// Three spellings of the same thing, and they had better agree.
4882 #[tokio::test]
4883 async fn a_bare_tilde_and_no_base_are_both_the_home_directory() {
4884 let fs = FakeRemote::new().home("/home/souta").spawn().await;
4885 assert_eq!(
4886 resolve_base(None, &fs).await.expect("resolves"),
4887 "/home/souta"
4888 );
4889 assert_eq!(
4890 resolve_base(Some("~"), &fs).await.expect("resolves"),
4891 "/home/souta"
4892 );
4893 }
4894
4895 /// An absolute base is already the answer, so asking the remote would be a round trip
4896 /// spent to be told something already written down.
4897 #[tokio::test]
4898 async fn an_absolute_base_costs_no_round_trip() {
4899 let fs = FakeRemote::new().home("/home/souta").spawn().await;
4900 let before = fs.round_trips();
4901 assert_eq!(
4902 resolve_base(Some("/srv/docs"), &fs)
4903 .await
4904 .expect("resolves"),
4905 "/srv/docs"
4906 );
4907 assert_eq!(fs.round_trips(), before, "an absolute base must not ask");
4908 }
4909
4910 /// The base is the blast radius of every page served under it, so a base that quietly
4911 /// meant somewhere other than where it reads is the worst place for a surprise.
4912 #[test]
4913 fn a_base_that_could_climb_out_of_the_home_directory_is_refused() {
4914 for bad in [
4915 "~/..",
4916 "~/../.ssh",
4917 "~/work/../..",
4918 "~/./x",
4919 "~work",
4920 "work",
4921 "",
4922 ] {
4923 assert!(!is_base(bad), "should have been refused: {bad:?}");
4924 assert!(
4925 Alias::new("docs", "h", Some(bad)).is_err(),
4926 "should have been refused: {bad:?}"
4927 );
4928 }
4929 for good in ["/", "/srv", "~", "~/work", "~/a/b/c"] {
4930 assert!(is_base(good), "should have been accepted: {good:?}");
4931 }
4932 }
4933
4934 /// A home of `/` is unusual and not impossible, and `//work` is not portably the same
4935 /// path as `/work`: POSIX leaves a leading double slash implementation-defined.
4936 #[tokio::test]
4937 async fn a_root_home_does_not_produce_a_doubled_slash() {
4938 let fs = FakeRemote::new().home("/").spawn().await;
4939 assert_eq!(resolve_base(None, &fs).await.expect("resolves"), "/");
4940 assert_eq!(
4941 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
4942 "/work"
4943 );
4944 }
4945
4946 /// Deliberately asserts nothing about which hosts come back: the answer is whatever
4947 /// this machine's ssh_config says, and a test that pinned it would pass on one
4948 /// machine and fail on every other. What it does catch is the route not being wired
4949 /// up, which is otherwise only visible by hand.
4950 ///
4951 /// The token is not checked here because it cannot be reached without one: the gate
4952 /// runs in `handle` before any route is dispatched, so no control route can have its
4953 /// own answer to that question.
4954 #[tokio::test]
4955 async fn the_host_list_is_a_control_route() {
4956 let origin = origin_with(one_page()).await;
4957 let res = origin
4958 .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
4959 .await;
4960 assert_eq!(res.status(), StatusCode::OK);
4961 let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4962 let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
4963 assert!(parsed.get("hosts").is_some_and(|h| h.is_array()), "{text}");
4964 assert!(
4965 parsed.get("unusable").is_some_and(|u| u.is_array()),
4966 "{text}"
4967 );
4968 }
4969
4970 /// The round-trip count is reachable at runtime, and moves.
4971 ///
4972 /// The claim this daemon is built on is a round-trip count, and the counter behind it
4973 /// used to be visible only to unit tests holding a `FakeRemote` — which makes the claim
4974 /// checkable against the fake and nowhere else. Reporting it lets `e2e/probe.mjs` read
4975 /// it for a real page on a real host, which is the only place it can be wrong in a way
4976 /// a reader would notice.
4977 ///
4978 /// Asserted as a strict increase rather than as a number. The number is the subject of
4979 /// other tests, and pinning it here would make this fail for every unrelated change to
4980 /// how a page is fetched. What must not pass is a field wired to a constant, which
4981 /// would report the invariant as perfect forever.
4982 #[tokio::test]
4983 async fn the_host_list_reports_round_trips_and_they_grow() {
4984 let origin = origin_with(one_page()).await;
4985
4986 async fn trips_now(origin: &Origin) -> u64 {
4987 let res = origin
4988 .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
4989 .await;
4990 assert_eq!(res.status(), StatusCode::OK);
4991 let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4992 let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
4993 let open = parsed["open"].as_array().expect("open is an array");
4994 assert_eq!(open.len(), 1, "{text}");
4995 open[0]["trips"].as_u64().expect("trips is a number")
4996 }
4997
4998 let before = trips_now(&origin).await;
4999 let res = origin.handle(get("/a.html", None)).await;
5000 assert_eq!(res.status(), StatusCode::OK);
5001 let after = trips_now(&origin).await;
5002
5003 assert!(
5004 after > before,
5005 "serving a page reported no round trips ({before} -> {after})"
5006 );
5007 }
5008
5009 /// A remembered label finds the ssh_config `Host` it came from, whatever its case.
5010 ///
5011 /// The regression this exists for: startup dialled the *label* rather than the `Host`, so a
5012 /// config saying `Host Panza` and a remembered `panza` produced `Could not resolve hostname
5013 /// panza` on every single start — while turning the host on a moment earlier had worked,
5014 /// because that path had the entry in hand. Two runs found it; no fake remote could have.
5015 #[test]
5016 fn a_remembered_label_finds_the_host_it_came_from() {
5017 let known = vec![
5018 ssh_config::Host {
5019 host: "Panza".to_string(),
5020 alias: "panza".to_string(),
5021 },
5022 ssh_config::Host {
5023 host: "issp-ohtaka".to_string(),
5024 alias: "issp-ohtaka".to_string(),
5025 },
5026 ];
5027
5028 // What is actually written down is the label, and it has to reach `Panza`.
5029 assert_eq!(
5030 entry_for(&known, "panza").map(|h| h.host.as_str()),
5031 Some("Panza")
5032 );
5033 // And the other spelling, for a name typed rather than clicked.
5034 assert_eq!(
5035 entry_for(&known, "Panza").map(|h| h.host.as_str()),
5036 Some("Panza")
5037 );
5038 assert_eq!(
5039 entry_for(&known, "issp-ohtaka").map(|h| h.host.as_str()),
5040 Some("issp-ohtaka")
5041 );
5042 }
5043
5044 /// A name ssh has never heard of is not dialled.
5045 ///
5046 /// Startup is the one path that reads hosts out of a file rather than from a request, so
5047 /// without this it is a looser door into "make this daemon ssh somewhere" than the two that
5048 /// are guarded — and one that fires again at every start.
5049 #[test]
5050 fn a_name_ssh_config_does_not_know_resolves_to_nothing() {
5051 let known = vec![ssh_config::Host {
5052 host: "Panza".to_string(),
5053 alias: "panza".to_string(),
5054 }];
5055 assert!(entry_for(&known, "not-a-host-anywhere").is_none());
5056 assert!(entry_for(&known, "").is_none());
5057 // Not a prefix or substring match either: `panz` is somebody else's name.
5058 assert!(entry_for(&known, "panz").is_none());
5059 }
5060
5061 /// Enabling a host is held to the same gate opening one is.
5062 ///
5063 /// This is a second door into "make the daemon ssh somewhere", and a worse one if it is
5064 /// looser, because what it writes down is retried at every start from then on. The name is
5065 /// nonsense on purpose, so this asserts the same thing whether or not the machine running
5066 /// it has an ssh_config at all.
5067 #[tokio::test]
5068 async fn enabling_a_host_ssh_does_not_know_is_refused() {
5069 let origin = origin_with(one_page()).await;
5070 let res = origin
5071 .handle(control_post(
5072 "/_control/enabled",
5073 Some(TEST_TOKEN),
5074 r#"{"host":"not-a-host-in-anyones-ssh-config.invalid","enabled":true}"#,
5075 ))
5076 .await;
5077 assert_eq!(res.status(), StatusCode::NOT_FOUND);
5078 }
5079
5080 /// And it needs the token, like everything else on this API.
5081 ///
5082 /// Worth its own check rather than trusting the gate: this route writes a file that
5083 /// survives the process, so "anyone on loopback can make this daemon ssh somewhere every
5084 /// morning" is the failure it would be.
5085 #[tokio::test]
5086 async fn enabling_a_host_without_the_token_is_refused() {
5087 let origin = origin_with(one_page()).await;
5088 let res = origin
5089 .handle(control_post(
5090 "/_control/enabled",
5091 None,
5092 r#"{"host":"anything","enabled":true}"#,
5093 ))
5094 .await;
5095 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
5096 }
5097
5098 /// A body that does not say what to do is refused rather than defaulted.
5099 ///
5100 /// Defaulting `enabled` would make a malformed request turn a host on, or off, and the
5101 /// caller would have no way to tell which had happened.
5102 #[tokio::test]
5103 async fn enabling_needs_to_say_which_way() {
5104 let origin = origin_with(one_page()).await;
5105 for body in [
5106 r#"{"host":"anything"}"#,
5107 r#"{"enabled":true}"#,
5108 "{}",
5109 "not json",
5110 ] {
5111 let res = origin
5112 .handle(control_post("/_control/enabled", Some(TEST_TOKEN), body))
5113 .await;
5114 assert_eq!(
5115 res.status(),
5116 StatusCode::BAD_REQUEST,
5117 "{body} should not have been accepted"
5118 );
5119 }
5120 }
5121
5122 /// Turning a host off closes it now, not only next time.
5123 ///
5124 /// A setting that took effect at the next restart is indistinguishable from one that did
5125 /// not work, and in this direction it is worse than confusing: a host still answering after
5126 /// you switched it off is an ssh session you believe you have given back.
5127 /// The failure this whole path exists to end.
5128 ///
5129 /// A declared alias whose ssh will not come up used to be fatal at startup and, once past
5130 /// that, indistinguishable from a name nobody had written down. Now it is a gateway that
5131 /// did not answer, said in the status code, and the next request asks again.
5132 #[tokio::test]
5133 async fn an_alias_whose_host_is_down_is_still_an_alias() {
5134 let mut origin = origin_with(one_page()).await;
5135 // No cooldown, so the second request below reaches the dial rather than the memory of
5136 // the first. The cooldown itself is the next test.
5137 origin.cooldown = Duration::ZERO;
5138 // The declared host is `nowhere`, so re-dialling it fails without a network.
5139 origin.sessions.write().await.remove("docs");
5140
5141 let res = origin.handle(get("/a.html", None)).await;
5142 assert_eq!(
5143 res.status(),
5144 StatusCode::BAD_GATEWAY,
5145 "a declared alias that will not connect is 502, not 404"
5146 );
5147 assert!(
5148 origin.trouble.read().await.contains_key("docs"),
5149 "the reason has to outlive the request that found it"
5150 );
5151
5152 // Not latched. Asked again, the dial happens again -- a daemon that took the first
5153 // failure as final would pass every check above and still need restarting.
5154 //
5155 // `at` is the evidence and the status is not: a second 502 is what a re-dial and a
5156 // remembered failure both look like from outside, so a test that only read the status
5157 // would pass whichever this did.
5158 let first = origin.trouble.read().await["docs"].at;
5159 assert_eq!(
5160 origin.handle(get("/a.html", None)).await.status(),
5161 StatusCode::BAD_GATEWAY
5162 );
5163 assert!(
5164 origin.trouble.read().await["docs"].at > first,
5165 "the second request did not reach the dial"
5166 );
5167 }
5168
5169 /// But not once per request, because a page decides how often those arrive.
5170 ///
5171 /// `<img src="http://docs.ssh-browser/x">` in a loop is a remote document choosing how
5172 /// often this machine opens an ssh. It cannot name a host that was not declared -- the set
5173 /// is fixed at startup and is not `ssh_config` -- so the reach is a host the reader already
5174 /// asked to have served. That makes it cheap, not free, and the rate is the part somebody
5175 /// else was choosing.
5176 #[tokio::test]
5177 async fn a_page_cannot_choose_how_often_an_ssh_is_opened() {
5178 let mut origin = origin_with(one_page()).await;
5179 origin.cooldown = Duration::from_secs(300);
5180 origin.sessions.write().await.remove("docs");
5181
5182 assert_eq!(
5183 origin.handle(get("/a.html", None)).await.status(),
5184 StatusCode::BAD_GATEWAY
5185 );
5186 let first = origin.trouble.read().await["docs"].at;
5187
5188 for _ in 0..5 {
5189 assert_eq!(
5190 origin.handle(get("/a.html", None)).await.status(),
5191 StatusCode::BAD_GATEWAY,
5192 "the answer is still the truth, it just costs nothing to give"
5193 );
5194 }
5195 assert_eq!(
5196 origin.trouble.read().await["docs"].at,
5197 first,
5198 "a loop of requests dialled more than once inside the cooldown"
5199 );
5200 }
5201
5202 /// A connection that dies is dropped, not held.
5203 ///
5204 /// Without this the daemon answers `sftp session is gone` for as long as it runs. It is
5205 /// the one failure mode a reconnecting daemon must not have, and the only way to see it is
5206 /// to kill a real connection under a live origin.
5207 #[tokio::test]
5208 async fn a_dead_connection_is_not_held() {
5209 let (fs, stop) = one_page().spawn_stoppable().await;
5210 let origin = origin_over(fs, Cache::default());
5211 assert_eq!(
5212 origin.handle(get("/a.html", None)).await.status(),
5213 StatusCode::OK
5214 );
5215
5216 stop.abort();
5217 // The driver task notices when its reader fails, which is a scheduling hop away rather
5218 // than a duration. Bounded so a change that stops it noticing fails here instead of
5219 // hanging the suite.
5220 for _ in 0..1000 {
5221 if origin.live("docs").await.is_none() {
5222 break;
5223 }
5224 tokio::task::yield_now().await;
5225 }
5226 assert!(
5227 origin.live("docs").await.is_none(),
5228 "a connection whose remote is gone must not stay in the map"
5229 );
5230
5231 // And what follows is a re-dial rather than a corpse. Both are 502, so the status is
5232 // not the evidence -- what it says is. `nowhere` is not a host, so a request that
5233 // re-dialled reports ssh failing to reach it; one that went down the dead connection
5234 // instead would report the pipe, and only the request after that would try again.
5235 let res = origin.handle(get("/a.html", None)).await;
5236 assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
5237 let said = String::from_utf8_lossy(&body_of(res).await).to_string();
5238 assert!(said.contains("is not connected"), "{said}");
5239 assert!(
5240 !said.contains("session closed"),
5241 "the dead connection was used rather than replaced: {said}"
5242 );
5243
5244 // The window this cannot reach: a connection that is gone but whose driver task has
5245 // not noticed yet, because the notice arrives when its own read fails and a request
5246 // can be handed down the pipe before that. An in-memory duplex closes synchronously,
5247 // so there is no window here to open. It was measured instead against a real host --
5248 // kill the ssh under a live daemon, reload once -- three times for three reloads, all
5249 // 200. Before `alias` checked liveness *after* the attempt, that reload was a 502 and
5250 // only the one after it worked.
5251 }
5252
5253 /// A name nobody declared stays a 404.
5254 ///
5255 /// The three answers have to stay three. If everything unreachable became 502 then a typo
5256 /// in a URL would read as somebody's host being down.
5257 #[tokio::test]
5258 async fn an_undeclared_name_is_not_a_gateway_failure() {
5259 let origin = origin_with(one_page()).await;
5260 let res = origin.handle(get_on("typo", "/a.html")).await;
5261 assert_eq!(res.status(), StatusCode::NOT_FOUND);
5262 }
5263
5264 #[tokio::test]
5265 async fn disabling_a_host_closes_it_now() {
5266 let origin = origin_with(one_page()).await;
5267 // A host opened from the dashboard, not a declared alias. The two are closed on
5268 // different terms now and this is the one disabling is about: it was never in
5269 // `declared`, so removing the session is the whole of stopping it and the name goes
5270 // back to being one this daemon does not serve. Sharing the `Arc` rather than opening
5271 // a second remote means dropping this one leaves `docs` connected, which is the point
5272 // -- disabling one host must not disturb another.
5273 let session = Arc::clone(&origin.sessions.read().await["docs"]);
5274 origin
5275 .sessions
5276 .write()
5277 .await
5278 .insert("opened".to_string(), session);
5279 assert!(origin.live("opened").await.is_some());
5280
5281 origin.sessions.write().await.remove("opened");
5282 assert!(origin.live("opened").await.is_none());
5283 let res = origin.handle(get_on("opened", "/a.html")).await;
5284 assert_eq!(res.status(), StatusCode::NOT_FOUND);
5285 assert_eq!(
5286 origin.handle(get("/a.html", None)).await.status(),
5287 StatusCode::OK,
5288 "closing one must not disturb another"
5289 );
5290 }
5291
5292 /// Nothing about how to reach a host is reported to anything but the dashboard.
5293 ///
5294 /// The point of the whole arrangement: `ssh_config` keeps the account, the port and the
5295 /// jump host, and an alias origin never learns any of it. A page that could read this off
5296 /// its own origin would be reading the machine's ssh setup.
5297 #[tokio::test]
5298 async fn an_alias_origin_cannot_read_the_host_list() {
5299 let origin = origin_with(one_page()).await;
5300 for path in ["/_control/hosts", "/_control/enabled", "/_control/hello"] {
5301 let res = origin.handle(get(path, None)).await;
5302 assert_ne!(
5303 res.status(),
5304 StatusCode::OK,
5305 "{path} answered a request from an alias origin"
5306 );
5307 }
5308 }
5309
5310 /// The check that keeps `open` from being "ssh to anything on request". The list the
5311 /// extension offers is the menu, and a host that is not on it is a config change,
5312 /// which is a deliberate act rather than one request.
5313 ///
5314 /// The name is nonsense on purpose, so this asserts the same thing on a machine with
5315 /// an ssh_config and on one without.
5316 #[tokio::test]
5317 async fn opening_a_host_ssh_does_not_know_is_refused() {
5318 let origin = origin_with(one_page()).await;
5319 let res = origin
5320 .handle(control_post(
5321 "/_control/open",
5322 Some(TEST_TOKEN),
5323 r#"{"host":"not-a-host-in-anyones-ssh-config.invalid"}"#,
5324 ))
5325 .await;
5326 assert_eq!(res.status(), StatusCode::NOT_FOUND);
5327 }
5328
5329 /// A body that does not name a host, and one that names a field this does not have.
5330 /// The second matters for the same reason the config file refuses unknown keys: a
5331 /// quietly dropped `base_path` opens an alias at somewhere nobody chose.
5332 #[tokio::test]
5333 async fn an_open_request_that_is_not_one_is_refused() {
5334 let origin = origin_with(one_page()).await;
5335 for body in [
5336 "",
5337 "{}",
5338 r#"{"base":"/srv"}"#,
5339 r#"{"host":"docs","base_path":"/srv"}"#,
5340 ] {
5341 let res = origin
5342 .handle(control_post("/_control/open", Some(TEST_TOKEN), body))
5343 .await;
5344 assert_eq!(
5345 res.status(),
5346 StatusCode::BAD_REQUEST,
5347 "should have been refused: {body}"
5348 );
5349 }
5350 }
5351
5352 /// `open` has a side effect, so it is the route where the token matters most: a page
5353 /// can send a simple POST without a preflight, and could not read the answer but
5354 /// would still have caused the thing to happen.
5355 #[tokio::test]
5356 async fn opening_a_host_needs_the_token() {
5357 let origin = origin_with(one_page()).await;
5358 for token in [None, Some("wrong")] {
5359 let res = origin
5360 .handle(control_post("/_control/open", token, r#"{"host":"docs"}"#))
5361 .await;
5362 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "token {token:?}");
5363 }
5364 }
5365
5366 /// What removes the paste, and the check that makes it safe to.
5367 ///
5368 /// The header values are the measured ones: an extension's `fetch` arrives with no
5369 /// `Sec-Fetch-Site` value this daemon would call a page, and a page the daemon itself
5370 /// serves in fallback mode arrives as `same-origin` -- the hardest case, because it
5371 /// shares an origin with the control API.
5372 #[tokio::test]
5373 async fn the_token_is_handed_over_to_something_that_is_not_a_page() {
5374 let origin = origin_with(one_page()).await;
5375 for site in [None, Some("none")] {
5376 let res = origin.handle(from_site("/_control/token", site)).await;
5377 assert_eq!(res.status(), StatusCode::OK, "site {site:?}");
5378 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5379 assert_eq!(body.trim(), TEST_TOKEN, "site {site:?}");
5380 }
5381 }
5382
5383 #[tokio::test]
5384 async fn a_page_is_not_handed_the_token() {
5385 let origin = origin_with(one_page()).await;
5386 for site in ["same-origin", "same-site", "cross-site"] {
5387 let res = origin
5388 .handle(from_site("/_control/token", Some(site)))
5389 .await;
5390 assert_eq!(res.status(), StatusCode::FORBIDDEN, "site {site}");
5391 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5392 assert!(!body.contains(TEST_TOKEN), "the refusal leaked it: {body}");
5393 }
5394 }
5395
5396 /// The case the token alone could not refuse: a page in the no-proxy fallback mode is
5397 /// same-origin with the control API, so a leaked token would have been enough.
5398 #[tokio::test]
5399 async fn a_page_with_the_token_still_cannot_use_the_control_api() {
5400 let origin = origin_with(one_page()).await;
5401 let req = Request::builder()
5402 .uri("http://127.0.0.1:7391/_control/hello")
5403 .header(HOST, "127.0.0.1:7391")
5404 .header(control::TOKEN_HEADER, TEST_TOKEN)
5405 .header(control::FETCH_SITE_HEADER, "same-origin")
5406 .body(Full::new(Bytes::new()))
5407 .expect("request builds");
5408 assert_eq!(origin.handle(req).await.status(), StatusCode::FORBIDDEN);
5409 }
5410
5411 /// The other half of `open`, and the way a base gets changed: close, then reopen.
5412 #[tokio::test]
5413 async fn an_alias_can_be_closed_and_is_then_gone() {
5414 let origin = origin_with(one_page()).await;
5415 assert_eq!(
5416 origin.handle(get("/a.html", None)).await.status(),
5417 StatusCode::OK
5418 );
5419
5420 let res = origin
5421 .handle(control_post(
5422 "/_control/close",
5423 Some(TEST_TOKEN),
5424 r#"{"alias":"docs"}"#,
5425 ))
5426 .await;
5427 assert_eq!(res.status(), StatusCode::OK);
5428
5429 // The origin stops answering, rather than answering with stale bytes out of the
5430 // cache. An alias that is closed but still serving would be the worst of both.
5431 //
5432 // 503 and not 404, and the difference is the whole of this change: `docs` is still an
5433 // alias this daemon serves, it has been switched off. A 404 here would say the name
5434 // was never known, which is what the daemon used to say about every host that was
5435 // merely asleep -- and the reader would go looking in the config file for a name that
5436 // is sitting right there in it.
5437 let res = origin.handle(get("/a.html", None)).await;
5438 assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
5439
5440 // And a reload does not undo it. A stop that the next request reversed would not be a
5441 // stop, and this is the exact path that would reverse it.
5442 assert_eq!(
5443 origin.handle(get("/a.html", None)).await.status(),
5444 StatusCode::SERVICE_UNAVAILABLE
5445 );
5446 }
5447
5448 /// Kept apart from success. Told neither, a caller cannot tell "closed it" from
5449 /// "there was nothing there", and the second usually means a typo.
5450 #[tokio::test]
5451 async fn closing_an_alias_that_is_not_open_says_so() {
5452 let origin = origin_with(one_page()).await;
5453 let res = origin
5454 .handle(control_post(
5455 "/_control/close",
5456 Some(TEST_TOKEN),
5457 r#"{"alias":"nope"}"#,
5458 ))
5459 .await;
5460 assert_eq!(res.status(), StatusCode::NOT_FOUND);
5461 }
5462
5463 #[tokio::test]
5464 async fn closing_an_alias_needs_the_token() {
5465 let origin = origin_with(one_page()).await;
5466 let res = origin
5467 .handle(control_post("/_control/close", None, r#"{"alias":"docs"}"#))
5468 .await;
5469 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
5470 // And it must not have happened anyway.
5471 assert_eq!(
5472 origin.handle(get("/a.html", None)).await.status(),
5473 StatusCode::OK
5474 );
5475 }
5476
5477 /// souta's actual problem, in miniature. `out/` contains no HTML of its own; the board
5478 /// is `out/ft_demo/index.html`. Grouping the HTML in one listing would never surface
5479 /// it, so a directory that *is* a page has to say so.
5480 #[tokio::test]
5481 async fn a_directory_holding_an_index_is_listed_as_a_site() {
5482 let origin = origin_with(
5483 FakeRemote::new()
5484 .dir("/srv", vec![("ft-demo", dir_attrs()), ("src", dir_attrs())])
5485 .dir("/srv/ft-demo", vec![("index.html", file_attrs(5, 1))])
5486 .dir("/srv/src", vec![("main.jl", file_attrs(5, 1))])
5487 .file("/srv/ft-demo/index.html", b"board"),
5488 )
5489 .await;
5490
5491 let body = String::from_utf8(body_of(origin.handle(get("/", None)).await).await.to_vec())
5492 .expect("utf-8");
5493 // Marked, so it reads as somewhere to open rather than somewhere to look. With no
5494 // headings left, the class and its colour are the whole signal.
5495 assert!(
5496 body.contains("class=\"row site\" href=\"/ft-demo/\""),
5497 "{body}"
5498 );
5499 let demo = body.find("ft-demo/").expect("the site listed");
5500 let src = body.find("src/").expect("the folder listed");
5501 assert!(demo < src, "a site leads the other directories: {body}");
5502 }
5503
5504 /// The invariant, on the one page that pays for the scan. One listing for the directory
5505 /// and one batch for all of its subdirectories, whether there are two or twenty -- not
5506 /// one round trip each, which is what a loop would cost and what would make browsing a
5507 /// deep tree unusable over a real link.
5508 #[tokio::test]
5509 async fn the_site_scan_costs_the_same_however_many_subdirectories() {
5510 async fn trips_for(n: usize) -> u64 {
5511 let names: Vec<String> = (0..n).map(|i| format!("d{i:02}")).collect();
5512 let mut remote = FakeRemote::new().dir(
5513 "/srv",
5514 names.iter().map(|s| (s.as_str(), dir_attrs())).collect(),
5515 );
5516 for name in &names {
5517 remote = remote.dir(&format!("/srv/{name}"), vec![("a.txt", file_attrs(1, 1))]);
5518 }
5519 let origin = origin_with(remote).await;
5520 let before = trips(&origin).await;
5521 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
5522 trips(&origin).await - before
5523 }
5524
5525 let few = trips_for(2).await;
5526 let many = trips_for(20).await;
5527 assert_eq!(
5528 few, many,
5529 "{many} round trips for twenty subdirectories against {few} for two"
5530 );
5531 }
5532
5533 /// And stepping into one of them is free afterwards, because the scan already fetched
5534 /// exactly the listing that click needs. The extra round trip is not purely a cost.
5535 #[tokio::test]
5536 async fn the_scan_leaves_the_next_click_paid_for() {
5537 let origin = origin_with(
5538 FakeRemote::new()
5539 .dir("/srv", vec![("sub", dir_attrs())])
5540 .dir("/srv/sub", vec![("a.txt", file_attrs(1, 1))]),
5541 )
5542 .await;
5543 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
5544
5545 let before = trips(&origin).await;
5546 assert_eq!(
5547 origin.handle(get("/sub/", None)).await.status(),
5548 StatusCode::OK
5549 );
5550 assert_eq!(
5551 trips(&origin).await,
5552 before,
5553 "the listing the scan fetched should still be the one that answers"
5554 );
5555 }
5556
5557 /// The race a two-second TTL made possible, forced to happen every time.
5558 ///
5559 /// The walk used to ask the cache whether a listing was there and then ask it for the
5560 /// listing. Those are two questions with a gap between them, and a request landing on
5561 /// the expiry boundary got yes and then no -- a 404 reading "cannot list" about a
5562 /// directory that plainly existed, on about one e2e run in six. With a TTL of zero
5563 /// every read misses, so the gap is guaranteed rather than occasional.
5564 ///
5565 /// It passes because the listings are taken once and held for the request. Nothing here
5566 /// can make them expire, because nothing re-reads them.
5567 #[tokio::test]
5568 async fn a_listing_that_expires_mid_request_does_not_lose_the_path() {
5569 let origin =
5570 origin_with_cache(deep_tree(), Cache::new(std::time::Duration::ZERO, 1 << 20)).await;
5571 assert_eq!(
5572 origin.handle(get("/a/b/c/d.html", None)).await.status(),
5573 StatusCode::OK,
5574 "a path four deep must survive its own listings expiring"
5575 );
5576 // And a directory too, which is the one that builds a tree out of them.
5577 assert_eq!(
5578 origin.handle(get("/a/b/c/", None)).await.status(),
5579 StatusCode::OK
5580 );
5581 }
5582
5583 /// The other half: a refusal that is not absence carries ssh's own words out, rather
5584 /// than this daemon's word for not knowing.
5585 #[tokio::test]
5586 async fn a_directory_the_remote_refuses_says_why() {
5587 let origin = origin_with(
5588 FakeRemote::new()
5589 .dir("/srv", vec![("locked", dir_attrs())])
5590 // 3 is SSH_FX_PERMISSION_DENIED: refused, and not for being absent.
5591 .refuses_listing("/srv/locked", 3),
5592 )
5593 .await;
5594
5595 let res = origin.handle(get("/locked/x.html", None)).await;
5596 assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
5597 let said = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5598 assert!(said.contains("/srv/locked"), "{said}");
5599 assert!(
5600 !said.contains("cannot list"),
5601 "the old wording said nothing the reader could act on: {said}"
5602 );
5603 }
5604}