use std::net::SocketAddr;
use std::time::Duration;
use toml_edit::value;
use crate::config::name::ProfileName;
use crate::config::profile::{Home, Profile};
use crate::config::store::Store;
use crate::config::value::HostKey;
use crate::error::Error;
use crate::home_check::{self, AwayReason, Timeouts, Verdict};
use crate::keys::store::DeviceSecrets;
use crate::transport;
use super::{away, prompt};
pub(crate) const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
pub fn run(store: &Store, name: &ProfileName, yes: bool) -> Result<i32, Error> {
let profile = store.load_profile(name)?;
let Some(home) = profile.home.clone() else {
return Err(Error::msg(format!(
"profile `{name}` has no [home] SSH server to check. Add one with \
`gitaway profile set {name} home.ssh <host:port>`"
)));
};
let target = profile
.hosts
.route(&home.ssh.host, home.ssh_port())
.ok_or_else(|| {
Error::msg(format!(
"profile `{name}`'s [hosts] doesn't say where {} is through the tunnel. Add it \
with `gitaway profile set {name} 'hosts.\"{}\"' <address through the tunnel>`",
home.ssh, home.ssh.host
))
})?;
if home_check::check(&home)? == Verdict::Home {
println!(
"Home check: you're home, and {} proved the pinned host key directly, so \
commands connect to it directly.\n\
Tunnel: not tested at home. From inside the home network it would need your \
router to reach its own public address, which many can't (no \"hairpin NAT\"). \
Run `gitaway check {name}` on another network to test it.",
home.ssh
);
return Ok(0);
}
let (_lock, secrets) = away::take_tunnel(store, name)?;
let runtime = away::runtime()?;
let result = runtime.block_on(async {
let seen = capture_through_tunnel(name, &profile, &home, target, secrets)
.await
.map_err(|error| {
Error::msg(format!(
"{error}\nIf you're on the home network, your router may not be able to reach \
its own public address, so the tunnel can only be tested from another network."
))
})?;
let (home, ok) = settle_key(store, name, home, seen, yes)?;
report_home_check(&home).await;
Ok(if ok { 0 } else { 1 })
});
runtime.shutdown_timeout(away::SHUTDOWN_GRACE);
result
}
async fn capture_through_tunnel(
name: &ProfileName,
profile: &Profile,
home: &Home,
target: SocketAddr,
secrets: DeviceSecrets,
) -> Result<HostKey, Error> {
let transport = transport::open(name.as_str(), profile, secrets).await?;
let stream = transport.connect(target).await?;
println!(
"Tunnel: works. Reached {} through it, at {target}.",
home.ssh
);
let pinned = home.host_key.as_ref();
home_check::capture(stream, CAPTURE_TIMEOUT, pinned.map(HostKey::algorithm))
.await
.map_err(|e| Error::msg(format!("{} answered through the tunnel, but {e}", home.ssh)))
}
fn settle_key(
store: &Store,
name: &ProfileName,
mut home: Home,
seen: HostKey,
yes: bool,
) -> Result<(Home, bool), Error> {
println!(
"Home server's host key: {} {}",
algorithm_name(&seen),
seen.fingerprint()
);
let question = match &home.host_key {
Some(pinned) if pinned.same_key(&seen) => {
println!("It matches the key pinned in profile `{name}`.");
return Ok((home, true));
}
Some(pinned) => {
println!(
"WARNING: it differs from the key pinned in profile `{name}` ({}).\n\
The tunnel proves this is your own network, so either the server's key changed \
(a reinstall, say), or [hosts] sends {} to a different machine than the one at \
home. Check on the server with `ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub`.",
pinned.fingerprint(),
home.ssh
);
"Replace the pinned key with this one?"
}
None => "Pin this key, so gitaway can recognise home?",
};
if !yes && !(prompt::interactive() && prompt::confirm(question)?) {
println!("Left profile `{name}` unchanged. Pass --yes to pin the key without asking.");
return Ok((home, false));
}
store.update_profile(name, |doc| {
doc["home"]["host_key"] = value(seen.to_string());
Ok(())
})?;
println!("Pinned it in profile `{name}`.");
home.host_key = Some(seen);
Ok((home, true))
}
async fn report_home_check(home: &Home) {
match home_check::verify(home, Timeouts::default()).await {
Verdict::Home => println!(
"Home check: you're home, so commands connect to {} directly.",
home.ssh
),
Verdict::Away(AwayReason::NotPinned) => {
println!("Home check: no key pinned, so commands always use the tunnel.");
}
Verdict::Away(reason) => {
println!("Home check: away from home ({reason}), so commands use the tunnel.");
if matches!(
reason,
AwayReason::ConnectTimeout(_) | AwayReason::Unreachable(_)
) {
println!(
" Actually home? Then a firewall or security tool on the server (such as \
fail2ban, CrowdSec or a NAS's login protection) may be blocking this device \
at {}: allow your home network in it.",
home.ssh
);
}
}
}
}
pub(crate) fn algorithm_name(key: &HostKey) -> String {
key.to_string()
.split_whitespace()
.next()
.unwrap_or_default()
.to_owned()
}