diff --git a/README.md b/README.md index 17beb72..4ac0492 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ Terminal=false **Attach over SSH** to use the HTTPS clone URL instead, which asks for a credential on every push. + The SSH host comes from the **server URL you configured**, not from the URL + Gitea advertises. Gitea builds that from its own `SSH_DOMAIN`, which is + whatever it was told to announce — and a server announcing a public hostname + whose SSH port is only reachable on the LAN hands out a remote that connects + from nowhere, with the failure surfacing at the first push rather than at + creation. The server URL is known to work, because the API call just went + over it. The port Gitea reports is kept as-is. So on a tailnet, setting the + Gitea server to `http://100.x.y.z:4000` gets you + `ssh://git@100.x.y.z:2228/…`, which works on and off the LAN alike. + After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s, and pushes (setting the upstream on the first push). Until origin is set it commits locally and says so in the log. diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 9762520..14f394b 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -1226,8 +1226,11 @@ impl App { save_now |= ui .checkbox(&mut self.config.gitea_use_ssh, "Attach over SSH") .on_hover_text( - "SSH pushes on a key. Turn this off to use the HTTPS clone \ - URL, which asks for a credential on every push.", + "SSH pushes on a key. The host is taken from the server URL \ + above rather than from what Gitea advertises, so the remote \ + points at an address this machine can actually reach. Turn \ + this off to use the HTTPS clone URL, which asks for a \ + credential on every push.", ) .changed(); ui.add_space(4.0); diff --git a/src/gitea.rs b/src/gitea.rs index 65f2fcc..e49cdb0 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -124,6 +124,80 @@ pub fn normalize_name(name: &str) -> String { out.trim_matches(|c| c == '-' || c == '.').to_string() } +/// The host in a URL: no scheme, no credentials, no port, no path. +pub fn host_of(url: &str) -> &str { + let s = url.trim(); + let s = s.split_once("://").map_or(s, |(_, rest)| rest); + let s = s.split(['/', '?', '#']).next().unwrap_or(s); + let s = s.rsplit_once('@').map_or(s, |(_, host)| host); + // Only a numeric tail is a port; anything else is part of the host. + match s.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, + _ => s, + } +} + +/// Rewrite the host of an SSH clone URL to `host`, keeping user, port and path. +/// +/// Gitea builds `ssh_url` from its own `SSH_DOMAIN`, which is whatever the +/// server was configured to advertise — not necessarily an address the machine +/// running this app can reach. A server told to advertise a public hostname +/// whose SSH port is only open on the LAN hands out a URL that connects from +/// nowhere, and the failure only shows up at the first push. +/// +/// The app already knows one address that works, because it just made an API +/// call over it, so the created remote is pointed at that host instead. Both +/// URL shapes git accepts are handled: `ssh://user@host:port/path` and the +/// scp-like `user@host:path`. +pub fn rehost_ssh_url(ssh_url: &str, host: &str) -> String { + let host = host.trim(); + if host.is_empty() || ssh_url.trim().is_empty() { + return ssh_url.to_string(); + } + if let Some(rest) = ssh_url.strip_prefix("ssh://") { + let (authority, path) = match rest.find('/') { + Some(i) => rest.split_at(i), + None => (rest, ""), + }; + let mut out = String::from("ssh://"); + if let Some((user, _)) = authority.rsplit_once('@') { + out.push_str(user); + out.push('@'); + } + out.push_str(host); + if let Some(port) = port_of(authority) { + out.push(':'); + out.push_str(port); + } + out.push_str(path); + return out; + } + // scp-like: everything before the first colon is [user@]host. + if let Some((authority, path)) = ssh_url.split_once(':') { + let mut out = String::new(); + if let Some((user, _)) = authority.rsplit_once('@') { + out.push_str(user); + out.push('@'); + } + out.push_str(host); + out.push(':'); + out.push_str(path); + return out; + } + ssh_url.to_string() +} + +/// The numeric port of an `[user@]host[:port]` authority, if it has one. +fn port_of(authority: &str) -> Option<&str> { + let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + match hostport.rsplit_once(':') { + Some((_, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => { + Some(port) + } + _ => None, + } +} + /// Create `repo` under the token's own account, returning what the server holds. /// /// A name that is already taken is not treated as a failure: the existing @@ -167,7 +241,7 @@ pub fn create(server: &str, user: &str, token: &str, repo: &NewRepo) -> Result { let text = resp .into_string() @@ -177,7 +251,13 @@ pub fn create(server: &str, user: &str, token: &str, repo: &NewRepo) -> Result fetch(&agent, &api, token, user, &name), Err(e) => Err(friendly_error(&api, e)), - } + }; + // Point SSH at the host the API call just succeeded over, rather than + // whatever the server advertises — see `rehost_ssh_url`. + repo.map(|mut repo| { + repo.ssh_url = rehost_ssh_url(&repo.ssh_url, host_of(server)); + repo + }) } /// Read back a repository that already exists. @@ -294,6 +374,52 @@ mod tests { assert_eq!(normalize_name("!!!"), ""); } + #[test] + fn the_ssh_host_becomes_the_one_the_api_call_reached() { + // The real shape of the bug: Gitea advertises a public hostname whose + // SSH port is only open on the LAN, so the URL connects from nowhere. + assert_eq!( + rehost_ssh_url( + "ssh://git@gitlab.example.ca:2228/landon/book.git", + "100.106.219.48", + ), + "ssh://git@100.106.219.48:2228/landon/book.git" + ); + // The port is the server's business and is kept; so is the path. + assert_eq!( + rehost_ssh_url("ssh://git@old/deep/path/repo.git", "new"), + "ssh://git@new/deep/path/repo.git" + ); + } + + #[test] + fn the_scp_like_url_shape_is_rewritten_too() { + // Gitea emits this form when it is not using a custom SSH port. + assert_eq!( + rehost_ssh_url("git@gitlab.example.ca:landon/book.git", "100.106.219.48"), + "git@100.106.219.48:landon/book.git" + ); + } + + #[test] + fn rehosting_leaves_a_url_alone_when_there_is_nothing_to_put_in() { + let url = "ssh://git@host:2228/landon/book.git"; + assert_eq!(rehost_ssh_url(url, ""), url); + assert_eq!(rehost_ssh_url(url, " "), url); + assert_eq!(rehost_ssh_url("", "newhost"), ""); + } + + #[test] + fn the_host_is_pulled_out_of_the_server_url() { + assert_eq!(host_of("http://100.106.219.48:4000"), "100.106.219.48"); + assert_eq!(host_of("https://git.example.com/"), "git.example.com"); + assert_eq!(host_of("https://git.example.com/api/v1"), "git.example.com"); + // A bare host, as someone might type it. + assert_eq!(host_of("192.168.1.11:4000"), "192.168.1.11"); + // A non-numeric tail after the colon is not a port. + assert_eq!(host_of("http://user:pass@git.example.com/x"), "git.example.com"); + } + #[test] fn the_clone_urls_come_out_of_the_reply() { // Trimmed to the fields used; a real reply carries dozens more, which