lmjtfy.git / apps / lmjtfy / src / host.rs
host.rsannotatedhost.rssource491 lines · 22.0 KB · raw

Which of the site's addresses a request came to, and what each one does.

There are two homes. lmjtfy.fun is the site: the page, /ask, the archive's feed and sockets. code.lmjtfy.fun is the code: the clone routes, the code pages and a front page listing every repository (clone::Repo::ALL), and nothing else, so no question can be asked there and no socket opened. The old addresses (www.lmjtfy.fun, the workers.dev one) lead to the right home for good.

The split is made twice, so neither half can be forgotten. gate decides what happens to a request before any route sees it: a repository's path on lmjtfy.fun or an old address is sent to the code host. And fetch (lib.rs) gives each host its own router, so the routes a host does not serve are not there to be reached by a path gate did not think of.

Pure: strings and a method in, a decision out, tested natively.

18use axum::http::{Method, StatusCode};
20use crate::clone::Repo;

The site's address, bare.

23pub const HOME: &str = "lmjtfy.fun";

The code's: git clone https://code.lmjtfy.fun/<repo>.git.

25pub const CODE: &str = "code.lmjtfy.fun";

The addresses that lead to HOME: where the site was first served, and the same name with www.

28const ELSEWHERE: [&str; 2] = ["lmjtfy.deizel.workers.dev", "www.lmjtfy.fun"];

Whether the old addresses send the code to the code host: the Worker var GIT_REDIRECT (wrangler.toml).

A closed type, so a half-way state cannot be written: Off is how the site was before the code host (the apex serves the clones itself, the code host serves them too, and no page offers an address that is not yet known to work); On is the end state. One deploy ships the code host with Off; once it is checked, a second turns On (README, "Rolling out").

38#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
39pub enum GitRedirect {

The default, and the fallback for a value that is neither.

41    #[default]
42    Off,
43    On,
44}

A GIT_REDIRECT that is neither on nor off.

47#[derive(Debug, PartialEq, Eq)]
48pub struct BadSwitch(pub String);
50impl std::fmt::Display for BadSwitch {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "GIT_REDIRECT is {:?}; it must be exactly \"on\" or \"off\". Treating it as \"off\": the apex keeps serving clones.", self.0)
53    }
54}
55
56impl GitRedirect {

The var's name.

58    pub const VAR: &'static str = "GIT_REDIRECT";

Unset is Off. Anything but exactly on or off is refused, not guessed at ("true", "ON", " on" and "" included): the caller says so loudly and runs as Off (resolve).

63    pub fn parse(value: Option<&str>) -> Result<GitRedirect, BadSwitch> {
64        match value {
65            None | Some("off") => Ok(GitRedirect::Off),
66            Some("on") => Ok(GitRedirect::On),
67            Some(other) => Err(BadSwitch(other.to_owned())),
68        }
69    }

The switch to run with, and the complaint to log if the var was refused. Off is the fallback because it is the behaviour that cannot break a working clone: it never sends anyone to an address that may not be live, and a mistyped var then fails safe, visibly in the logs, rather than redirecting every clone.

76    pub fn resolve(value: Option<&str>) -> (GitRedirect, Option<String>) {
77        match GitRedirect::parse(value) {
78            Ok(switch) => (switch, None),
79            Err(bad) => (GitRedirect::Off, Some(bad.to_string())),
80        }
81    }
83    pub fn is_on(self) -> bool {
84        self == GitRedirect::On
85    }
86}

Which address a request's Host is.

89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum Host {

lmjtfy.fun.

92    Home,

code.lmjtfy.fun.

94    Code,

An old address, which leads to the right home.

96    Elsewhere,

Anything else: staging, a dev server. It serves everything, git included, and redirects nothing, so a change can be tried where it is.

99    Other,
100}
102impl Host {
103    pub fn of(host: &str) -> Host {
104        let host = host.to_ascii_lowercase();
105        match host.as_str() {
106            HOME => Host::Home,
107            CODE => Host::Code,
108            _ if ELSEWHERE.contains(&host.as_str()) => Host::Elsewhere,
109            _ => Host::Other,
110        }
111    }

Whether this address serves the repositories itself. The code host and a dev server or staging always do; the site's own addresses only until the redirect is on.

116    pub fn serves_git(self, redirect: GitRedirect) -> bool {
117        match self {
118            Host::Code | Host::Other => true,
119            Host::Home | Host::Elsewhere => !redirect.is_on(),
120        }
121    }

Whether this address serves the repositories' release files (release.rs). Only the code host, and a dev server or staging, which have no code host of their own. Never the site's addresses, whatever the redirect: downloads are not a thing the site did before the code host existed, so there is nothing to keep working there.

128    pub fn serves_downloads(self) -> bool {
129        matches!(self, Host::Code | Host::Other)
130    }

Where a clone is made from, for the commands pages offer: the code host for the site's own addresses once the redirect is on (it is then known to work), and wherever the page is until then, and for a staging or dev server, which serves the clone itself. So a page never advertises an address that has not been checked.

137    pub fn code_origin(self, own: &str, redirect: GitRedirect) -> String {
138        match self {
139            Host::Home | Host::Elsewhere if redirect.is_on() => format!("https://{CODE}"),
140            _ => own.to_owned(),
141        }
142    }

Where the site's front page is, for a link from a page that may be on the code host.

146    pub fn home_link(self) -> &'static str {
147        match self {
148            Host::Code => "https://lmjtfy.fun/",
149            _ => "/",
150        }
151    }

Whether pages here take part in what the site does live: the online count and toasts over /live, and the report of a page to /seen. The code host has neither route.

156    pub fn is_live(self) -> bool {
157        self != Host::Code
158    }
159}

A request, as far as gate reads it.

162pub struct Asked<'a> {
163    pub host: &'a str,
164    pub method: &'a Method,

The path and the query, as sent: /lmjtfy.git/info/refs?service=git-upload-pack.

166    pub target: &'a str,

A WebSocket upgrade.

168    pub socket: bool,

A request a page made for itself (Sec-Fetch-Mode other than navigate), rather than a person or git following a link.

171    pub own: bool,
172}

What to do with a request before routing it.

175#[derive(Debug, PartialEq, Eq)]
176pub enum Gate {

Route it.

178    Pass,

Send it on for good.

180    Moved { to: String, status: StatusCode },

Answer 405 with this: a method that was never served at the address the repository left.

183    Refused(String),
184}

The repository a path is under (/lmjtfy.git, /lmjtfy.git/info/refs), if it is one that is served. Only the exact name counts: /lmjtfy.gitx and /other.git are not.

189pub fn repository(path: &str) -> Option<Repo> {
190    Repo::named(path.strip_prefix('/')?.split('/').next()?)
191}
193pub fn gate(asked: &Asked<'_>, redirect: GitRedirect) -> Gate {
194    let host = Host::of(asked.host);
195    if matches!(host, Host::Code | Host::Other) {
196        return Gate::Pass;
197    }
198    let path = asked.target.split_once('?').map_or(asked.target, |(path, _)| path);
199    let get = matches!(*asked.method, Method::GET | Method::HEAD);
200    // The code left for its own address. Git follows the redirect of its
201    // first request, `GET info/refs?service=…`, and makes every request after
202    // it, the `POST`s of the pack, at the address it was sent to. So a
203    // `POST` that arrives here is not git following the redirect; it is
204    // something that was never told, and is told. `308`, not `301`: the
205    // method and the path are kept, and a client that ever did send a `POST`
206    // would send it again to the new address.
207    // With the redirect off, the code is not moved: the old addresses do what
208    // they did before the code host existed, below.
209    // The release files (`/whiskers/latest/...`) follow the clone: with the
210    // redirect on, a `GET` is sent to the code host, and anything else is
211    // told. With it off they are not served here at all (`serves_downloads`),
212    // and the path is a `404`.
213    if redirect.is_on() && crate::release::under(path).is_some() {
214        return if get {
215            Gate::Moved { to: format!("https://{CODE}{}", asked.target), status: StatusCode::PERMANENT_REDIRECT }
216        } else {
217            Gate::Refused(format!("The downloads are at https://{CODE}, not here: https://{CODE}{path}"))
218        };
219    }
220    if redirect.is_on() && repository(path).is_some() {
221        return if get {
222            Gate::Moved { to: format!("https://{CODE}{}", asked.target), status: StatusCode::PERMANENT_REDIRECT }
223        } else {
224            Gate::Refused(format!("The code is at https://{CODE}, not here: git clone https://{CODE}{path}"))
225        };
226    }
227    // The old addresses of the site itself. A page still open there is left
228    // to finish there: its socket and its own requests would only break if
229    // sent on, and it moves the next time it is loaded.
230    if host == Host::Elsewhere && get && !asked.socket && !asked.own {
231        return Gate::Moved { to: format!("https://{HOME}{}", asked.target), status: StatusCode::MOVED_PERMANENTLY };
232    }
233    Gate::Pass
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn ask<'a>(host: &'a str, method: &'a Method, target: &'a str) -> Asked<'a> {
241        Asked { host, method, target, socket: false, own: false }
242    }
243
244    use GitRedirect::{Off, On};
245
246    const OLD: [&str; 3] = [HOME, "www.lmjtfy.fun", "lmjtfy.deizel.workers.dev"];
247
248    #[test]
249    fn an_address_is_told_from_the_others() {
250        assert_eq!(Host::of("lmjtfy.fun"), Host::Home);
251        assert_eq!(Host::of("LMJTFY.fun"), Host::Home);
252        assert_eq!(Host::of("code.lmjtfy.fun"), Host::Code);
253        assert_eq!(Host::of("www.lmjtfy.fun"), Host::Elsewhere);
254        assert_eq!(Host::of("lmjtfy.deizel.workers.dev"), Host::Elsewhere);
255        assert_eq!(Host::of("staging.lmjtfy.fun"), Host::Other);
256        assert_eq!(Host::of("localhost:8787"), Host::Other);
257        // Not a suffix match: a name that merely ends in ours is no one's.
258        assert_eq!(Host::of("evil-code.lmjtfy.fun"), Host::Other);
259        assert_eq!(Host::of("code.lmjtfy.fun.evil.example"), Host::Other);
260        assert_eq!(Host::of(""), Host::Other);
261    }
262
263    #[test]
264    fn only_the_code_host_lacks_the_live_routes() {
265        assert!(!Host::Code.is_live());
266        for host in [Host::Home, Host::Elsewhere, Host::Other] {
267            assert!(host.is_live());
268        }
269        assert_eq!(Host::Code.home_link(), "https://lmjtfy.fun/");
270        assert_eq!(Host::Home.home_link(), "/");
271    }
272
273    #[test]
274    fn clones_are_offered_from_the_code_host_only_once_it_is_on() {
275        assert_eq!(Host::Home.code_origin("https://lmjtfy.fun", On), "https://code.lmjtfy.fun");
276        assert_eq!(Host::Elsewhere.code_origin("https://www.lmjtfy.fun", On), "https://code.lmjtfy.fun");
277        assert_eq!(Host::Code.code_origin("https://code.lmjtfy.fun", On), "https://code.lmjtfy.fun");
278        assert_eq!(Host::Other.code_origin("http://localhost:8787", On), "http://localhost:8787");
279        // Off: nothing is advertised that has not been checked; the page's
280        // own address serves the clone.
281        assert_eq!(Host::Home.code_origin("https://lmjtfy.fun", Off), "https://lmjtfy.fun");
282        assert_eq!(Host::Elsewhere.code_origin("https://www.lmjtfy.fun", Off), "https://www.lmjtfy.fun");
283        assert_eq!(Host::Code.code_origin("https://code.lmjtfy.fun", Off), "https://code.lmjtfy.fun");
284        assert_eq!(Host::Other.code_origin("http://localhost:8787", Off), "http://localhost:8787");
285    }
286
287    #[test]
288    fn the_switch_is_parsed_strictly_and_defaults_off() {
289        assert_eq!(GitRedirect::default(), Off);
290        assert_eq!(GitRedirect::parse(None), Ok(Off));
291        assert_eq!(GitRedirect::parse(Some("off")), Ok(Off));
292        assert_eq!(GitRedirect::parse(Some("on")), Ok(On));
293        for bad in ["", "ON", "Off", "true", "1", " on", "on ", "yes", "enabled"] {
294            assert_eq!(GitRedirect::parse(Some(bad)), Err(BadSwitch(bad.to_owned())), "{bad:?}");
295        }
296    }

A var that is wrong runs as off, and says so; one that is right says nothing.

300    #[test]
301    fn a_refused_value_falls_back_to_off_with_a_complaint() {
302        assert_eq!(GitRedirect::resolve(None), (Off, None));
303        assert_eq!(GitRedirect::resolve(Some("on")), (On, None));
304        let (switch, complaint) = GitRedirect::resolve(Some("ON"));
305        assert_eq!(switch, Off);
306        let complaint = complaint.expect("a complaint");
307        assert!(complaint.contains("GIT_REDIRECT") && complaint.contains("\"ON\"") && complaint.contains("off"), "{complaint}");
308    }
310    #[test]
311    fn who_serves_the_repositories_in_each_mode() {
312        for (host, off, on) in [(Host::Home, true, false), (Host::Elsewhere, true, false), (Host::Code, true, true), (Host::Other, true, true)] {
313            assert_eq!(host.serves_git(Off), off, "{host:?} off");
314            assert_eq!(host.serves_git(On), on, "{host:?} on");
315        }
316    }

Off is the site as it was before the code host: nothing under a repository's name is moved or refused at the apex, www and workers.dev get the 301 of everything to the apex (git follows it), and the code host passes everything.

322    #[test]
323    fn with_the_redirect_off_the_old_addresses_do_what_they_did_before() {
324        let refs = "/lmjtfy.git/info/refs?service=git-upload-pack";
325        for method in [Method::GET, Method::HEAD, Method::POST] {
326            assert_eq!(gate(&ask(HOME, &method, refs), Off), Gate::Pass, "{method}");
327            assert_eq!(gate(&ask(HOME, &method, "/lmjtfy.git/git-upload-pack"), Off), Gate::Pass, "{method}");
328        }
329        for host in ["www.lmjtfy.fun", "lmjtfy.deizel.workers.dev"] {
330            assert_eq!(
331                gate(&ask(host, &Method::GET, refs), Off),
332                Gate::Moved { to: format!("https://lmjtfy.fun{refs}"), status: StatusCode::MOVED_PERMANENTLY },
333                "{host}"
334            );
335            assert_eq!(gate(&ask(host, &Method::POST, "/lmjtfy.git/git-upload-pack"), Off), Gate::Pass, "{host}");
336        }
337        for target in [refs, "/", "/lmjtfy.git"] {
338            assert_eq!(gate(&ask(CODE, &Method::GET, target), Off), Gate::Pass);
339        }
340    }

Everything that is not about the repositories is the same in both modes.

343    #[test]
344    fn the_modes_differ_only_in_what_is_about_a_repository() {
345        for host in OLD.into_iter().chain([CODE, "staging.lmjtfy.fun"]) {
346            for target in ["/", "/rules", "/ask", "/feed?ms=1", "/nosuchrepo.git/info/refs"] {
347                for method in [Method::GET, Method::POST] {
348                    for (socket, own) in [(false, false), (true, false), (false, true)] {
349                        let asked = Asked { host, method: &method, target, socket, own };
350                        assert_eq!(gate(&asked, Off), gate(&asked, On), "{method} {host}{target}");
351                    }
352                }
353            }
354        }
355    }

Every repository served, at every address that is not the code host, with and without a query and a trailing path: the same path and query at code.lmjtfy.fun, with a 308.

360    #[test]
361    fn every_repository_leads_to_the_code_host_with_its_path_and_query() {
362        let suffixes = [
363            "",
364            "/",
365            "/info/refs?service=git-upload-pack",
366            "/info/refs",
367            "/git-upload-pack",
368            "/apps/lmjtfy/src/lib.rs",
369            "/apps/lmjtfy/src/lib.rs?raw",
370            "/README.md?view=agents&x=a%20b",
371            "?view=agents",
372        ];
373        for host in OLD {
374            for repo in Repo::ALL {
375                for suffix in suffixes {
376                    for method in [Method::GET, Method::HEAD] {
377                        let target = format!("/{}{suffix}", repo.served());
378                        assert_eq!(
379                            gate(&ask(host, &method, &target), On),
380                            Gate::Moved { to: format!("https://code.lmjtfy.fun{target}"), status: StatusCode::PERMANENT_REDIRECT },
381                            "{method} {host}{target}"
382                        );
383                    }
384                }
385            }
386        }
387    }

git's own first request, which it follows, is among them, and it does not matter what else the client sent: git sends neither header the site's own pages do.

392    #[test]
393    fn gits_first_request_is_followed_to_the_same_suffix() {
394        let target = "/lmjtfy.git/info/refs?service=git-upload-pack";
395        let mut asked = ask(HOME, &Method::GET, target);
396        asked.own = true;
397        asked.socket = true;
398        assert_eq!(
399            gate(&asked, On),
400            Gate::Moved { to: format!("https://code.lmjtfy.fun{target}"), status: StatusCode::PERMANENT_REDIRECT }
401        );
402    }

A POST is not redirected: a client that sends one at the old address was not following a redirect of its first request (git sends the rest to the address that redirect gave it), and it is told where to go.

407    #[test]
408    fn a_post_to_the_old_address_is_told_where_the_code_is() {
409        for host in OLD {
410            for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
411                let got = gate(&ask(host, &method, "/lmjtfy.git/git-upload-pack"), On);
412                let Gate::Refused(why) = got else { panic!("{method} {host}: {got:?}") };
413                assert!(why.contains("https://code.lmjtfy.fun/lmjtfy.git/git-upload-pack"), "{why}");
414            }
415        }
416    }
418    #[test]
419    fn what_is_not_a_served_repository_is_not_sent_on() {
420        for target in ["/nosuchrepo.git/info/refs?service=git-upload-pack", "/lmjtfy.gitx", "/lmjtfy", "/x/lmjtfy.git", "/lmjtfy.git.evil/a", "//lmjtfy.git"] {
421            assert_eq!(gate(&ask(HOME, &Method::GET, target), On), Gate::Pass, "{target}");
422        }
423    }
424
425    #[test]
426    fn the_code_host_and_a_dev_server_are_not_redirected() {
427        for host in ["code.lmjtfy.fun", "staging.lmjtfy.fun", "localhost:8787", "127.0.0.1:8787"] {
428            for target in ["/lmjtfy.git/info/refs?service=git-upload-pack", "/", "/rules"] {
429                for method in [Method::GET, Method::POST] {
430                    assert_eq!(gate(&ask(host, &method, target), On), Gate::Pass, "{method} {host}{target}");
431                }
432            }
433        }
434    }

What www and the workers.dev address did before the code host existed, kept: a permanent redirect of everything else to the bare address, 301, except what a page still open there is making.

439    #[test]
440    fn the_old_addresses_still_lead_to_the_site_for_good() {
441        let moved = |host, method: &Method, target, socket, own| {
442            gate(&Asked { host, method, target, socket, own }, On)
443        };
444        let site = |target: &str| Gate::Moved { to: format!("https://lmjtfy.fun{target}"), status: StatusCode::MOVED_PERMANENTLY };
445        assert_eq!(moved("www.lmjtfy.fun", &Method::GET, "/rules", false, false), site("/rules"));
446        assert_eq!(moved("lmjtfy.deizel.workers.dev", &Method::GET, "/?q=is+it%3F", false, false), site("/?q=is+it%3F"));
447        assert_eq!(moved("www.lmjtfy.fun", &Method::HEAD, "/", false, true), Gate::Pass);
448        assert_eq!(moved("www.lmjtfy.fun", &Method::POST, "/ask", false, false), Gate::Pass);
449        assert_eq!(moved("www.lmjtfy.fun", &Method::GET, "/live", true, false), Gate::Pass);
450        assert_eq!(moved("www.lmjtfy.fun", &Method::GET, "/feed?ms=1&q=x", false, true), Gate::Pass);
451        // The apex serves the site: nothing to move.
452        assert_eq!(moved(HOME, &Method::GET, "/", false, false), Gate::Pass);
453        assert_eq!(moved(HOME, &Method::GET, "/rules?a=b", false, false), Gate::Pass);
454    }
456    #[test]
457    fn a_repository_is_found_in_a_path_only_by_its_first_segment() {
458        assert_eq!(repository("/jevcrates.git"), Some(Repo::Jevcrates));
459        assert_eq!(repository("/jevcrates.git/info/refs"), Some(Repo::Jevcrates));
460        assert_eq!(repository("/"), None);
461        assert_eq!(repository(""), None);
462        assert_eq!(repository("/a/jevcrates.git"), None);
463    }
464
465    #[test]
466    fn release_files_are_the_code_hosts_alone() {
467        for host in [Host::Home, Host::Elsewhere] {
468            assert!(!host.serves_downloads());
469        }
470        for host in [Host::Code, Host::Other] {
471            assert!(host.serves_downloads());
472        }
473        // Like a clone: sent to the code host once the redirect is on, by a
474        // 308 that keeps the path and the query, from every old address.
475        for host in OLD {
476            let moved = gate(&ask(host, &Method::GET, "/whiskers/latest/arm64.apk"), On);
477            assert_eq!(moved, Gate::Moved { to: format!("https://{CODE}/whiskers/latest/arm64.apk"), status: StatusCode::PERMANENT_REDIRECT }, "{host}");
478            assert!(matches!(gate(&ask(host, &Method::HEAD, "/whiskers/releases/1.0/a.apk"), On), Gate::Moved { .. }), "{host}");
479            assert!(matches!(gate(&ask(host, &Method::POST, "/whiskers/latest/arm64.apk"), On), Gate::Refused(_)), "{host}");
480            // A path that is not a repository's is not the code's.
481            let other = gate(&ask(host, &Method::GET, "/nixos-config/latest/x"), On);
482            assert!(!matches!(other, Gate::Moved { status: StatusCode::PERMANENT_REDIRECT, .. } | Gate::Refused(_)), "{host}: {other:?}");
483        }
484        // With it off the apex does not serve them (no route) and moves nothing.
485        assert_eq!(gate(&ask(HOME, &Method::GET, "/whiskers/latest/arm64.apk"), Off), Gate::Pass);
486        // Where they are served, nothing is moved.
487        for host in [CODE, "staging.lmjtfy.fun", "localhost:8787"] {
488            assert_eq!(gate(&ask(host, &Method::GET, "/whiskers/latest/arm64.apk"), On), Gate::Pass, "{host}");
489        }
490    }
491}