use std::net::SocketAddr;
use std::path::Path;
use std::time::Duration;
use crate::wire::DEFAULT_MAX_DATAGRAM;
use bytes::Bytes;
use iroh::endpoint::{
presets, Connection, ConnectionError, IdleTimeout, PathId, QuicTransportConfig, VarInt,
};
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, RelayUrl, SecretKey};
use secrecy::{ExposeSecret, SecretString};
use zeroize::{Zeroize, Zeroizing};
pub mod admission;
mod keyfile;
#[expect(
clippy::expect_used,
reason = "300s is far below IdleTimeout's varint ceiling; the conversion is statically infallible"
)]
#[allow(
clippy::duration_suboptimal_units,
reason = "`from_secs(300)` is the intended, readable idle timeout"
)]
fn koh_transport_config() -> QuicTransportConfig {
QuicTransportConfig::builder()
.keep_alive_interval(Duration::from_secs(5))
.max_idle_timeout(Some(
IdleTimeout::try_from(Duration::from_secs(300)).expect("300s fits in IdleTimeout"),
))
.build()
}
pub const ALPN: &[u8] = b"koh/iroh/1";
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("secret key file is invalid, a symlink, or not a regular file")]
BadKeyFile,
#[error("could not parse endpoint id: {0}")]
BadEndpointId(String),
#[error("encrypted identity key: {0}")]
Keyfile(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub fn load_or_create_secret_key(path: &Path) -> Result<SecretKey, SetupError> {
if path.exists() {
if let Some(parent) = path.parent() {
ensure_state_dir_secure(parent)?;
}
let mut text = read_key_file_secure(path)?;
let pass = resolve_key_passphrase(path)?;
let secret = keyfile::decrypt_key(&text, pass.expose_secret())
.map_err(|e| SetupError::Keyfile(e.to_string()))?;
let sk = SecretKey::from_bytes(&secret);
text.zeroize();
Ok(sk)
} else {
let sk = generate_secret_key();
if let Some(parent) = path.parent() {
create_dir_private(parent)?;
ensure_state_dir_secure(parent)?;
}
let pass = resolve_new_key_passphrase(path)?;
write_identity_key(path, &sk, pass.expose_secret())?;
Ok(sk)
}
}
fn resolve_key_passphrase(path: &Path) -> Result<SecretString, SetupError> {
use std::io::IsTerminal as _;
if let Ok(p) = std::env::var("KOH_KEY_PASSPHRASE") {
if !p.is_empty() {
return Ok(SecretString::from(p));
}
}
if std::io::stdin().is_terminal() {
let p = rpassword::prompt_password(format!("Passphrase for {}: ", path.display()))
.map_err(SetupError::Io)?;
return Ok(SecretString::from(p));
}
Err(SetupError::Other(anyhow::anyhow!(
"identity key {} is encrypted; set $KOH_KEY_PASSPHRASE (no TTY available for a prompt)",
path.display()
)))
}
fn resolve_new_key_passphrase(path: &Path) -> Result<SecretString, SetupError> {
use std::io::IsTerminal as _;
if let Ok(p) = std::env::var("KOH_KEY_NEW_PASSPHRASE") {
if p.is_empty() {
return Err(SetupError::Other(anyhow::anyhow!(
"$KOH_KEY_NEW_PASSPHRASE is empty; identity keys are always encrypted (set a non-empty passphrase)"
)));
}
warn_if_weak_passphrase(&p);
return Ok(SecretString::from(p));
}
if std::io::stdin().is_terminal() {
let p1 = rpassword::prompt_password(format!(
"Set a passphrase to encrypt the new identity key {}: ",
path.display()
))
.map_err(SetupError::Io)?;
if p1.is_empty() {
return Err(SetupError::Other(anyhow::anyhow!(
"an empty passphrase is not allowed; identity keys are always encrypted"
)));
}
let p2 = rpassword::prompt_password("Confirm passphrase: ").map_err(SetupError::Io)?;
if p1 != p2 {
return Err(SetupError::Other(anyhow::anyhow!(
"passphrases did not match"
)));
}
warn_if_weak_passphrase(&p1);
return Ok(SecretString::from(p1));
}
Err(SetupError::Other(anyhow::anyhow!(
"no identity key at {} and no TTY to prompt; set $KOH_KEY_NEW_PASSPHRASE to create an encrypted key",
path.display()
)))
}
pub(crate) fn warn_if_weak_passphrase(passphrase: &str) {
const MIN_REASONABLE_CHARS: usize = 8;
if passphrase.chars().count() < MIN_REASONABLE_CHARS {
eprintln!(
"koh: warning: identity-key passphrase is short (< {MIN_REASONABLE_CHARS} chars); prefer \
a longer, higher-entropy one (advisory — not enforced)."
);
}
}
pub(crate) fn write_identity_key(
path: &Path,
sk: &SecretKey,
passphrase: &str,
) -> Result<(), SetupError> {
let secret = Zeroizing::new(sk.to_bytes());
let text = keyfile::encrypt_key(&secret, passphrase)
.map_err(|e| SetupError::Keyfile(e.to_string()))?;
write_secret_file(path, text.as_bytes())?;
Ok(())
}
fn create_dir_private(dir: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir)
}
}
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
let _ = std::fs::remove_file(&tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(contents)?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)
}
}
fn read_key_file_secure(path: &Path) -> Result<String, SetupError> {
#[cfg(unix)]
{
use std::io::Read as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(nix::libc::O_NOFOLLOW)
.open(path)
{
Ok(f) => f,
Err(e) if e.raw_os_error() == Some(nix::libc::ELOOP) => {
tracing::warn!(path = %path.display(), "secret key path is a symlink; refusing to load it");
return Err(SetupError::BadKeyFile);
}
Err(e) => return Err(SetupError::Io(e)),
};
let meta = file.metadata().map_err(SetupError::Io)?;
if !meta.file_type().is_file() {
tracing::warn!(path = %path.display(), "secret key path is not a regular file; refusing to load it");
return Err(SetupError::BadKeyFile);
}
tighten_key_perms_via_fd(&file, path, &meta);
let mut text = String::new();
file.read_to_string(&mut text).map_err(SetupError::Io)?;
Ok(text)
}
#[cfg(not(unix))]
{
Ok(std::fs::read_to_string(path)?)
}
}
#[cfg(unix)]
fn tighten_key_perms_via_fd(file: &std::fs::File, path: &Path, meta: &std::fs::Metadata) {
use std::os::unix::fs::PermissionsExt as _;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
match file.set_permissions(std::fs::Permissions::from_mode(0o600)) {
Ok(()) => tracing::warn!(
path = %path.display(),
prev_mode = format!("{:o}", mode & 0o777),
"secret key file was group/other-accessible; tightened to 0600 (via fd)"
),
Err(e) => tracing::warn!(
path = %path.display(),
mode = format!("{:o}", mode & 0o777),
error = %e,
"secret key file is group/other-accessible and could not be tightened; fix it with `chmod 600`"
),
}
}
}
fn ensure_state_dir_secure(dir: &Path) -> Result<(), SetupError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if dir.as_os_str().is_empty() {
return Ok(()); }
if let Ok(meta) = std::fs::metadata(dir) {
let mode = meta.permissions().mode();
let other_writable = mode & 0o002 != 0;
let sticky = mode & 0o1000 != 0;
if other_writable && !sticky {
return Err(SetupError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"state dir {} is world-writable without the sticky bit (mode {:o}); any user \
could replace the secret key — chmod 700 it, add the sticky bit, or pass \
--key-file / set $KOH_STATE_DIR to a private path",
dir.display(),
mode & 0o7777
),
)));
}
if mode & 0o077 != 0 {
tracing::warn!(
path = %dir.display(),
mode = format!("{:o}", mode & 0o7777),
"state dir is group/other-accessible; the key is still 0600, but prefer chmod 700"
);
}
}
}
#[cfg(not(unix))]
let _ = dir;
Ok(())
}
pub fn default_key_path(role: &str) -> std::path::PathBuf {
if let Some(dirs) = directories::ProjectDirs::from("", "", "koh") {
return dirs.config_dir().join(format!("{role}.key"));
}
state_dir_from(
std::env::var_os("KOH_STATE_DIR"),
std::env::var_os("HOME"),
std::env::var_os("TMPDIR"),
)
.join(format!("{role}.key"))
}
fn state_dir_from(
koh_state: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
tmpdir: Option<std::ffi::OsString>,
) -> std::path::PathBuf {
let nonempty = |o: Option<std::ffi::OsString>| o.filter(|v| !v.is_empty());
if let Some(d) = nonempty(koh_state) {
return std::path::PathBuf::from(d);
}
if let Some(h) = nonempty(home) {
return std::path::PathBuf::from(h).join(".config").join("koh");
}
if let Some(t) = nonempty(tmpdir) {
return std::path::PathBuf::from(t).join("koh");
}
std::path::PathBuf::from("/data/local/tmp/koh")
}
pub fn generate_secret_key() -> SecretKey {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
SecretKey::from_bytes(&bytes)
}
pub fn parse_endpoint_id(s: &str) -> Result<EndpointId, SetupError> {
s.trim()
.parse::<EndpointId>()
.map_err(|e| SetupError::BadEndpointId(e.to_string()))
}
pub fn format_endpoint_id(id: &EndpointId) -> String {
id.to_string()
}
fn parse_dns_spec(spec: &str) -> Option<SocketAddr> {
let spec = spec.trim();
spec.parse::<SocketAddr>().ok().or_else(|| {
spec.parse::<std::net::IpAddr>()
.ok()
.map(|ip| SocketAddr::new(ip, 53))
})
}
#[cfg_attr(
target_os = "android",
expect(
clippy::unnecessary_wraps,
reason = "Android always pins a nameserver (Some); the None arm is desktop-only"
)
)]
fn discovery_dns_resolver() -> Option<iroh::dns::DnsResolver> {
use iroh::dns::DnsResolver;
if let Some(addr) = std::env::var("KOH_DNS")
.ok()
.as_deref()
.and_then(parse_dns_spec)
{
return Some(DnsResolver::with_nameserver(addr));
}
#[cfg(target_os = "android")]
{
Some(DnsResolver::with_nameserver(SocketAddr::from((
[8, 8, 8, 8],
53,
))))
}
#[cfg(not(target_os = "android"))]
{
None
}
}
pub async fn bind_endpoint(secret: SecretKey, accept: bool) -> Result<Endpoint, SetupError> {
let mut builder = Endpoint::builder(presets::N0)
.secret_key(secret)
.transport_config(koh_transport_config());
if let Some(resolver) = discovery_dns_resolver() {
builder = builder.dns_resolver(resolver);
}
if accept {
builder = builder.alpns(vec![ALPN.to_vec()]);
}
let ep = builder
.bind()
.await
.map_err(|e| SetupError::Other(e.into()))?;
Ok(ep)
}
pub async fn bind_endpoint_local(secret: SecretKey, accept: bool) -> Result<Endpoint, SetupError> {
let mut builder = Endpoint::builder(presets::Minimal)
.secret_key(secret)
.transport_config(koh_transport_config());
if let Some(resolver) = discovery_dns_resolver() {
builder = builder.dns_resolver(resolver);
}
if accept {
builder = builder.alpns(vec![ALPN.to_vec()]);
}
let ep = builder
.bind()
.await
.map_err(|e| SetupError::Other(e.into()))?;
Ok(ep)
}
pub fn loopback_addr(ep: &Endpoint) -> EndpointAddr {
let mut addr = EndpointAddr::new(ep.id());
if let Some(port) = ep
.bound_sockets()
.iter()
.find(|s| s.is_ipv4())
.map(std::net::SocketAddr::port)
{
addr = addr.with_ip_addr(SocketAddr::from(([127, 0, 0, 1], port)));
}
addr
}
pub fn direct_addr(id: EndpointId, addr: SocketAddr) -> EndpointAddr {
EndpointAddr::new(id).with_ip_addr(addr)
}
pub fn relay_addr(id: EndpointId, relay: RelayUrl) -> EndpointAddr {
EndpointAddr::new(id).with_relay_url(relay)
}
pub async fn bind_endpoint_with_relay(
secret: SecretKey,
accept: bool,
relay: RelayUrl,
) -> Result<Endpoint, SetupError> {
let mut builder = Endpoint::builder(presets::Minimal)
.secret_key(secret)
.relay_mode(RelayMode::custom([relay]))
.transport_config(koh_transport_config());
if let Some(resolver) = discovery_dns_resolver() {
builder = builder.dns_resolver(resolver);
}
if accept {
builder = builder.alpns(vec![ALPN.to_vec()]);
}
let ep = builder
.bind()
.await
.map_err(|e| SetupError::Other(e.into()))?;
Ok(ep)
}
pub fn parse_relay_url(s: &str) -> Result<RelayUrl, SetupError> {
s.trim()
.parse::<RelayUrl>()
.map_err(|e| SetupError::Other(anyhow::anyhow!("bad relay url: {e}")))
}
#[derive(Clone)]
pub struct IrohChannel {
conn: Connection,
}
impl IrohChannel {
pub fn new(conn: Connection) -> Self {
Self { conn }
}
pub fn remote_id(&self) -> EndpointId {
self.conn.remote_id()
}
pub fn send(&self, datagram: &[u8]) -> bool {
match self.conn.send_datagram(Bytes::copy_from_slice(datagram)) {
Ok(()) => true,
Err(e) => {
tracing::trace!(error = %e, len = datagram.len(), "datagram send dropped");
false
}
}
}
pub async fn recv(&self) -> Result<Bytes, ConnectionError> {
self.conn.read_datagram().await
}
pub fn max_datagram_size(&self) -> usize {
self.conn
.max_datagram_size()
.unwrap_or(DEFAULT_MAX_DATAGRAM)
.max(64)
}
pub fn rtt_ms(&self) -> Option<f64> {
let to_ms = |d: Duration| d.as_secs_f64() * 1000.0;
if let Some(p) = self
.conn
.paths()
.iter()
.find(iroh::endpoint::Path::is_selected)
{
return Some(to_ms(p.rtt()));
}
if let Some(p) = self.conn.paths().iter().next() {
return Some(to_ms(p.rtt()));
}
self.conn.rtt(PathId::ZERO).map(to_ms)
}
pub fn close(&self, code: u32, reason: &[u8]) {
self.conn.close(VarInt::from_u32(code), reason);
}
}
#[derive(Debug, Clone, Copy)]
pub struct MonoClock {
base: tokio::time::Instant,
}
impl Default for MonoClock {
fn default() -> Self {
Self::new()
}
}
impl MonoClock {
pub fn new() -> Self {
Self {
base: tokio::time::Instant::now(),
}
}
pub fn now_ms(&self) -> u64 {
self.base.elapsed().as_millis() as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_dir_resolves_in_priority_order() {
use std::ffi::OsString;
use std::path::PathBuf;
let s = |x: &str| Some(OsString::from(x));
assert_eq!(
state_dir_from(s("/x"), s("/home/u"), s("/tmp")),
PathBuf::from("/x")
);
assert_eq!(
state_dir_from(None, s("/home/u"), s("/tmp")),
PathBuf::from("/home/u/.config/koh")
);
assert_eq!(
state_dir_from(Some(OsString::new()), Some(OsString::new()), s("/tmp")),
PathBuf::from("/tmp/koh")
);
let last = state_dir_from(None, None, None);
assert_eq!(last, PathBuf::from("/data/local/tmp/koh"));
assert!(
last.is_absolute(),
"the default must be absolute, not CWD-relative"
);
}
#[test]
fn secret_key_roundtrips_through_disk() {
let dir = std::env::temp_dir().join(format!("koh-key-test-{}", std::process::id()));
let path = dir.join("id.key");
let _ = std::fs::remove_dir_all(&dir);
create_dir_private(&dir).unwrap();
let sk1 = generate_secret_key();
write_identity_key(&path, &sk1, "test-pass").expect("write encrypted key");
let text = std::fs::read_to_string(&path).unwrap();
let bytes = keyfile::decrypt_key(&text, "test-pass").expect("decrypts back");
let sk2 = SecretKey::from_bytes(&bytes);
assert_eq!(sk1.to_bytes(), sk2.to_bytes(), "round-trips through disk");
let id = sk1.public();
let s = format_endpoint_id(&id);
assert_eq!(parse_endpoint_id(&s).unwrap(), id);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn created_key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("koh-key-perm-{}", std::process::id()));
let path = dir.join("id.key");
let _ = std::fs::remove_dir_all(&dir);
create_dir_private(&dir).unwrap();
write_identity_key(&path, &generate_secret_key(), "test-pass")
.expect("write encrypted key");
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o077,
0,
"key file must not be group/other-accessible, got {mode:o}"
);
let dmode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(
dmode & 0o077,
0,
"state dir must not be group/other-accessible, got {dmode:o}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn ensure_state_dir_secure_refuses_only_nonsticky_world_writable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("koh-ww-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let set =
|m: u32| std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(m)).unwrap();
set(0o777); assert!(
ensure_state_dir_secure(&dir).is_err(),
"a non-sticky world-writable dir must be refused"
);
set(0o700);
assert!(
ensure_state_dir_secure(&dir).is_ok(),
"a private 0700 dir is accepted"
);
set(0o771); assert!(
ensure_state_dir_secure(&dir).is_ok(),
"a group-writable but not-other-writable dir (0771) must be allowed"
);
set(0o1777); assert!(
ensure_state_dir_secure(&dir).is_ok(),
"a sticky world-writable dir (1777) must be allowed"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn fd_key_read_does_not_follow_a_symlinked_key() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("koh-symlink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("victim");
std::fs::write(&target, b"x").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
let link = dir.join("server.key");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(
matches!(read_key_file_secure(&link), Err(SetupError::BadKeyFile)),
"a symlinked key must be refused at open, not followed"
);
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o644,
"a symlinked key's target must not be re-permissioned"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn fd_key_read_tightens_a_loose_real_key_via_the_fd() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("koh-loose-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
create_dir_private(&dir).unwrap();
let key = dir.join("server.key");
std::fs::write(&key, b"deadbeef\n").unwrap();
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
let text = read_key_file_secure(&key).expect("a loose real key still reads");
assert_eq!(text.trim(), "deadbeef", "contents read back through the fd");
let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "a loose key is tightened to 0600 via the fd");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn load_refuses_a_symlinked_key() {
let dir = std::env::temp_dir().join(format!("koh-keylink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
create_dir_private(&dir).unwrap(); let target = dir.join("secret");
std::fs::write(&target, b"deadbeef").unwrap();
let link = dir.join("server.key");
std::os::unix::fs::symlink(&target, &link).unwrap();
let result = load_or_create_secret_key(&link);
assert!(
matches!(result, Err(SetupError::BadKeyFile)),
"a symlinked key path must be refused, got {result:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_identity_key_encrypted_roundtrips_and_rejects_wrong_passphrase() {
let dir = std::env::temp_dir().join(format!("koh-enc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
create_dir_private(&dir).unwrap();
let key = dir.join("id.key");
let sk = generate_secret_key();
write_identity_key(&key, &sk, "correct horse").expect("write encrypted");
let text = std::fs::read_to_string(&key).unwrap();
assert!(
text.starts_with("koh-key-v1"),
"stored in the encrypted format"
);
let got = keyfile::decrypt_key(&text, "correct horse").expect("decrypts");
assert_eq!(*got, sk.to_bytes(), "round-trips to the same secret");
assert!(
keyfile::decrypt_key(&text, "wrong").is_err(),
"a wrong passphrase is rejected"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_rejects_garbage() {
assert!(parse_endpoint_id("not-a-real-endpoint-id").is_err());
}
#[test]
fn dns_spec_accepts_ip_and_ip_port_rejects_junk() {
assert_eq!(
parse_dns_spec("1.1.1.1"),
Some(SocketAddr::from(([1, 1, 1, 1], 53)))
);
assert_eq!(
parse_dns_spec("8.8.8.8:5353"),
Some(SocketAddr::from(([8, 8, 8, 8], 5353)))
);
assert_eq!(
parse_dns_spec("2001:4860:4860::8888").map(|a| a.port()),
Some(53)
);
assert_eq!(
parse_dns_spec("[2001:4860:4860::8888]:53").map(|a| a.port()),
Some(53)
);
assert_eq!(
parse_dns_spec(" 9.9.9.9 "),
Some(SocketAddr::from(([9, 9, 9, 9], 53)))
);
assert_eq!(parse_dns_spec(""), None);
assert_eq!(parse_dns_spec("not-an-ip"), None);
assert_eq!(parse_dns_spec("8.8.8.8:"), None);
assert_eq!(parse_dns_spec("8.8.8.8:99999"), None);
}
#[test]
fn explicit_nameserver_resolver_builds() {
let _resolver =
iroh::dns::DnsResolver::with_nameserver(SocketAddr::from(([8, 8, 8, 8], 53)));
}
#[tokio::test]
async fn two_endpoints_exchange_datagram_over_loopback() {
let server = bind_endpoint_local(generate_secret_key(), true)
.await
.expect("bind server");
let client = bind_endpoint_local(generate_secret_key(), false)
.await
.expect("bind client");
let server_addr = loopback_addr(&server);
let srv = tokio::spawn(async move {
let incoming = server.accept().await.expect("accept");
let conn = incoming.await.expect("handshake");
let dg = conn.read_datagram().await.expect("read datagram");
conn.send_datagram(dg).expect("echo datagram"); conn.closed().await;
});
let conn = client
.connect(server_addr, ALPN)
.await
.expect("connect over loopback");
let chan = IrohChannel::new(conn);
assert!(
chan.send(b"ping-over-real-iroh"),
"datagram send should succeed"
);
let echoed = chan.recv().await.expect("recv echo");
assert_eq!(&echoed[..], b"ping-over-real-iroh");
chan.close(0, b"done");
let _ = srv.await;
}
}