From eedcdd1976fd53f4370f7347aa213f5bc5faac71 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:16:10 +0200 Subject: [PATCH] fix(mobile): keep reaching the computer after the gateway restarts Every `serve` bound a random port, so a restart left each phone holding addresses that no longer answered. Where the n0 relays are unreachable, which is common behind the Great Firewall, the phone had no other way to find the gateway until it was paired again. - `serve` listens on the same UDP port every time. It picks one on first run, keeps it in `/mobile/port`, and moves only if the port is taken. IPv6 binding may fail, as in iroh's own defaults. - Both ends add mDNS lookup (`iroh-mdns-address-lookup`, service `_tty7._udp`). On the same network a phone finds the gateway by key when its addresses are stale, such as after a new DHCP lease or a new IPv6 prefix. The gateway advertises and the phone only listens. If multicast is refused, each side starts without it and says so, rather than failing. - iOS: `Info.ios.plist` declares the local-network use and the Bonjour service, without which iOS blocks multicast. - An ignored test (`--test mdns`) connects by key alone over mDNS, for a machine that allows multicast. --- Cargo.lock | 90 ++++++++++++++++++++++++- crates/tty7-gateway/Cargo.toml | 4 ++ crates/tty7-gateway/src/main.rs | 73 +++++++++++++++++--- crates/tty7-gateway/src/state.rs | 25 +++++++ crates/tty7-mobile-client/Cargo.toml | 3 + crates/tty7-mobile-client/src/lib.rs | 28 ++++++-- crates/tty7-mobile-client/tests/mdns.rs | 41 +++++++++++ crates/tty7-mobile-proto/src/lib.rs | 4 ++ mobile/README.md | 14 ++++ mobile/src-tauri/Cargo.lock | 85 +++++++++++++++++++++++ mobile/src-tauri/Info.ios.plist | 15 +++++ 11 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 crates/tty7-mobile-client/tests/mdns.rs create mode 100644 mobile/src-tauri/Info.ios.plist diff --git a/Cargo.lock b/Cargo.lock index 35656c9f..65ec17f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,21 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str 0.1.24", + "sync_wrapper", + "tokio", + "tracing", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -1894,7 +1909,7 @@ dependencies = [ "rustc-hash 2.1.3", "self_cell", "skrifa 0.40.0", - "smol_str", + "smol_str 0.3.6", "swash", "sys-locale", "unicode-bidi", @@ -1936,6 +1951,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -3792,7 +3813,7 @@ source = "git+https://github.com/l0ng-ai/zed?branch=tty7#5c390b9359e39f645ed6819 dependencies = [ "schemars", "serde", - "smol_str", + "smol_str 0.3.6", ] [[package]] @@ -4097,6 +4118,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand 0.10.2", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -4795,6 +4834,26 @@ dependencies = [ "url", ] +[[package]] +name = "iroh-mdns-address-lookup" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad3dfcaddd5c3681bf0a42606498fc6e8891de793408dfbc46a9fbe76f370b3" +dependencies = [ + "data-encoding", + "derive_more", + "futures-util", + "iroh", + "iroh-base", + "n0-error", + "n0-future", + "n0-watcher", + "swarm-discovery", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "iroh-metrics" version = "1.0.2" @@ -6842,6 +6901,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -9498,6 +9561,12 @@ dependencies = [ "futures-lite 2.6.1", ] +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "smol_str" version = "0.3.6" @@ -9895,6 +9964,21 @@ dependencies = [ "siphasher", ] +[[package]] +name = "swarm-discovery" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4877f20f064de34b77d79237c9caa37c393590af8ef5a2f3253d59be5a338b" +dependencies = [ + "acto", + "hickory-proto", + "rand 0.10.2", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", +] + [[package]] name = "swash" version = "0.2.10" @@ -11109,6 +11193,7 @@ dependencies = [ "clap", "getrandom 0.3.4", "iroh", + "iroh-mdns-address-lookup", "libc", "qrcode", "serde", @@ -11126,6 +11211,7 @@ version = "26.9.3" dependencies = [ "anyhow", "iroh", + "iroh-mdns-address-lookup", "serde", "serde_json", "tokio", diff --git a/crates/tty7-gateway/Cargo.toml b/crates/tty7-gateway/Cargo.toml index 8de5aeba..0bb4592e 100644 --- a/crates/tty7-gateway/Cargo.toml +++ b/crates/tty7-gateway/Cargo.toml @@ -24,6 +24,10 @@ clap = { version = "4", features = ["derive"] } # cannot — so a phone on cellular reaches a laptop behind a home router with no # port forwarding and no VPN, end-to-end encrypted either way. iroh = "1.2" +# Lets a phone on the same network find the gateway by its key when the +# addresses in its pairing code have gone stale — a new DHCP lease, a new IPv6 +# prefix — with no relay reachable to ask instead. +iroh-mdns-address-lookup = "0.5" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] } getrandom = "0.3" # Draws the pairing code as a QR code right in the terminal. diff --git a/crates/tty7-gateway/src/main.rs b/crates/tty7-gateway/src/main.rs index 5fc7dd8c..5b3ff76c 100644 --- a/crates/tty7-gateway/src/main.rs +++ b/crates/tty7-gateway/src/main.rs @@ -3,12 +3,15 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use clap::{Parser, Subcommand}; -use iroh::Endpoint; -use iroh::endpoint::presets; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +use iroh::endpoint::{BindOpts, presets}; +use iroh::{Endpoint, SecretKey}; +use iroh_mdns_address_lookup::MdnsAddressLookup; use tty7_gateway::daemon::{Daemon, hostname}; use tty7_gateway::serve::{self, Backend as _}; use tty7_gateway::state::{Reachable, State}; -use tty7_mobile_proto::{ALPN, PairCode}; +use tty7_mobile_proto::{ALPN, MDNS_SERVICE, PairCode}; #[derive(Parser)] #[command( @@ -82,12 +85,16 @@ fn main() -> Result<()> { } async fn serve_forever(state: State, daemon: Arc) -> Result<()> { - let endpoint = Endpoint::builder(presets::N0) - .secret_key(state.secret_key()?) - .alpns(vec![ALPN.to_vec()]) - .bind() - .await - .context("starting the iroh endpoint")?; + let endpoint = bind(state.secret_key()?, state.port()).await?; + if let Some(port) = endpoint + .bound_sockets() + .iter() + .map(SocketAddr::port) + .find(|&p| p != 0) + && state.port() != Some(port) + { + state.set_port(port).context("remembering the port")?; + } println!("tty7-gateway: listening as {}", endpoint.id()); // Keep the addresses `pair` puts in a code current. The local ones are @@ -116,6 +123,54 @@ async fn serve_forever(state: State, daemon: Arc) -> Result<()> { Ok(()) } +/// Binds the gateway's endpoint on the port it had last time, so the +/// addresses in phones' pairing codes still reach it, and advertises it on the +/// local network. Each of those is given up, with a note, rather than let it +/// keep the gateway from starting: the port may be taken, and multicast may be +/// off. +async fn bind(key: SecretKey, port: Option) -> Result { + let mut attempts = Vec::new(); + if let Some(port) = port { + attempts.extend([(port, true), (port, false)]); + } + attempts.extend([(0, true), (0, false)]); + + let mut failures = Vec::new(); + for (port, mdns) in attempts { + let builder = Endpoint::builder(presets::N0) + .secret_key(key.clone()) + .alpns(vec![ALPN.to_vec()]) + .clear_ip_transports() + .bind_addr(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)))? + // IPv6 is a bonus, as it is in iroh's own defaults. + .bind_addr_with_opts( + SocketAddr::from((Ipv6Addr::UNSPECIFIED, port)), + BindOpts::default().set_is_required(false), + )?; + let builder = match mdns { + true => builder.address_lookup(MdnsAddressLookup::builder().service_name(MDNS_SERVICE)), + false => builder, + }; + match builder.bind().await { + Ok(endpoint) => { + for failure in failures { + eprintln!("tty7-gateway: {failure} — carrying on without it"); + } + return Ok(endpoint); + } + Err(e) => { + let what = match (port, mdns) { + (0, true) => "local network discovery".to_string(), + (0, false) => "the iroh endpoint".to_string(), + (port, _) => format!("port {port}"), + }; + failures.push(format!("{what}: {e}")); + } + } + } + anyhow::bail!("could not start: {}", failures.join("; ")) +} + fn pair(state: &State, ttl: u64) -> Result<()> { let host_id = state.secret_key()?.public().to_string(); let reachable = state.reachable(); diff --git a/crates/tty7-gateway/src/state.rs b/crates/tty7-gateway/src/state.rs index 5e4f94c9..27633c0f 100644 --- a/crates/tty7-gateway/src/state.rs +++ b/crates/tty7-gateway/src/state.rs @@ -21,6 +21,7 @@ const DEVICES_FILE: &str = "devices.json"; const PAIRING_FILE: &str = "pairing.json"; const ADDR_FILE: &str = "addr.json"; const LOCK_FILE: &str = "serve.lock"; +const PORT_FILE: &str = "port"; #[derive(Debug, Clone)] pub struct State { @@ -194,6 +195,22 @@ impl State { pairing.expires_at >= unix_now() && constant_time_eq(&pairing.secret, secret) } + /// The UDP port `serve` last listened on. A phone dials the addresses in + /// its pairing code first, so keeping the port keeps those addresses good + /// across restarts. + pub fn port(&self) -> Option { + fs::read_to_string(self.dir.join(PORT_FILE)) + .ok()? + .trim() + .parse() + .ok() + .filter(|&port| port != 0) + } + + pub fn set_port(&self, port: u16) -> Result<()> { + write_private(&self.dir.join(PORT_FILE), port.to_string().as_bytes()) + } + pub fn reachable(&self) -> Reachable { read_json(&self.dir.join(ADDR_FILE)) .ok() @@ -324,6 +341,14 @@ mod tests { .expect("free again once the holder is gone"); } + #[test] + fn the_port_is_kept() { + let (_tmp, state) = state(); + assert_eq!(state.port(), None); + state.set_port(41641).unwrap(); + assert_eq!(state.port(), Some(41641)); + } + #[test] fn devices_are_added_replaced_and_revoked() { let (_tmp, state) = state(); diff --git a/crates/tty7-mobile-client/Cargo.toml b/crates/tty7-mobile-client/Cargo.toml index 5a99dd1a..7ebd33e7 100644 --- a/crates/tty7-mobile-client/Cargo.toml +++ b/crates/tty7-mobile-client/Cargo.toml @@ -15,10 +15,13 @@ tty7-mobile-proto = { path = "../tty7-mobile-proto", features = ["tokio"] } anyhow.workspace = true serde = { workspace = true } iroh = "1.2" +# Finds a paired gateway on the local network after its addresses change. +iroh-mdns-address-lookup = "0.5" tokio = { version = "1", features = ["sync", "time", "io-util"] } [dev-dependencies] serde_json.workspace = true +tty7-mobile-proto = { path = "../tty7-mobile-proto" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } [lints] diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index bd163121..cb7963e5 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -11,10 +11,11 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use iroh::endpoint::{Connection, RecvStream, SendStream, presets}; use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl, SecretKey}; +use iroh_mdns_address_lookup::MdnsAddressLookup; use serde::{Deserialize, Serialize}; use tty7_mobile_proto::{ - ALPN, ControlEvent, ControlRequest, Frame, GridSize, Open, OpenReply, PROTOCOL_VERSION, - PairCode, PaneEvent, TabCreated, read_frame, write_bytes, write_msg, + ALPN, ControlEvent, ControlRequest, Frame, GridSize, MDNS_SERVICE, Open, OpenReply, + PROTOCOL_VERSION, PairCode, PaneEvent, TabCreated, read_frame, write_bytes, write_msg, }; /// How long to wait for a gateway to answer an [`Open`]. @@ -52,13 +53,28 @@ impl Host { } } -/// Binds the phone's endpoint with iroh's public relays and address lookup. +/// Binds the phone's endpoint with iroh's public relays and address lookup, +/// plus mDNS: on the gateway's own network that finds it by key even when the +/// addresses the phone knows are stale and no relay can be reached. The phone +/// only listens; it has no reason to announce itself. Where multicast is not +/// allowed it does without. pub async fn bind(secret: SecretKey) -> Result { - Endpoint::builder(presets::N0) - .secret_key(secret) + let mdns = MdnsAddressLookup::builder() + .service_name(MDNS_SERVICE) + .advertise(false); + match Endpoint::builder(presets::N0) + .secret_key(secret.clone()) + .address_lookup(mdns) .bind() .await - .context("starting the iroh endpoint") + { + Ok(endpoint) => Ok(endpoint), + Err(_) => Endpoint::builder(presets::N0) + .secret_key(secret) + .bind() + .await + .context("starting the iroh endpoint"), + } } /// Trades a pairing code for a [`Host`] the app can store and dial later. diff --git a/crates/tty7-mobile-client/tests/mdns.rs b/crates/tty7-mobile-client/tests/mdns.rs new file mode 100644 index 00000000..4e0c51b9 --- /dev/null +++ b/crates/tty7-mobile-client/tests/mdns.rs @@ -0,0 +1,41 @@ +//! A phone finds a gateway on the local network by key alone, with none of +//! its addresses known and no relay or DNS to ask — what happens when every +//! address in the pairing code has gone stale. +//! +//! Ignored by default: it needs multicast on the machine's network, which CI +//! runners and sandboxes may not allow. `cargo test -p tty7-mobile-client +//! --test mdns -- --ignored`. + +use std::time::Duration; + +use iroh::endpoint::presets; +use iroh::{Endpoint, EndpointAddr, SecretKey}; +use iroh_mdns_address_lookup::MdnsAddressLookup; +use tty7_mobile_proto::{ALPN, MDNS_SERVICE}; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs multicast on the local network"] +async fn a_gateway_is_found_by_key_on_the_local_network() { + let gateway = Endpoint::builder(presets::Minimal) + .secret_key(SecretKey::generate()) + .alpns(vec![ALPN.to_vec()]) + .address_lookup(MdnsAddressLookup::builder().service_name(MDNS_SERVICE)) + .bind() + .await + .unwrap(); + let accept = tokio::spawn({ + let gateway = gateway.clone(); + async move { gateway.accept().await.unwrap().await.unwrap() } + }); + + let phone = tty7_mobile_client::bind(SecretKey::generate()) + .await + .unwrap(); + let only_the_key = EndpointAddr::new(gateway.id()); + let conn = tokio::time::timeout(Duration::from_secs(20), phone.connect(only_the_key, ALPN)) + .await + .expect("found within 20 s") + .expect("connected"); + assert_eq!(conn.remote_id(), gateway.id()); + accept.await.unwrap(); +} diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index 8b945c5c..971e4699 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -30,6 +30,10 @@ use serde::{Deserialize, Serialize}; /// during the handshake, before a byte of this protocol is read. pub const ALPN: &[u8] = b"tty7/mobile/1"; +/// The mDNS service both ends use to find each other on a local network, +/// whatever addresses the gateway has moved to since pairing. +pub const MDNS_SERVICE: &str = "tty7"; + /// Bumped when a message changes shape. Carried in [`OpenReply::Ok`] so an app /// can tell the user to update rather than misread a newer gateway. pub const PROTOCOL_VERSION: u32 = 1; diff --git a/mobile/README.md b/mobile/README.md index a668f5a6..41a576f6 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -75,6 +75,20 @@ cargo run -p tty7-mobile-client --example probe -- tree cargo run -p tty7-mobile-client --example probe -- type 1 'echo hi' ``` +## Finding the computer again + +A pairing code carries the gateway's addresses at the time it was made. Those +stay good across restarts: `serve` listens on the same UDP port every time +(`/mobile/port`, a fresh one only if it is taken). When they go stale +anyway — a new DHCP lease, a new IPv6 prefix — the phone looks the gateway up by +key: through n0's relay and DNS where those are reachable, and by mDNS +(`_tty7._udp`) on the same local network where they are not. + +mDNS needs the OS's local-network permission on both ends. On macOS that belongs +to whatever launched the gateway (your terminal), and it answers "Allow … to find +devices on local networks" once. On iOS the app declares it in +`src-tauri/Info.ios.plist`. Without it, known addresses and the relay still work. + ## Not done yet - **QR scanning.** The app takes a pasted code for now. Next step is diff --git a/mobile/src-tauri/Cargo.lock b/mobile/src-tauri/Cargo.lock index 66f4fc30..1a04e177 100644 --- a/mobile/src-tauri/Cargo.lock +++ b/mobile/src-tauri/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "sync_wrapper", + "tokio", + "tracing", +] + [[package]] name = "adler2" version = "2.0.1" @@ -605,6 +620,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1722,6 +1743,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2169,6 +2208,26 @@ dependencies = [ "url", ] +[[package]] +name = "iroh-mdns-address-lookup" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad3dfcaddd5c3681bf0a42606498fc6e8891de793408dfbc46a9fbe76f370b3" +dependencies = [ + "data-encoding", + "derive_more", + "futures-util", + "iroh", + "iroh-base", + "n0-error", + "n0-future", + "n0-watcher", + "swarm-discovery", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "iroh-metrics" version = "1.0.2" @@ -3295,6 +3354,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "opaque-debug" @@ -4429,6 +4492,12 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "socket2" version = "0.6.5" @@ -4583,6 +4652,21 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-discovery" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4877f20f064de34b77d79237c9caa37c393590af8ef5a2f3253d59be5a338b" +dependencies = [ + "acto", + "hickory-proto", + "rand", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", +] + [[package]] name = "swift-rs" version = "1.0.8" @@ -5418,6 +5502,7 @@ version = "26.9.3" dependencies = [ "anyhow", "iroh", + "iroh-mdns-address-lookup", "serde", "tokio", "tty7-mobile-proto", diff --git a/mobile/src-tauri/Info.ios.plist b/mobile/src-tauri/Info.ios.plist new file mode 100644 index 00000000..88e48954 --- /dev/null +++ b/mobile/src-tauri/Info.ios.plist @@ -0,0 +1,15 @@ + + + + + + NSLocalNetworkUsageDescription + tty7 looks for your paired computer on this network, so it can reach it after its address changes. + NSBonjourServices + + _tty7._udp + + +