diff --git a/README.md b/README.md
index 397c215..4c1bb68 100644
--- a/README.md
+++ b/README.md
@@ -208,14 +208,22 @@ Enabled hosts are connected at startup, all at once, and one that does not answe
reported rather than fatal: a laptop on the wrong network has half of them unreachable,
and refusing to start then would be refusing exactly when it is wanted.
-**Enabled means open, not "opens on demand".** Connecting when a request for an unopened
-host arrives would be nicer to describe and much worse to have: every request to an alias
-origin arrives through the proxy, so any web page could start ssh sessions by naming a host
-in an ``, and time the answer to learn which hosts you have. The header that would
-separate a navigation from a subresource is not available — **Chromium sends no
-`Sec-Fetch-*` at all on a proxied request**, measured against a real browser. So a session
-is opened by the daemon at startup or by a control call carrying the token, and by nothing
-else.
+**A host in `ssh_config` is never opened by a request.** Every request to an alias origin
+arrives through the proxy, so if any name could be dialled by asking for it, a web page
+could start ssh sessions by naming hosts in an `` and time the answers to learn
+which ones you have. The header that would separate a navigation from a subresource is not
+available — **Chromium sends no `Sec-Fetch-*` at all on a proxied request**, measured
+against a real browser. So an `ssh_config` host is opened at startup if it is enabled, or by
+a control call carrying the token, and by nothing else.
+
+**A declared alias is different, and a request does open it.** The set of them is fixed when
+the daemon starts, from the config file and the command line — it is not `ssh_config` — so
+the furthest a page can reach is a host you already asked to have served, and one whose
+up-or-down it can already read off the status code. What it must not get is the *rate*, so a
+failed dial is remembered for three seconds and requests inside that window are answered
+from the memory. Long enough that a loop cannot choose how often this machine opens an ssh;
+short enough that reloading is still a retry, which is the point of the retry being a
+reload.
`--port` and `--suffix` change the listener and the hostname suffix. `ssh-browser
pac` prints the script without starting a server.
@@ -244,8 +252,20 @@ host = "login-node"
`base` may be an absolute path, or `~` and a path under the remote's home directory, or
omitted for the home directory itself. The tilde is resolved by asking the remote, once,
-at startup: it is shell syntax and this transport never runs a shell, so expanding it
-locally would produce *your* home directory rather than the account's.
+when the alias connects: it is shell syntax and this transport never runs a shell, so
+expanding it locally would produce *your* home directory rather than the account's.
+
+**An alias that will not connect does not stop the daemon.** All of them are dialled at
+startup, at once; the ones that come up are served, and the ones that do not are named with
+what ssh said, listed as not connected, and opened by the next request that asks for them.
+A cluster in maintenance used to take the rest of your sites down with it. The exception is
+an alias typed on the command line — `ssh-browser serve docs=myhost` — which is a thing you
+are standing there waiting on, so failing to open it is still an error.
+
+An alias that is down answers `502` and says what ssh said; one stopped from the dashboard
+answers `503` and says a restart brings it back; a name that was never declared is still a
+`404`. Three situations, three answers — a daemon that called all of them "not found" sent
+you looking in the config file for a name that was sitting in it.
Pointing an alias at a home directory is reasonable because **no name beginning with a dot
is ever served**, at any depth, and they are left out of listings. An alias base is one
diff --git a/crates/ssh-browser/src/fs/sftp.rs b/crates/ssh-browser/src/fs/sftp.rs
index d7a5d46..cd5b840 100644
--- a/crates/ssh-browser/src/fs/sftp.rs
+++ b/crates/ssh-browser/src/fs/sftp.rs
@@ -56,6 +56,20 @@ impl SftpFs {
Ok(Self::drive(sftp, Some(child)))
}
+ /// Whether this connection is still worth sending a request down.
+ ///
+ /// The driver task owns the ssh child's pipes and returns when they close, which drops the
+ /// receiving end of this channel. So a shut channel is not a proxy for "the ssh died" --
+ /// it is the same event, observed from the only side that can see it without a syscall.
+ ///
+ /// Racy by nature: a connection alive when this is asked can be gone by the time the
+ /// request lands. That is fine, because the caller retries either way. What this prevents
+ /// is the other thing -- holding a corpse forever and answering every request with
+ /// `sftp session is gone` until somebody restarts the daemon.
+ pub fn is_alive(&self) -> bool {
+ !self.jobs.is_closed()
+ }
+
/// Drive a session over arbitrary streams. Exists so the round-trip invariant
/// can be asserted against an in-memory server, with no ssh anywhere.
pub async fn over(w: W, r: R) -> Result
diff --git a/crates/ssh-browser/src/main.rs b/crates/ssh-browser/src/main.rs
index 0ef2423..443c09a 100644
--- a/crates/ssh-browser/src/main.rs
+++ b/crates/ssh-browser/src/main.rs
@@ -289,5 +289,7 @@ fn parse_alias(spec: &str) -> Result {
Some((host, base)) => (host, Some(base)),
None => (rest, None),
};
- Alias::new(name, host, base).with_context(|| format!("in {spec:?}"))
+ Alias::new(name, host, base)
+ .map(Alias::for_this_run)
+ .with_context(|| format!("in {spec:?}"))
}
diff --git a/crates/ssh-browser/src/origin/mod.rs b/crates/ssh-browser/src/origin/mod.rs
index 7ca8be3..ae5ccd8 100644
--- a/crates/ssh-browser/src/origin/mod.rs
+++ b/crates/ssh-browser/src/origin/mod.rs
@@ -20,6 +20,7 @@ use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
use tokio::sync::RwLock;
@@ -83,8 +84,25 @@ pub struct Alias {
/// Deferred rather than filled in with a guess, because the answer lives on the
/// remote. `~` is shell syntax and this transport never runs a shell; expanding it
/// here would produce this machine's home directory, which is a different computer's.
- /// It is resolved once in `bind`, by asking.
+ /// It is resolved once on connecting, by asking.
base: Option,
+ named: Named,
+}
+
+/// Where an alias's name came from, which is what failing to open it means.
+///
+/// A name typed for this run is something the reader is standing there waiting on, so a daemon
+/// that started without it would be answering a different question than the one asked. A name
+/// in a config file is what they use in a week: a cluster in maintenance, a laptop off the
+/// VPN, an agent with no key loaded yet. Refusing to start until every one of those answers
+/// makes the daemon useless exactly when it is most wanted -- which is the rule `[[host]]` has
+/// followed all along, written down two hundred lines below this and not applied here.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Named {
+ /// Typed on the command line: `ssh-browser serve docs=myhost`.
+ ForThisRun,
+ /// `[[alias]]` in the config file.
+ InTheFile,
}
impl Alias {
@@ -109,9 +127,22 @@ impl Alias {
name: name.to_string(),
host: host.to_string(),
base: base.map(str::to_string),
+ named: Named::InTheFile,
})
}
+ /// The same alias, but typed on the command line rather than read out of a file.
+ ///
+ /// Only `parse_alias` calls this, and the asymmetry is the point: failing to open one of
+ /// these stops the daemon, and that is a thing to opt into at one call site rather than a
+ /// default every construction inherits.
+ pub fn for_this_run(self) -> Self {
+ Self {
+ named: Named::ForThisRun,
+ ..self
+ }
+ }
+
pub fn name(&self) -> &str {
&self.name
}
@@ -124,6 +155,10 @@ impl Alias {
pub fn base(&self) -> Option<&str> {
self.base.as_deref()
}
+
+ pub fn named(&self) -> Named {
+ self.named
+ }
}
/// One line of the host list: a host ssh knows, and what this daemon is doing with it.
@@ -175,6 +210,13 @@ struct OpenAlias {
#[derive(serde::Serialize)]
struct KnownHosts {
open: Vec,
+ /// Declared aliases that are not connected, and why.
+ ///
+ /// The other half of `open`, and the half that used to not exist: before the daemon could
+ /// run without every alias connected, a name was either being served or the daemon was not
+ /// running. Now one can be neither, and a reader who is not told which is looking at the
+ /// silent failure this project says it does not have.
+ stalled: Vec,
hosts: Vec,
unusable: Vec,
/// What TLS handshakes have done, under https.
@@ -185,6 +227,34 @@ struct KnownHosts {
tls: Option,
}
+/// How long a failed dial is remembered before another is attempted.
+///
+/// Not a backoff and not tuning. It is the smallest number that makes a remote page unable to
+/// choose how often this machine opens an ssh, while leaving a reload a real retry. See
+/// `session_for`.
+const DIAL_COOLDOWN: Duration = Duration::from_secs(3);
+
+/// What happened the last time an alias was dialled, and when.
+struct Trouble {
+ at: Instant,
+ why: String,
+}
+
+/// A declared alias with no connection behind it.
+#[derive(serde::Serialize)]
+struct StalledAlias {
+ alias: String,
+ host: String,
+ url: String,
+ /// What ssh said the last time this was tried, or `None` if it has not been tried since
+ /// it was stopped.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ why: Option,
+ /// Stopped from the dashboard rather than unreachable. A different thing to do about it:
+ /// one is fixed by a restart, the other by the host coming back.
+ stopped: bool,
+}
+
/// Whether a configured base is one this daemon can resolve.
///
/// `~` is accepted here and nowhere else in the codebase. It is shell syntax, and this
@@ -336,6 +406,33 @@ pub struct Origin {
/// not been connected to since it started simply has no dashboard to point at, which is
/// the truth at that moment.
dashboard: RwLock
\
",
);
s
@@ -3152,7 +3558,11 @@ mod tests {
}
async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
- let fs = remote.spawn().await;
+ origin_over(remote.spawn().await, cache)
+ }
+
+ /// The same origin, over a connection the caller made -- so it can also end it.
+ fn origin_over(fs: SftpFs, cache: Cache) -> Origin {
let mut sessions = HashMap::new();
sessions.insert(
"docs".to_string(),
@@ -3179,6 +3589,22 @@ mod tests {
reachable: RwLock::new(reachable::Set::default()),
handshakes: Handshakes::default(),
dashboard: RwLock::new(None),
+ // Declared as well as connected, because that is what production looks like and
+ // because the reconnect path reads it: an alias whose session dies has to be
+ // re-dialled from somewhere, and a test whose `docs` was connected but never
+ // declared would exercise the half of `session_for` that cannot retry.
+ declared: HashMap::from([(
+ "docs".to_string(),
+ Declared {
+ host: "nowhere".to_string(),
+ base: Some("/srv".to_string()),
+ named: Named::InTheFile,
+ dialling: tokio::sync::Mutex::new(()),
+ },
+ )]),
+ trouble: RwLock::new(HashMap::new()),
+ stopped: RwLock::new(HashSet::new()),
+ cooldown: DIAL_COOLDOWN,
}
}
@@ -3192,6 +3618,14 @@ mod tests {
b.body(Empty::new()).expect("request builds")
}
+ fn get_on(alias: &str, path: &str) -> Request> {
+ Request::builder()
+ .uri(format!("http://{alias}.ssh-browser{path}"))
+ .header(HOST, format!("{alias}.ssh-browser"))
+ .body(Empty::new())
+ .expect("request builds")
+ }
+
async fn trips(origin: &Origin) -> u64 {
origin
.sessions
@@ -4690,22 +5124,171 @@ mod tests {
/// A setting that took effect at the next restart is indistinguishable from one that did
/// not work, and in this direction it is worse than confusing: a host still answering after
/// you switched it off is an ssh session you believe you have given back.
+ /// The failure this whole path exists to end.
+ ///
+ /// A declared alias whose ssh will not come up used to be fatal at startup and, once past
+ /// that, indistinguishable from a name nobody had written down. Now it is a gateway that
+ /// did not answer, said in the status code, and the next request asks again.
#[tokio::test]
- async fn disabling_a_host_closes_it_now() {
- let origin = origin_with(one_page()).await;
- // `docs` is the alias the test origin serves. Naming it here needs no ssh_config, but
- // the route checks ssh_config before it does anything -- so this asserts the closing
- // through the piece that does not need a network, and the gate above covers the rest.
- assert!(origin.session("docs").await.is_some());
+ async fn an_alias_whose_host_is_down_is_still_an_alias() {
+ let mut origin = origin_with(one_page()).await;
+ // No cooldown, so the second request below reaches the dial rather than the memory of
+ // the first. The cooldown itself is the next test.
+ origin.cooldown = Duration::ZERO;
+ // The declared host is `nowhere`, so re-dialling it fails without a network.
origin.sessions.write().await.remove("docs");
+
+ let res = origin.handle(get("/a.html", None)).await;
+ assert_eq!(
+ res.status(),
+ StatusCode::BAD_GATEWAY,
+ "a declared alias that will not connect is 502, not 404"
+ );
+ assert!(
+ origin.trouble.read().await.contains_key("docs"),
+ "the reason has to outlive the request that found it"
+ );
+
+ // Not latched. Asked again, the dial happens again -- a daemon that took the first
+ // failure as final would pass every check above and still need restarting.
+ //
+ // `at` is the evidence and the status is not: a second 502 is what a re-dial and a
+ // remembered failure both look like from outside, so a test that only read the status
+ // would pass whichever this did.
+ let first = origin.trouble.read().await["docs"].at;
+ assert_eq!(
+ origin.handle(get("/a.html", None)).await.status(),
+ StatusCode::BAD_GATEWAY
+ );
assert!(
- origin.session("docs").await.is_none(),
- "removing the session is what disabling does, and a request must then 404"
+ origin.trouble.read().await["docs"].at > first,
+ "the second request did not reach the dial"
);
+ }
+
+ /// But not once per request, because a page decides how often those arrive.
+ ///
+ /// `` in a loop is a remote document choosing how
+ /// often this machine opens an ssh. It cannot name a host that was not declared -- the set
+ /// is fixed at startup and is not `ssh_config` -- so the reach is a host the reader already
+ /// asked to have served. That makes it cheap, not free, and the rate is the part somebody
+ /// else was choosing.
+ #[tokio::test]
+ async fn a_page_cannot_choose_how_often_an_ssh_is_opened() {
+ let mut origin = origin_with(one_page()).await;
+ origin.cooldown = Duration::from_secs(300);
+ origin.sessions.write().await.remove("docs");
+
+ assert_eq!(
+ origin.handle(get("/a.html", None)).await.status(),
+ StatusCode::BAD_GATEWAY
+ );
+ let first = origin.trouble.read().await["docs"].at;
+
+ for _ in 0..5 {
+ assert_eq!(
+ origin.handle(get("/a.html", None)).await.status(),
+ StatusCode::BAD_GATEWAY,
+ "the answer is still the truth, it just costs nothing to give"
+ );
+ }
+ assert_eq!(
+ origin.trouble.read().await["docs"].at,
+ first,
+ "a loop of requests dialled more than once inside the cooldown"
+ );
+ }
+
+ /// A connection that dies is dropped, not held.
+ ///
+ /// Without this the daemon answers `sftp session is gone` for as long as it runs. It is
+ /// the one failure mode a reconnecting daemon must not have, and the only way to see it is
+ /// to kill a real connection under a live origin.
+ #[tokio::test]
+ async fn a_dead_connection_is_not_held() {
+ let (fs, stop) = one_page().spawn_stoppable().await;
+ let origin = origin_over(fs, Cache::default());
+ assert_eq!(
+ origin.handle(get("/a.html", None)).await.status(),
+ StatusCode::OK
+ );
+
+ stop.abort();
+ // The driver task notices when its reader fails, which is a scheduling hop away rather
+ // than a duration. Bounded so a change that stops it noticing fails here instead of
+ // hanging the suite.
+ for _ in 0..1000 {
+ if origin.live("docs").await.is_none() {
+ break;
+ }
+ tokio::task::yield_now().await;
+ }
+ assert!(
+ origin.live("docs").await.is_none(),
+ "a connection whose remote is gone must not stay in the map"
+ );
+
+ // And what follows is a re-dial rather than a corpse. Both are 502, so the status is
+ // not the evidence -- what it says is. `nowhere` is not a host, so a request that
+ // re-dialled reports ssh failing to reach it; one that went down the dead connection
+ // instead would report the pipe, and only the request after that would try again.
let res = origin.handle(get("/a.html", None)).await;
+ assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
+ let said = String::from_utf8_lossy(&body_of(res).await).to_string();
+ assert!(said.contains("is not connected"), "{said}");
+ assert!(
+ !said.contains("session closed"),
+ "the dead connection was used rather than replaced: {said}"
+ );
+
+ // The window this cannot reach: a connection that is gone but whose driver task has
+ // not noticed yet, because the notice arrives when its own read fails and a request
+ // can be handed down the pipe before that. An in-memory duplex closes synchronously,
+ // so there is no window here to open. It was measured instead against a real host --
+ // kill the ssh under a live daemon, reload once -- three times for three reloads, all
+ // 200. Before `alias` checked liveness *after* the attempt, that reload was a 502 and
+ // only the one after it worked.
+ }
+
+ /// A name nobody declared stays a 404.
+ ///
+ /// The three answers have to stay three. If everything unreachable became 502 then a typo
+ /// in a URL would read as somebody's host being down.
+ #[tokio::test]
+ async fn an_undeclared_name_is_not_a_gateway_failure() {
+ let origin = origin_with(one_page()).await;
+ let res = origin.handle(get_on("typo", "/a.html")).await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
+ #[tokio::test]
+ async fn disabling_a_host_closes_it_now() {
+ let origin = origin_with(one_page()).await;
+ // A host opened from the dashboard, not a declared alias. The two are closed on
+ // different terms now and this is the one disabling is about: it was never in
+ // `declared`, so removing the session is the whole of stopping it and the name goes
+ // back to being one this daemon does not serve. Sharing the `Arc` rather than opening
+ // a second remote means dropping this one leaves `docs` connected, which is the point
+ // -- disabling one host must not disturb another.
+ let session = Arc::clone(&origin.sessions.read().await["docs"]);
+ origin
+ .sessions
+ .write()
+ .await
+ .insert("opened".to_string(), session);
+ assert!(origin.live("opened").await.is_some());
+
+ origin.sessions.write().await.remove("opened");
+ assert!(origin.live("opened").await.is_none());
+ let res = origin.handle(get_on("opened", "/a.html")).await;
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+ assert_eq!(
+ origin.handle(get("/a.html", None)).await.status(),
+ StatusCode::OK,
+ "closing one must not disturb another"
+ );
+ }
+
/// Nothing about how to reach a host is reported to anything but the dashboard.
///
/// The point of the whole arrangement: `ssh_config` keeps the account, the port and the
@@ -4845,9 +5428,20 @@ mod tests {
// The origin stops answering, rather than answering with stale bytes out of the
// cache. An alias that is closed but still serving would be the worst of both.
+ //
+ // 503 and not 404, and the difference is the whole of this change: `docs` is still an
+ // alias this daemon serves, it has been switched off. A 404 here would say the name
+ // was never known, which is what the daemon used to say about every host that was
+ // merely asleep -- and the reader would go looking in the config file for a name that
+ // is sitting right there in it.
+ let res = origin.handle(get("/a.html", None)).await;
+ assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
+
+ // And a reload does not undo it. A stop that the next request reversed would not be a
+ // stop, and this is the exact path that would reverse it.
assert_eq!(
origin.handle(get("/a.html", None)).await.status(),
- StatusCode::NOT_FOUND
+ StatusCode::SERVICE_UNAVAILABLE
);
}
diff --git a/crates/ssh-browser/src/testing.rs b/crates/ssh-browser/src/testing.rs
index 6434f65..9193f73 100644
--- a/crates/ssh-browser/src/testing.rs
+++ b/crates/ssh-browser/src/testing.rs
@@ -127,13 +127,24 @@ impl FakeRemote {
/// Start serving, and hand back a client driving it through the real `SftpFs`,
/// including its writer coalescing and its round-trip counter.
pub async fn spawn(self) -> SftpFs {
+ self.spawn_stoppable().await.0
+ }
+
+ /// The same, but handing back the way to end the remote.
+ ///
+ /// Aborting it drops the server's half of the pipe, which ends the client's driver task --
+ /// which is what happens when an ssh dies. There is no other way to produce a dead
+ /// `SftpFs` to test against, and a daemon that reconnects but has never been asked to face
+ /// a dead connection is a daemon that reconnects in the comments.
+ pub async fn spawn_stoppable(self) -> (SftpFs, tokio::task::AbortHandle) {
let (client, server) = tokio::io::duplex(1 << 20);
let (cr, cw) = tokio::io::split(client);
let (sr, sw) = tokio::io::split(server);
- tokio::spawn(serve(self, sr, sw));
- SftpFs::over(cw, cr)
+ let serving = tokio::spawn(serve(self, sr, sw));
+ let fs = SftpFs::over(cw, cr)
.await
- .expect("handshake with the in-memory remote")
+ .expect("handshake with the in-memory remote");
+ (fs, serving.abort_handle())
}
}
diff --git a/extension/src/dashboard.ts b/extension/src/dashboard.ts
index c75ec62..052d792 100644
--- a/extension/src/dashboard.ts
+++ b/extension/src/dashboard.ts
@@ -15,6 +15,19 @@ interface OpenAlias {
url: string;
}
+/// A declared alias with nothing connected behind it.
+///
+/// The other half of `OpenAlias`, and the half that used to be impossible: an alias was either
+/// being served or the daemon had refused to start. Now a host can be asleep without taking
+/// the rest down, which is only an improvement if the reader is told which ones are.
+interface StalledAlias {
+ alias: string;
+ host: string;
+ url: string;
+ why?: string;
+ stopped: boolean;
+}
+
interface KnownHost {
alias: string;
host: string;
@@ -36,6 +49,7 @@ interface Reply {
detail: string;
suffix?: string;
open?: OpenAlias[];
+ stalled?: StalledAlias[];
hosts?: KnownHost[];
unusable?: { host: string; why: string }[];
url?: string;
@@ -206,6 +220,37 @@ function renderList(): void {
view.append(list);
}
+ // Between the sites and the hosts, because that is what they are: sites that are not up.
+ // Each says what to do about it, because "panza — not connected" is the same silence as not
+ // listing it at all.
+ for (const s of latest.stalled ?? []) {
+ const box = document.createElement("div");
+ box.className = "act";
+ box.append(node("h2", "", `${s.alias} is not connected`));
+ box.append(
+ node(
+ "p",
+ "bad-host",
+ s.stopped
+ ? `Stopped from here. It is in the daemon's configuration, so restarting serves it ` +
+ `again — reloading the page will not.`
+ : (s.why ?? `${s.host} did not answer.`),
+ ),
+ );
+ if (!s.stopped) {
+ const retry = document.createElement("a");
+ retry.className = "open-site";
+ retry.href = s.url;
+ retry.target = "_blank";
+ retry.rel = "noreferrer";
+ // Opening it *is* the retry. A button here that dialled behind the reader's back would
+ // be a second way to do the same thing, and the one they already know is the address.
+ retry.textContent = `Try ${s.url}`;
+ box.append(retry);
+ }
+ view.append(box);
+ }
+
const spare = hosts.filter((h) => !open.some((o) => o.alias === h.alias));
view.append(node("h2", "", "hosts you can serve"));
if (spare.length === 0) {