feat(network): follow a machine's session and forget removed ones

- A network port kept a machine it had connected to after the connection was removed, listed it as on the network whenever its host advertised any session, and could not connect to it again: Connect resolved identifiers only among advertised sessions and failed with "no peer named". A machine remembered only for a connection is now dropped once no network port uses it, an untrusted machine is marked present only by a session at its exact address, and Connect resolves remembered machines by identifier or name.
- A network port invited the one address it had connected to until it answered, so a session that came back on another port was never reached. The session name advertised at a machine's address is now stored as advertised_as in the configuration, and while the link is down the port connects where that session is advertised and stores the new address. A machine carrying MIDI is never moved.
- Each daemon now holds an Ed25519 key in identity.key beside the configuration and publishes mhkey and mhport in every session's TXT record. Two packets on the control port, a challenge and a signed proof, let a network port prove which port of which daemon it is. A machine stored with a proved key and port_id is followed under any name and to another host, with its trust, and a port deleted and made again is not followed. The challenge is sent only to a session that advertises a key, so no other RTP-MIDI implementation receives it.
- A machine with no key is followed by name to a new port on its host, and to another host only when it is not trusted, since an advertisement proves nothing and trust is held by host.
- An invitation over an IPv6 link-local address was never answered, because the sender's address was kept without its scope. Apple's Network MIDI invites that way and reported that the port did not respond. The address is now kept whole for the control and data ports.
- Adds ed25519-dalek and hex. The packet fuzz target reads the new packets.
This commit is contained in:
James Coleman 2026-10-02 11:56:20 -05:00
parent d5d63c52cb
commit be2e93bcda
28 changed files with 2285 additions and 71 deletions

75
Cargo.lock generated
View file

@ -1471,6 +1471,33 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "custom_debug"
version = "0.6.2"
@ -1902,6 +1929,28 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.18.0"
@ -2025,6 +2074,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "find-crate"
version = "0.6.3"
@ -3880,7 +3935,9 @@ dependencies = [
name = "midi-harbor-daemon"
version = "0.0.0"
dependencies = [
"ed25519-dalek",
"gethostname",
"hex",
"if-addrs",
"jiff",
"mdns-sd",
@ -5697,6 +5754,12 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
[[package]]
name = "simd-adler32"
version = "0.3.10"
@ -5950,6 +6013,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svg_fmt"
version = "0.4.5"
@ -8289,6 +8358,12 @@ dependencies = [
"synstructure 0.14.0",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"

View file

@ -62,7 +62,10 @@ midi-harbor-service = { path = "crates/service" }
bytes = "1"
clap = { version = "4.6", features = ["derive"] }
directories = "6.0"
# Signs and verifies the proof of which network port a session is (R-106).
ed25519-dalek = "3.0"
gethostname = "1.1"
hex = "0.4"
if-addrs = "0.15"
jiff = { version = "0.2", features = ["serde"] }
mdns-sd = "0.21"

View file

@ -61,6 +61,19 @@ pub struct PeerConfig {
/// Whether invitations from this peer are accepted without asking.
#[serde(default)]
pub trusted: bool,
/// The session name it was advertising when a network port connected to it, if it was
/// advertising one. A session advertised under this name somewhere else is where the
/// machine is connected to again (R-105).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advertised_as: Option<String>,
/// The public key of the daemon that runs it, in hexadecimal, when it is a Midi Harbor that
/// proved the key where a network port connected to it (R-106).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
/// The identifier of the network port connected to, in that daemon's configuration, proved
/// with the key. A session is the same port only when it proves both.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port_id: Option<EndpointId>,
}
/// A persisted MIDI connection between two endpoints.
@ -770,6 +783,9 @@ mod tests {
name: "Studio PC".to_owned(),
addresses: vec!["192.0.2.13:5004".to_owned()],
trusted: true,
advertised_as: Some("Studio".to_owned()),
key: Some("5a".repeat(32)),
port_id: Some(EndpointId::from_uuid(uuid::Uuid::from_u128(7))),
}],
routes: vec![RouteConfig {
from_connector: Some(2),
@ -849,6 +865,9 @@ mod tests {
name: "Studio PC".to_owned(),
addresses: vec!["192.0.2.13:5004".to_owned()],
trusted: true,
advertised_as: None,
key: None,
port_id: None,
}],
..Configuration::default()
},

View file

@ -32,6 +32,16 @@ macro_rules! uuid_id {
pub fn from_uuid(value: Uuid) -> Self {
Self(value)
}
/// Returns the identifier's sixteen bytes, as it travels in a packet.
pub fn to_bytes(&self) -> [u8; 16] {
*self.0.as_bytes()
}
/// Reads an identifier from its sixteen bytes.
pub fn from_bytes(bytes: [u8; 16]) -> Self {
Self(Uuid::from_bytes(bytes))
}
}
impl Default for $name {

View file

@ -11,7 +11,9 @@ workspace = true
[dependencies]
serde_json.workspace = true
ed25519-dalek.workspace = true
gethostname.workspace = true
hex.workspace = true
rand.workspace = true
socket2.workspace = true
mdns-sd.workspace = true

View file

@ -2,11 +2,14 @@
//!
//! Run with `cargo run -p midi-harbor-daemon --example discover`.
use midi_harbor_core::ids::EndpointId;
use midi_harbor_daemon::discovery::Discovery;
use midi_harbor_daemon::identity::Identity;
use std::time::Duration;
fn main() {
let discovery = match Discovery::start() {
// A key of its own for the run, as every advertised session carries one.
let discovery = match Discovery::start(&Identity::generate().public_key()) {
Ok(discovery) => discovery,
Err(error) => {
eprintln!("could not start discovery: {error}");
@ -15,7 +18,7 @@ fn main() {
};
// Advertise on a port nothing else is using, so this cannot disturb a real session.
if let Err(error) = discovery.advertise("Midi Harbor Example", 5104) {
if let Err(error) = discovery.advertise("Midi Harbor Example", 5104, EndpointId::new()) {
eprintln!("could not advertise: {error}");
}

View file

@ -14,7 +14,8 @@
//! immediately by both browsers; advertising the same service through `mdns-sd` was seen by
//! neither. Browsing was never the problem.
use midi_harbor_core::ids::PeerId;
use crate::identity::PortIdentity;
use midi_harbor_core::ids::{EndpointId, PeerId};
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
@ -25,6 +26,15 @@ use tracing::{debug, info};
/// The service type Apple's Network MIDI advertises and browses for.
pub const SERVICE_TYPE: &str = "_apple-midi._udp.local.";
/// The TXT property holding the public key of the daemon advertising a session, in hexadecimal.
///
/// Only Midi Harbor publishes it, which is how a session is known to answer the identity
/// exchange before it is sent a packet no other implementation understands (R-106).
pub const KEY_PROPERTY: &str = "mhkey";
/// The TXT property holding the identifier of the network port a session is.
pub const PORT_PROPERTY: &str = "mhport";
/// How long a peer may go unseen before it is dropped from the list.
pub const PEER_TTL: Duration = Duration::from_secs(120);
@ -59,6 +69,85 @@ pub struct DiscoveredPeer {
pub port: u16,
/// Set when this is our own advertisement reflected back.
pub is_self: bool,
/// Which Midi Harbor network port it says it is. Unproven: an advertisement can say anything.
pub identity: Option<PortIdentity>,
}
/// What a network port holds about a machine it connects to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Followed<'a> {
/// The address it is connected to at.
pub held: SocketAddr,
/// Whether its invitations are accepted without asking.
pub trusted: bool,
/// The session name it was advertising when last seen at its address.
pub advertised_as: Option<&'a str>,
/// Which network port it proved it is, when it is a Midi Harbor.
pub identity: Option<PortIdentity>,
}
/// What the advertisements ask to be done about a machine a network port connects to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
/// A session is advertised where the machine is. Keep its name when that is given, and its
/// identity once it proves it there.
Learn {
/// The session's name, when it is not the one held.
name: Option<String>,
/// The identity it advertises, when it is not the one held.
identity: Option<PortIdentity>,
},
/// The machine's session is advertised somewhere else. Connect to it there, once it proves
/// `prove` there when that is set.
Move {
/// Where it is advertised now.
to: SocketAddr,
/// The name it is advertised under now.
name: String,
/// What it must prove at the new address before it is followed.
prove: Option<PortIdentity>,
},
}
/// Decides what the advertisements ask to be done about one machine.
///
/// A machine that proved which network port it is, is that port wherever it is advertised and
/// whatever it is called, and nothing else is: a port deleted and made again is another port. It
/// is followed to another host once it proves itself there. Any other machine is known by its
/// session name alone, which proves nothing. It is followed to a new port on the host it was on,
/// and to another host only when it is not trusted: trust is held by host, so following a name
/// would let whichever machine took it in unasked.
pub fn next_step(known: &Followed<'_>, advertised: &[DiscoveredPeer]) -> Option<Step> {
let advertised = || advertised.iter().filter(|peer| !peer.is_self);
// A session where the machine is says what it is called and which port it is.
if let Some(here) = advertised().find(|peer| peer.is_at(known.held)) {
let name = (known.advertised_as != Some(here.name.as_str())).then(|| here.name.clone());
let identity = here.identity.filter(|_| here.identity != known.identity);
return (name.is_some() || identity.is_some()).then_some(Step::Learn { name, identity });
}
// Otherwise it may be advertised somewhere else.
let moved = match known.identity {
Some(identity) => advertised().find(|peer| peer.identity == Some(identity)),
None => advertised().find(|peer| known.advertised_as == Some(peer.name.as_str())),
}?;
let name = moved.name.clone();
if moved.is_on(known.held.ip()) {
return Some(Step::Move {
to: SocketAddr::new(known.held.ip(), moved.port),
name,
prove: None,
});
}
if known.identity.is_none() && known.trusted {
return None;
}
Some(Step::Move {
to: moved.address()?,
name,
prove: known.identity,
})
}
impl DiscoveredPeer {
@ -67,6 +156,18 @@ impl DiscoveredPeer {
crate::net::choose_peer_address(&self.addresses, self.port)
}
/// Reports whether this session is advertised at `address`.
pub fn is_at(&self, address: SocketAddr) -> bool {
self.port == address.port() && self.is_on(address.ip())
}
/// Reports whether `host` is one of the addresses this session is advertised on.
fn is_on(&self, host: IpAddr) -> bool {
self.addresses
.iter()
.any(|address| address.to_canonical() == host.to_canonical())
}
/// Returns a label that distinguishes this peer from another with the same name.
///
/// Two machines advertising the same name is ordinary on a network of identical laptops, and
@ -100,6 +201,7 @@ impl PeerTable {
existing.addresses = peer.addresses;
existing.port = peer.port;
existing.name = peer.name;
existing.identity = peer.identity;
}
None => {
let _ = self.peers.insert(peer.fullname.clone(), peer);
@ -159,11 +261,16 @@ pub struct Discovery {
advertised: Arc<Mutex<Vec<(String, u16)>>>,
/// The running advertisements, keyed by the name each publishes.
services: Mutex<HashMap<String, Advertisement>>,
/// Notified each time a session is resolved or goes, so what follows sessions looks again.
changed: Arc<tokio::sync::Notify>,
/// This daemon's public key in hexadecimal, published with every session it advertises.
key: String,
}
impl Discovery {
/// Starts the responder and begins browsing.
pub fn start() -> Result<Arc<Self>, DiscoveryError> {
/// Starts the responder and begins browsing. `key` is this daemon's public key, published
/// with every session it advertises.
pub fn start(key: &[u8]) -> Result<Arc<Self>, DiscoveryError> {
let daemon = mdns_sd::ServiceDaemon::new()
.map_err(|error| DiscoveryError::Start(error.to_string()))?;
@ -172,6 +279,8 @@ impl Discovery {
table: Arc::new(Mutex::new(PeerTable::default())),
advertised: Arc::new(Mutex::new(Vec::new())),
services: Mutex::new(HashMap::new()),
changed: Arc::new(tokio::sync::Notify::new()),
key: hex::encode(key),
});
let receiver = discovery
@ -181,9 +290,10 @@ impl Discovery {
let table = Arc::clone(&discovery.table);
let advertised = Arc::clone(&discovery.advertised);
let changed = Arc::clone(&discovery.changed);
std::thread::Builder::new()
.name("mdns-browse".to_owned())
.spawn(move || browse_loop(receiver, table, advertised))
.spawn(move || browse_loop(receiver, table, advertised, changed))
.map_err(|error| DiscoveryError::Start(error.to_string()))?;
Ok(discovery)
@ -192,8 +302,10 @@ impl Discovery {
/// Advertises a session so other machines can find it.
///
/// Registered through the platform responder rather than the pure-Rust one, because the
/// latter announces once and then stops answering queries from other machines.
pub fn advertise(&self, name: &str, port: u16) -> Result<(), DiscoveryError> {
/// latter announces once and then stops answering queries from other machines. This
/// daemon's key and the network port's identifier `id` are published with it, so another
/// Midi Harbor knows which port the session is and that it can be asked to prove it.
pub fn advertise(&self, name: &str, port: u16, id: EndpointId) -> Result<(), DiscoveryError> {
// Remembering what we publish is what lets our own records be recognised coming back.
// Comparing against the machine name would not: a session advertises its own name.
if let Ok(mut advertised) = self.advertised.lock() {
@ -206,12 +318,17 @@ impl Discovery {
let thread_running = Arc::clone(&running);
let thread_name = name.to_owned();
let properties = vec![
(KEY_PROPERTY.to_owned(), self.key.clone()),
(PORT_PROPERTY.to_owned(), id.to_string()),
];
std::thread::Builder::new()
.name(format!("mdns-advertise-{name}"))
.spawn(move || {
midi_harbor_platform::responder::advertise(
&thread_name,
port,
&properties,
&thread_running,
&ready,
);
@ -268,6 +385,13 @@ impl Discovery {
names
}
/// Waits until a session has been resolved or has gone since this was last waited on.
///
/// A change while nothing was waiting is not missed: the next wait returns at once.
pub async fn changed(&self) {
self.changed.notified().await;
}
/// Returns the peers a user may connect to.
pub fn peers(&self) -> Vec<(DiscoveredPeer, String)> {
match self.table.lock() {
@ -282,6 +406,7 @@ fn browse_loop(
receiver: mdns_sd::Receiver<mdns_sd::ServiceEvent>,
table: Arc<Mutex<PeerTable>>,
advertised: Arc<Mutex<Vec<(String, u16)>>>,
changed: Arc<tokio::sync::Notify>,
) {
while let Ok(event) = receiver.recv() {
match event {
@ -301,6 +426,10 @@ fn browse_loop(
is_own_record(&poisoned.into_inner(), &local, &addresses, port)
}
};
let identity = info
.get_property_val_str(KEY_PROPERTY)
.zip(info.get_property_val_str(PORT_PROPERTY))
.and_then(|(key, port)| PortIdentity::from_text(key, port));
let peer = DiscoveredPeer {
id: PeerId::new(),
is_self,
@ -308,6 +437,7 @@ fn browse_loop(
fullname: info.get_fullname().to_owned(),
addresses,
port,
identity,
};
debug!(peer = %peer.fullname, own = peer.is_self, "resolved network peer");
@ -315,11 +445,17 @@ fn browse_loop(
Ok(mut table) => table.insert(peer),
Err(poisoned) => poisoned.into_inner().insert(peer),
}
changed.notify_one();
}
mdns_sd::ServiceEvent::ServiceRemoved(_, fullname) => match table.lock() {
mdns_sd::ServiceEvent::ServiceRemoved(_, fullname) => {
match table.lock() {
Ok(mut table) => table.remove(&fullname),
Err(poisoned) => poisoned.into_inner().remove(&fullname),
},
}
// A session renamed is advertised under its new name before the old one is
// withdrawn. Until then the old one says the port is still where it was.
changed.notify_one();
}
// A service that is found but never resolves is invisible to the user, so the two
// stages are logged separately: they fail for different reasons.
mdns_sd::ServiceEvent::ServiceFound(kind, fullname) => {
@ -341,7 +477,7 @@ fn instance_name(fullname: &str) -> &str {
}
/// Returns every address this machine's interfaces hold, loopback included.
fn local_addresses() -> Vec<IpAddr> {
pub(crate) fn local_addresses() -> Vec<IpAddr> {
match if_addrs::get_if_addrs() {
Ok(interfaces) => interfaces.iter().map(if_addrs::Interface::ip).collect(),
Err(error) => {
@ -385,6 +521,7 @@ mod tests {
addresses: vec![at(last_octet)],
port: 5004,
is_self,
identity: None,
}
}
@ -432,6 +569,97 @@ mod tests {
}
}
/// Proves what the advertisements ask to be done about a machine a network port connects
/// to. A session where it is gives it its name, and its identity to prove. A session by its
/// name on another port of its host is followed. On another host it is followed by name only
/// when it is not trusted, and by identity whatever it is called, once proved. A machine with
/// an identity is never followed by name: a port deleted and made again under the same name
/// is another port.
#[test]
fn an_advertisement_says_where_a_machine_is_and_what_it_must_prove() {
let held = SocketAddr::new(at(13), 5004);
let port_a = PortIdentity {
key: [1; 32],
port: EndpointId::from_bytes([2; 16]),
};
let port_b = PortIdentity {
port: EndpointId::from_bytes([3; 16]),
..port_a
};
let session = |name: &str, last_octet: u8, port: u16, identity| DiscoveredPeer {
port,
identity,
..peer(name, name, last_octet, false)
};
let known = |trusted, advertised_as, identity| Followed {
held,
trusted,
advertised_as,
identity,
};
let moved = |last_octet: u8, port: u16, name: &str, prove| {
Some(Step::Move {
to: SocketAddr::new(at(last_octet), port),
name: name.to_owned(),
prove,
})
};
let cases = [
(
"where it is, under the name held",
known(false, Some("Pad"), None),
session("Pad", 13, 5004, None),
None,
),
(
"where it is, not yet named, advertising an identity",
known(false, None, None),
session("Pad", 13, 5004, Some(port_a)),
Some(Step::Learn {
name: Some("Pad".to_owned()),
identity: Some(port_a),
}),
),
(
"by name on another port of its host, trusted",
known(true, Some("Pad"), None),
session("Pad", 13, 5010, None),
moved(13, 5010, "Pad", None),
),
(
"by name on another host, not trusted",
known(false, Some("Pad"), None),
session("Pad", 14, 5004, None),
moved(14, 5004, "Pad", None),
),
(
"by name on another host, trusted",
known(true, Some("Pad"), None),
session("Pad", 14, 5004, None),
None,
),
(
"by identity on another host, renamed, trusted",
known(true, Some("Pad"), Some(port_a)),
session("Stage Pad", 14, 5004, Some(port_a)),
moved(14, 5004, "Stage Pad", Some(port_a)),
),
(
"another port under the name of one with an identity",
known(false, Some("Pad"), Some(port_a)),
session("Pad", 13, 5010, Some(port_b)),
None,
),
];
for (name, known, advertised, want) in cases {
assert_eq!(
next_step(&known, &[advertised]),
want,
"{name}: the wrong thing is asked for"
);
}
}
/// Proves the peer list leaves out our own advertisement, which the responder reflects back,
/// and labels two machines advertising one name apart by address while a unique name is shown
/// plainly. Identical laptops advertise identical names, and two identical labels make one

View file

@ -0,0 +1,253 @@
//! Which daemon this is, provably, and which of its network ports a session is.
//!
//! A network port follows a machine it connected to when that machine's session is advertised
//! somewhere else. An advertisement proves nothing, so before a machine is followed to another
//! host it is asked to sign a challenge with the key it held where it was connected to (R-106).
//! Only another Midi Harbor can answer. Every other implementation is followed by name, and
//! only where that cannot let a stranger in.
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use midi_harbor_core::ids::EndpointId;
use midi_harbor_core::paths::Paths;
use midi_harbor_rtpmidi::identity::{KEY_LEN, NONCE_LEN, PORT_ID_LEN, SIGNATURE_LEN};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
/// File name of the daemon's key, beside the configuration document.
const KEY_FILE: &str = "identity.key";
/// Opens every message signed, so a signature made for this exchange means nothing anywhere else.
const DOMAIN: &[u8] = b"midi-harbor network port identity v1\0";
/// Why the daemon's key could not be read or kept.
#[derive(Debug, thiserror::Error)]
pub enum IdentityFileError {
/// The key file could not be read or written.
#[error("could not use {path}: {source}")]
Io {
/// The file.
path: PathBuf,
/// What went wrong.
source: std::io::Error,
},
/// The key file holds something other than a key.
#[error("{path} does not hold a key")]
Malformed {
/// The file.
path: PathBuf,
},
}
/// One network port of one daemon, as a session proves it and a remembered machine stores it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PortIdentity {
/// The public key of the daemon that runs the port.
pub key: [u8; KEY_LEN],
/// The port's identifier in that daemon's configuration, which a rename does not change.
pub port: EndpointId,
}
impl PortIdentity {
/// Reads an identity from a key in hexadecimal and a port identifier, as the configuration
/// writes them.
pub fn new(key: &str, port: EndpointId) -> Option<Self> {
let mut bytes = [0u8; KEY_LEN];
hex::decode_to_slice(key, &mut bytes).ok()?;
Some(Self { key: bytes, port })
}
/// Reads an identity from an advertisement's two properties.
pub fn from_text(key: &str, port: &str) -> Option<Self> {
Self::new(key, EndpointId::parse(port).ok()?)
}
/// Returns the key in hexadecimal.
pub fn key_text(&self) -> String {
hex::encode(self.key)
}
}
/// The key this daemon signs with.
pub struct Identity {
signing: SigningKey,
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The public half only. The private one must not reach a log.
write!(f, "Identity({})", hex::encode(self.public_key()))
}
}
impl Identity {
/// Reads the daemon's key, making and storing one the first time.
///
/// Kept in a file of its own rather than in the configuration document, which is exported,
/// copied to other machines and pasted into bug reports. Two machines sharing a key would
/// each pass for the other.
pub fn load_or_create(paths: &Paths) -> Result<Self, IdentityFileError> {
let path = paths.config_dir().join(KEY_FILE);
let io = |source| IdentityFileError::Io {
path: path.clone(),
source,
};
match std::fs::read_to_string(&path) {
Ok(text) => {
let mut seed = [0u8; KEY_LEN];
hex::decode_to_slice(text.trim(), &mut seed)
.map_err(|_| IdentityFileError::Malformed { path: path.clone() })?;
Ok(Self {
signing: SigningKey::from_bytes(&seed),
})
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let identity = Self::generate();
std::fs::create_dir_all(paths.config_dir()).map_err(io)?;
write_private(&path, &hex::encode(identity.signing.to_bytes())).map_err(io)?;
Ok(identity)
}
Err(error) => Err(io(error)),
}
}
/// Makes a key that is not stored, for a daemon that cannot keep one. It still answers
/// challenges, and is a stranger to every machine after a restart.
pub fn generate() -> Self {
Self {
signing: SigningKey::from_bytes(&rand::random::<[u8; KEY_LEN]>()),
}
}
/// Returns the public key other machines know this daemon by.
pub fn public_key(&self) -> [u8; KEY_LEN] {
self.signing.verifying_key().to_bytes()
}
/// Signs the answer to a challenge from `asker`, for the port `port`.
pub fn prove(
&self,
nonce: &[u8; NONCE_LEN],
asker: SocketAddr,
port: EndpointId,
) -> [u8; SIGNATURE_LEN] {
self.signing
.sign(&signed_message(nonce, asker, port))
.to_bytes()
}
}
/// Reports whether `signature` is `identity`'s answer to the challenge `nonce` from `asker`.
pub fn verifies(
identity: &PortIdentity,
nonce: &[u8; NONCE_LEN],
asker: SocketAddr,
signature: &[u8; SIGNATURE_LEN],
) -> bool {
let Ok(key) = VerifyingKey::from_bytes(&identity.key) else {
return false;
};
key.verify_strict(
&signed_message(nonce, asker, identity.port),
&Signature::from_bytes(signature),
)
.is_ok()
}
/// Builds the bytes a proof signs: the challenge, who asked, and which port answers.
///
/// The asker's address, as the answering port saw it, is what stops a machine passing a
/// challenge on to the real port and returning its answer as its own: the real port would sign
/// the go-between's address, not the asker's. The address is written as sixteen bytes, an IPv4
/// one mapped into IPv6, then the port, so both sides build the same bytes whichever family the
/// packet travelled in.
fn signed_message(nonce: &[u8; NONCE_LEN], asker: SocketAddr, port: EndpointId) -> Vec<u8> {
let address = match asker.ip().to_canonical() {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
let mut message = Vec::with_capacity(DOMAIN.len() + NONCE_LEN + 16 + 2 + PORT_ID_LEN);
message.extend_from_slice(DOMAIN);
message.extend_from_slice(nonce);
message.extend_from_slice(&address.octets());
message.extend_from_slice(&asker.port().to_be_bytes());
message.extend_from_slice(&port.to_bytes());
message
}
/// Writes a file only its owner can read.
fn write_private(path: &std::path::Path, text: &str) -> std::io::Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
let _ = options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Read and write for the owner, nothing for anyone else.
let _ = options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(text.as_bytes())?;
file.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
/// Proves a proof is accepted only as the answer it was signed as: by that key, for that
/// port, to that challenge, from that asker. An IPv4 asker seen through a dual-stack socket
/// arrives IPv4-mapped, and is the same asker.
#[test]
fn a_proof_answers_only_the_challenge_it_was_signed_for() {
let identity = Identity::generate();
let port = PortIdentity {
key: identity.public_key(),
port: EndpointId::new(),
};
let nonce = [5; NONCE_LEN];
let asker: SocketAddr = "192.0.2.10:5004".parse().unwrap();
let signature = identity.prove(&nonce, asker, port.port);
let other_port = PortIdentity {
port: EndpointId::new(),
..port
};
let other_key = PortIdentity {
key: Identity::generate().public_key(),
..port
};
let cases = [
("the challenge it answers", port, nonce, asker, true),
(
"the same asker, IPv4-mapped",
port,
nonce,
"[::ffff:192.0.2.10]:5004".parse().unwrap(),
true,
),
("another challenge", port, [6; NONCE_LEN], asker, false),
(
"another asker, as when the challenge was passed on",
port,
nonce,
"192.0.2.11:5004".parse().unwrap(),
false,
),
(
"another port of the daemon",
other_port,
nonce,
asker,
false,
),
("another daemon's key", other_key, nonce, asker, false),
];
for (name, expected, nonce, asker, want) in cases {
assert_eq!(
verifies(&expected, &nonce, asker, &signature),
want,
"{name}: accepted or refused wrongly"
);
}
}
}

View file

@ -9,6 +9,7 @@ pub mod bluetooth;
pub mod dataplane;
pub mod devices;
pub mod discovery;
pub mod identity;
pub mod log_file;
pub mod net;
pub mod network_port;

View file

@ -187,8 +187,11 @@ impl Daemon {
.read(|config, _| config.preferences.advertise_sessions)
.await;
if announce
&& let Err(error) =
discovery.advertise(after.local_name.as_str(), session.control_port())
&& let Err(error) = discovery.advertise(
after.local_name.as_str(),
session.control_port(),
id,
)
{
warn!(endpoint = %updated.name, error = %error, "could not advertise the new name");
}

View file

@ -96,9 +96,10 @@ impl HarborService {
/// Resolves a peer reference into an address to connect to.
///
/// Tried in order: a literal address, then a discovered peer, then a hostname. Discovery is
/// checked before hostname resolution deliberately — a peer's advertised name could otherwise
/// resolve to some unrelated machine and we would connect to the wrong thing.
/// Tried in order: a literal address, then a discovered peer, then a remembered machine,
/// then a hostname. Discovery and the remembered machines are checked before hostname
/// resolution deliberately — a peer's advertised name could otherwise resolve to some
/// unrelated machine and we would connect to the wrong thing.
///
/// Connecting by address is a first-class path, not a fallback: a network that filters
/// multicast breaks discovery, and a peer whose address is known should still be reachable.
@ -116,6 +117,12 @@ impl HarborService {
return Ok(address);
}
// A remembered machine is listed under its own identifier whether or not it is
// advertising, so the listing's identifier has to resolve here too.
if let Some(address) = self.daemon.remembered_address(reference).await {
return Ok(address);
}
// A hostname, resolved without blocking the runtime.
if let Some(address) = resolve_hostname(reference).await {
return Ok(address);

View file

@ -8,17 +8,20 @@
//! user has it enabled, and every failure silences the endpoint before anything else, so a peer
//! that vanishes mid-phrase cannot leave a note sounding.
use crate::identity::{Identity, PortIdentity};
use crate::net::{Datagram, NetError, SessionSockets};
use midi_harbor_core::backoff::BackoffPolicy;
use midi_harbor_core::controls::Controls;
use midi_harbor_core::endpoint::{InvitationDecision, InvitationPolicy};
use midi_harbor_core::failure::FailureReason;
use midi_harbor_core::ids::EndpointId;
use midi_harbor_core::midi::MidiMessage;
use midi_harbor_core::state::{ConnectionPhase, ConnectionState, Effect, Event};
use midi_harbor_core::time::{Clock, SystemClock};
use midi_harbor_rtpmidi::clock::ticks_from;
use midi_harbor_rtpmidi::identity::NONCE_LEN;
use midi_harbor_rtpmidi::session::{Action, Port, Role, Session, SessionFailure};
use midi_harbor_rtpmidi::{ControlPacket, RtpMidiPacket};
use midi_harbor_rtpmidi::{ControlPacket, IdentityError, IdentityPacket, RtpMidiPacket};
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
@ -38,6 +41,16 @@ pub const TICK_INTERVAL: Duration = Duration::from_millis(250);
/// second is enough for it to hear.
pub const GOODBYE_INTERVAL: Duration = Duration::from_secs(1);
/// How long a machine has to prove which network port it is.
///
/// A Midi Harbor on the same network answers in a millisecond or two. Long enough for a
/// challenge lost on the way to be sent again, short enough that a machine that will never
/// answer does not hold up following the others.
pub const PROOF_WAIT: Duration = Duration::from_secs(2);
/// How long before an unanswered challenge is sent again.
const CHALLENGE_INTERVAL: Duration = Duration::from_millis(500);
/// How long a session that ended itself ahead of sleep waits, awake, before reconnecting
/// without being told the machine woke.
///
@ -78,6 +91,32 @@ pub enum Command {
Invite(SocketAddr, oneshot::Sender<Place>),
/// End one machine's part, answering whether it was taking part.
DisconnectMachine(SocketAddr, oneshot::Sender<bool>),
/// Connect to a machine this side connected to at a new address in place of the old one,
/// unless it is carrying MIDI, answering whether it was moved.
Move {
/// Where it was connected to.
from: SocketAddr,
/// Where it is connected to from now on.
to: SocketAddr,
/// Answered with whether it moved.
done: oneshot::Sender<bool>,
},
/// Take the key to answer challenges with, and the identifier of the network port this is.
Identify {
/// The daemon's key.
identity: Arc<Identity>,
/// The network port's identifier.
port: EndpointId,
},
/// Ask the port listening at an address to prove it is `expected`, answering whether it did.
Prove {
/// The control address to ask.
at: SocketAddr,
/// The port it must prove it is.
expected: PortIdentity,
/// Answered with whether it proved it in time.
done: oneshot::Sender<bool>,
},
/// Take a new name, told to machines from the next invitation on. A machine already
/// connected keeps the name it was told, as Apple's sessions do.
Rename(String),
@ -271,6 +310,8 @@ impl NetworkSession {
peer_invited: false,
invited_guests: HashMap::new(),
joined_guests: HashSet::new(),
identity: None,
proving: Vec::new(),
};
let running = tokio::spawn(supervisor.run(inbox));
@ -352,6 +393,49 @@ impl NetworkSession {
answer.await.unwrap_or(false)
}
/// Connects to a machine this side connected to at `to` in place of `from`, and reports
/// whether it did.
///
/// A machine carrying MIDI is left where it is, and so is one the session does not connect
/// to. The supervisor decides, since only it knows which of its links are up.
pub async fn move_machine(&self, from: SocketAddr, to: SocketAddr) -> bool {
let (done, answer) = oneshot::channel();
if self
.commands
.send(Command::Move { from, to, done })
.await
.is_err()
{
return false;
}
answer.await.unwrap_or(false)
}
/// Gives the session the key it answers challenges with, and says which network port it is.
pub async fn identify(&self, identity: Arc<Identity>, port: EndpointId) -> bool {
self.commands
.send(Command::Identify { identity, port })
.await
.is_ok()
}
/// Asks the port listening at `at` to prove it is `expected`, and reports whether it did
/// within `PROOF_WAIT`.
///
/// Sent only to a session that advertises a key. No other implementation knows the packet.
pub async fn prove(&self, at: SocketAddr, expected: PortIdentity) -> bool {
let (done, answer) = oneshot::channel();
if self
.commands
.send(Command::Prove { at, expected, done })
.await
.is_err()
{
return false;
}
answer.await.unwrap_or(false)
}
/// Gives the session a new name for the machines it invites or accepts from now on.
pub async fn rename(&self, name: String) -> bool {
self.commands.send(Command::Rename(name)).await.is_ok()
@ -437,6 +521,26 @@ struct Supervisor {
invited_guests: HashMap<SocketAddr, ConnectionState>,
/// The guests that have finished joining.
joined_guests: HashSet<SocketAddr>,
/// The key challenges are answered with, and which network port this is.
identity: Option<(Arc<Identity>, EndpointId)>,
/// The challenges sent and not yet answered.
proving: Vec<Challenge>,
}
/// A challenge sent to a port, waiting for its proof.
struct Challenge {
/// The control address asked.
at: SocketAddr,
/// What was sent, which the proof must echo.
nonce: [u8; NONCE_LEN],
/// The port it must prove it is.
expected: PortIdentity,
/// When it was first sent.
asked: Instant,
/// When it was last sent.
sent: Instant,
/// Answered with whether it was proved.
done: oneshot::Sender<bool>,
}
impl Supervisor {
@ -485,6 +589,10 @@ impl Supervisor {
let found = self.disconnect_machine(machine).await;
let _ = done.send(found);
}
Command::Move { from, to, done } => {
let moved = self.move_machine(from, to).await;
let _ = done.send(moved);
}
Command::Connect(peer) => {
self.connect(peer).await;
// Connecting ends any wait for sleep to pass, so the machines invited beside the
@ -568,6 +676,20 @@ impl Supervisor {
self.policy = policy;
self.trusted = trusted;
}
Command::Identify { identity, port } => self.identity = Some((identity, port)),
Command::Prove { at, expected, done } => {
let now = Instant::now();
let challenge = Challenge {
at: canonical(at),
nonce: rand::random(),
expected,
asked: now,
sent: now,
done,
};
self.send_challenge(challenge.at, challenge.nonce).await;
self.proving.push(challenge);
}
Command::Rename(name) => {
info!(session = %self.name, to = %name, "network session renamed");
self.name = name;
@ -600,8 +722,110 @@ impl Supervisor {
self.carry_out(actions).await;
}
/// Sends a challenge to the port listening at `at`.
async fn send_challenge(&self, at: SocketAddr, nonce: [u8; NONCE_LEN]) {
let packet = IdentityPacket::Challenge { nonce };
self.send_to(Port::Control, at, &packet.encode()).await;
}
/// Sends unanswered challenges again, and gives up on those out of time.
async fn tend_challenges(&mut self) {
let now = Instant::now();
let (waiting, expired): (Vec<Challenge>, Vec<Challenge>) =
std::mem::take(&mut self.proving)
.into_iter()
.partition(|challenge| now.saturating_duration_since(challenge.asked) < PROOF_WAIT);
for challenge in expired {
debug!(session = %self.name, at = %challenge.at, "no proof of which port this is");
let _ = challenge.done.send(false);
}
self.proving = waiting;
let mut again = Vec::new();
for challenge in &mut self.proving {
if now.saturating_duration_since(challenge.sent) >= CHALLENGE_INTERVAL {
challenge.sent = now;
again.push((challenge.at, challenge.nonce));
}
}
for (at, nonce) in again {
self.send_challenge(at, nonce).await;
}
}
/// Answers a challenge, or takes a proof for one this side sent.
async fn on_identity(&mut self, packet: IdentityPacket, from: SocketAddr) {
match packet {
IdentityPacket::Challenge { nonce } => {
let Some((identity, port)) = &self.identity else {
return;
};
let proof = IdentityPacket::Proof {
nonce,
key: identity.public_key(),
port_id: port.to_bytes(),
signature: identity.prove(&nonce, from, *port),
};
self.send_to(Port::Control, from, &proof.encode()).await;
}
IdentityPacket::Proof {
nonce,
key,
port_id,
signature,
} => {
let Some(index) = self
.proving
.iter()
.position(|challenge| challenge.at == from && challenge.nonce == nonce)
else {
return;
};
// The answer must be the port asked for, signed for a challenge from this
// port: sent from one of this machine's addresses, on this control port. A
// wrong answer is ignored rather than ending the wait, so a forged one cannot
// spoil a real one on its way.
let claimed = PortIdentity {
key,
port: EndpointId::from_bytes(port_id),
};
let control_port = self.sockets.control_port();
let proved = self.proving.get(index).is_some_and(|challenge| {
claimed == challenge.expected
&& crate::discovery::local_addresses().into_iter().any(|own| {
crate::identity::verifies(
&claimed,
&nonce,
SocketAddr::new(own, control_port),
&signature,
)
})
});
if proved {
let challenge = self.proving.swap_remove(index);
let _ = challenge.done.send(true);
}
}
}
}
/// Handles a datagram arriving on either port.
async fn on_datagram(&mut self, datagram: Datagram) {
// The identity exchange stands apart from any session: a port is asked which it is
// whether or not it is connected to the asker.
if datagram.port == Port::Control {
match IdentityPacket::parse(&datagram.bytes) {
Ok(packet) => {
self.on_identity(packet, canonical(datagram.from)).await;
return;
}
Err(IdentityError::NotIdentity) => {}
Err(error) => {
debug!(from = %datagram.from, error = %error, "discarding an identity packet");
return;
}
}
}
// A guest's traffic goes to the guest's own session machine, never the peer's.
let from = control_address(&datagram);
if self.guests.contains_key(&from) {
@ -801,6 +1025,7 @@ impl Supervisor {
/// Gives the session machine a chance to act on elapsed time.
async fn on_tick(&mut self) {
self.tend_challenges().await;
for guest in self.guest_addresses() {
let ticks = self.ticks();
if let Some(session) = self.guests.get_mut(&guest) {
@ -892,7 +1117,7 @@ impl Supervisor {
};
let target = match port {
Port::Control => peer,
Port::Data => SocketAddr::new(peer.ip(), peer.port().saturating_add(1)),
Port::Data => on_port(peer, peer.port().saturating_add(1)),
};
let sent = self.sockets.send(port, target, bytes).await;
let no_route = sent.as_ref().is_err_and(NetError::is_no_route);
@ -919,7 +1144,7 @@ impl Supervisor {
async fn send_to(&self, port: Port, peer: SocketAddr, bytes: &[u8]) {
let target = match port {
Port::Control => peer,
Port::Data => SocketAddr::new(peer.ip(), peer.port().saturating_add(1)),
Port::Data => on_port(peer, peer.port().saturating_add(1)),
};
if let Err(error) = self.sockets.send(port, target, bytes).await {
debug!(session = %self.name, %target, error = %error, "could not answer a peer");
@ -1589,6 +1814,49 @@ impl Supervisor {
true
}
/// Connects to a machine this side connected to at `to` in place of `from`, unless it is
/// carrying MIDI, and reports whether it moved.
///
/// The attempt in progress at the old address is dropped without a goodbye, since nothing
/// was established there to end.
async fn move_machine(&mut self, from: SocketAddr, to: SocketAddr) -> bool {
let (from, to) = (canonical(from), canonical(to));
if self.peer.map(canonical) == Some(from) {
if self.status.lock().await.state.phase() == ConnectionPhase::Connected {
return false;
}
info!(session = %self.name, %from, %to, "following the peer to where it is advertised now");
self.connect(to).await;
return true;
}
if self.invited_guests.contains_key(&from) {
if self.joined_guests.contains(&from) {
return false;
}
info!(session = %self.name, %from, %to, "following a second machine to where it is advertised now");
let _ = self.invited_guests.remove(&from);
let _ = self.guests.remove(&from);
let _ = self.invite(to).await;
return true;
}
// Waiting out sleep, it is reconnected to at the new address on waking.
if let Some((peer, since)) = self.asleep
&& canonical(peer) == from
{
self.asleep = Some((to, since));
return true;
}
if let Some(asleep) = self
.asleep_guests
.iter_mut()
.find(|asleep| canonical(**asleep) == from)
{
*asleep = to;
return true;
}
false
}
/// Makes a guest the session's peer once the session has no other, so the machines still
/// connected are not left carried by nothing the session reports.
///
@ -1668,18 +1936,35 @@ fn failure_reason(failure: &SessionFailure, no_network: bool) -> FailureReason {
fn control_address(datagram: &Datagram) -> SocketAddr {
let control = match datagram.port {
Port::Control => datagram.from,
Port::Data => SocketAddr::new(datagram.from.ip(), datagram.from.port().saturating_sub(1)),
Port::Data => on_port(datagram.from, datagram.from.port().saturating_sub(1)),
};
canonical(control)
}
/// Returns an address on another port of the same machine.
///
/// The address is kept whole rather than rebuilt from its IP, because an IPv6 link-local address
/// is only reachable with its scope, the interface it was heard on.
fn on_port(address: SocketAddr, port: u16) -> SocketAddr {
let mut moved = address;
moved.set_port(port);
moved
}
/// Returns an address in the form machines are kept by.
///
/// The sockets are bound to the IPv6 wildcard, so an IPv4 machine's packets arrive from its
/// IPv4-mapped address. Keeping that form beside the plain one a user typed made a machine this
/// side invited look like an outsider when it answered, and it was sent a goodbye.
///
/// An IPv6 address keeps its scope. Apple's Network MIDI invites over the link-local address
/// Bonjour gives it, and the answer to `fe80::` with no scope goes nowhere: Audio MIDI Setup
/// reported that the port "didn't respond to the connection request".
fn canonical(address: SocketAddr) -> SocketAddr {
SocketAddr::new(address.ip().to_canonical(), address.port())
match address.ip().to_canonical() {
IpAddr::V4(v4) => SocketAddr::new(IpAddr::V4(v4), address.port()),
IpAddr::V6(_) => address,
}
}
/// Reports whether a datagram is something only a running session sends.
@ -1717,6 +2002,62 @@ mod tests {
SocketAddr::from(([127, 0, 0, 1], port))
}
/// Proves the address a machine is answered at is the one it was heard from: an IPv4 machine
/// seen through a dual-stack socket by its plain address, and an IPv6 link-local one with its
/// scope, on the control port whichever port it sent from.
///
/// Regression: the scope was dropped, so Apple's Network MIDI, which invites over the
/// link-local address Bonjour resolves, was never answered. Scope 14 stands for the interface
/// the invitation arrived on.
#[test]
fn a_machine_is_answered_at_the_address_it_was_heard_from() {
let link_local = |port: u16| {
SocketAddr::V6(std::net::SocketAddrV6::new(
"fe80::1".parse().unwrap(),
port,
0,
14,
))
};
let cases = [
(
"IPv4 through a dual-stack socket",
Port::Control,
"[::ffff:192.0.2.10]:5004".parse().unwrap(),
"192.0.2.10:5004".parse().unwrap(),
),
(
"link-local on the control port",
Port::Control,
link_local(5004),
link_local(5004),
),
(
"link-local on the data port",
Port::Data,
link_local(5005),
link_local(5004),
),
];
for (name, port, from, want) in cases {
let datagram = Datagram {
port,
from,
bytes: Vec::new(),
};
assert_eq!(
control_address(&datagram),
want,
"{name}: the wrong address is answered"
);
assert_eq!(
on_port(control_address(&datagram), want.port() + 1),
on_port(want, want.port() + 1),
"{name}: the data port is not on the same machine and interface"
);
}
}
/// Starts a session on loopback under a policy, keeping what it delivers and what it reports.
async fn started(
name: &str,

View file

@ -1,7 +1,8 @@
//! The daemon's owned state.
use crate::dataplane::{self, RtConsumer};
use crate::discovery::{DiscoveredPeer, Discovery};
use crate::discovery::{DiscoveredPeer, Discovery, Followed, Step};
use crate::identity::{Identity, PortIdentity};
use crate::session::{
Inbound, InvitationNotice, NetworkSession, Place, RESUME_AFTER_SLEEP, SessionNotice,
};
@ -406,6 +407,11 @@ pub struct Daemon {
/// Discovery, when it could be started. A machine without it still runs; peers simply have
/// to be added by address.
pub(crate) discovery: Option<Arc<Discovery>>,
/// The key this daemon's network ports prove themselves with.
identity: Arc<Identity>,
/// Held while machines are followed to where they are advertised, so two looks at once do
/// not each move the same machine.
following: tokio::sync::Mutex<()>,
/// Why the service cannot be installed on this machine, when it cannot. Asked once, since
/// whether a service manager exists does not change while the daemon runs.
pub(crate) service_unavailable: Option<midi_harbor_core::capability::UnavailableReason>,
@ -529,14 +535,22 @@ fn same_address(held: &str, address: SocketAddr) -> bool {
///
/// Found by the address exactly as stored, as a connected peer always has been. A machine
/// added here is not trusted: connecting out to a machine is not the same as letting it in
/// unasked.
fn known_peer_at(config: &mut Configuration, address: SocketAddr) -> midi_harbor_core::ids::PeerId {
/// unasked. The session name advertised at the address, when there is one, is kept with the
/// machine, so it can be followed when that session moves (R-105).
fn known_peer_at(
config: &mut Configuration,
address: SocketAddr,
advertised_as: Option<String>,
) -> midi_harbor_core::ids::PeerId {
let stored = address.to_string();
if let Some(known) = config
.peers
.iter()
.iter_mut()
.find(|known| known.addresses.iter().any(|held| held == &stored))
{
if advertised_as.is_some() {
known.advertised_as = advertised_as;
}
return known.id;
}
let peer = config::PeerConfig {
@ -544,6 +558,9 @@ fn known_peer_at(config: &mut Configuration, address: SocketAddr) -> midi_harbor
name: address.ip().to_canonical().to_string(),
addresses: vec![stored],
trusted: false,
advertised_as,
key: None,
port_id: None,
};
let id = peer.id;
config.peers.push(peer);
@ -564,10 +581,39 @@ fn network_session_mut(
}
}
/// Drops the remembered machines that hold nothing but an address no network port connects to.
///
/// Connecting to a machine records it, so the network port can name its peer. Once the port lets
/// it go, a record the user neither named nor trusted is only an old connection, and listing it
/// offers a machine that may no longer be listening there.
fn drop_unused_peers(config: &mut Configuration) {
let used: Vec<midi_harbor_core::ids::PeerId> = config
.endpoints
.iter()
.filter_map(|endpoint| match &endpoint.kind {
EndpointKind::NetworkSession(session) => Some(session),
_ => None,
})
.flat_map(|session| session.peer.iter().chain(session.other_peers.iter()))
.copied()
.collect();
config.peers.retain(|peer| {
// Named after its own address, as a machine recorded by connecting to it is.
let unnamed = peer
.addresses
.iter()
.any(|held| same_host(held, &peer.name));
peer.trusted || !unnamed || used.contains(&peer.id)
});
}
/// Lists remembered machines and advertised sessions together, one entry per session.
///
/// An advertisement from a remembered machine marks that machine as present rather than adding
/// a second entry, which would make the trusted one look like a different machine. Two
/// An advertisement from a trusted machine marks that machine as present rather than adding a
/// second entry, which would make the trusted one look like a different machine. Trust belongs
/// to the host, so any session it advertises marks it. A machine remembered without trust is
/// only an address, so it is marked by the session advertised at that address and by no other:
/// folding another session into it listed that session under a port nothing listens on. Two
/// advertisements from one machine stay two entries: they are separate sessions, and folding the
/// second into the first left it impossible to find.
fn merge_known_peers(
@ -587,19 +633,25 @@ fn merge_known_peers(
let remembered_count = known.len();
for (peer, label) in discovered {
let address = peer.address().map(|address| address.to_string());
let existing = address.as_ref().and_then(|address| {
known
.iter_mut()
.take(remembered_count)
.find(|known| known.addresses.iter().any(|held| same_host(held, address)))
let address = peer.address();
let existing = address.and_then(|address| {
let advertised = address.to_string();
known.iter_mut().take(remembered_count).find(|known| {
known.addresses.iter().any(|held| {
if known.trusted {
same_host(held, &advertised)
} else {
same_address(held, address)
}
})
})
});
match existing {
Some(entry) => entry.discovered = true,
None => known.push(KnownPeer {
id: peer.id,
name: label,
addresses: address.map(|a| vec![a]).unwrap_or_default(),
addresses: address.map(|a| vec![a.to_string()]).unwrap_or_default(),
discovered: true,
trusted: false,
}),
@ -745,7 +797,13 @@ impl Daemon {
let (changes, _) = broadcast::channel(STATE_CHANNEL_CAPACITY);
// Discovery failing is not fatal: peers can still be added by address, and the capability
// query reports honestly that browsing is unavailable.
let discovery = match Discovery::start() {
// A daemon that cannot keep its key still runs. It proves itself until it restarts, and
// is a stranger to every machine after.
let identity = Arc::new(Identity::load_or_create(&paths).unwrap_or_else(|error| {
error!(error = %error, "failed to keep this daemon's key; using one for this run");
Identity::generate()
}));
let discovery = match Discovery::start(&identity.public_key()) {
Ok(discovery) => Some(discovery),
Err(error) => {
warn!(error = %error, "network discovery unavailable; peers must be added by address");
@ -801,6 +859,8 @@ impl Daemon {
restart: tokio::sync::Notify::new(),
stop: tokio::sync::Notify::new(),
discovery,
identity,
following: tokio::sync::Mutex::new(()),
service_unavailable,
host_name: default_machine_name(),
bt_links: RwLock::new(HashMap::new()),
@ -819,6 +879,7 @@ impl Daemon {
daemon.watch_midi_environment();
daemon.watch_bluetooth();
daemon.watch_retries();
daemon.watch_discovery();
daemon.watch_traffic_log();
Ok(daemon)
}
@ -1035,7 +1096,7 @@ impl Daemon {
continue;
};
if announce {
if let Err(error) = discovery.advertise(name, port) {
if let Err(error) = discovery.advertise(name, port, id) {
warn!(endpoint = %name, error = %error, "could not advertise session");
}
} else {
@ -1393,6 +1454,8 @@ impl Daemon {
.map(|route| format!("{} -> {}", route.from, route.to))
.collect();
// A network port takes the machines only it connected to with it.
drop_unused_peers(&mut inner.config);
config::save(&self.paths, &inner.config)?;
drop(inner);
@ -1759,6 +1822,19 @@ impl Daemon {
merge_known_peers(&remembered, self.peers())
}
/// Returns where a remembered machine is reached, by identifier or name.
pub async fn remembered_address(&self, reference: &str) -> Option<SocketAddr> {
let inner = self.inner.read().await;
inner
.config
.peers
.iter()
.find(|known| known.id.to_string() == reference || known.name == reference)?
.addresses
.iter()
.find_map(|address| address.parse::<SocketAddr>().ok())
}
/// Remembers a machine by address, so it is let in without being asked about.
pub async fn add_manual_peer(
self: &Arc<Self>,
@ -1800,6 +1876,9 @@ impl Daemon {
name: label,
addresses: vec![stored],
trusted,
advertised_as: None,
key: None,
port_id: None,
};
inner.config.peers.push(peer.clone());
peer
@ -1977,7 +2056,11 @@ impl Daemon {
change => {
watcher
.record_session_change(id, &session_name, change)
.await
.await;
// A link that went down may be one whose session is already advertised
// somewhere else, seen while the link was still up. One that came up
// may be to a session whose name and key are not held yet.
watcher.follow_discovery().await;
}
}
}
@ -2000,6 +2083,9 @@ impl Daemon {
// The peers the user has already accepted, so a trusted one is not asked about again.
let trusted = self.trusted_addresses().await;
let _ = session.configure(config.invitation_policy, trusted).await;
let _ = session
.identify(Arc::clone(&self.identity), endpoint.id)
.await;
let port = session.control_port();
// A session left to the system's choice of port keeps the port it was given. Choosing
@ -2030,7 +2116,7 @@ impl Daemon {
.await;
if announce
&& let Some(discovery) = &self.discovery
&& let Err(error) = discovery.advertise(config.local_name.as_str(), port)
&& let Err(error) = discovery.advertise(config.local_name.as_str(), port, endpoint.id)
{
warn!(endpoint = %endpoint.name, error = %error, "could not advertise session");
}
@ -2092,24 +2178,27 @@ impl Daemon {
///
/// The peer is kept among the remembered machines, found by address or added, and is not
/// trusted by this. Connected to none, the session forgets the machines it had connected
/// beside the peer too, since disconnecting a session ends every machine's part.
/// beside the peer too, since disconnecting a session ends every machine's part. A machine
/// let go this way that was remembered only for the connection is dropped with it.
async fn remember_session_peer(
&self,
id: EndpointId,
peer: Option<SocketAddr>,
) -> Result<(), DaemonError> {
let advertised_as = peer.and_then(|address| self.advertised_at(address));
let mut inner = self.inner.write().await;
let chosen = peer.map(|address| known_peer_at(&mut inner.config, address));
let before = inner.config.clone();
let chosen = peer.map(|address| known_peer_at(&mut inner.config, address, advertised_as));
let Some(session) = network_session_mut(&mut inner.config, id) else {
return Ok(());
};
let before = session.clone();
session.peer = chosen;
match chosen {
Some(chosen) => session.other_peers.retain(|other| *other != chosen),
None => session.other_peers.clear(),
}
if *session == before {
drop_unused_peers(&mut inner.config);
if inner.config == before {
return Ok(());
}
config::save(&self.paths, &inner.config)?;
@ -2123,15 +2212,19 @@ impl Daemon {
id: EndpointId,
machine: SocketAddr,
) -> Result<(), DaemonError> {
let advertised_as = self.advertised_at(machine);
let mut inner = self.inner.write().await;
let known = known_peer_at(&mut inner.config, machine);
let before = inner.config.clone();
let known = known_peer_at(&mut inner.config, machine, advertised_as);
let Some(session) = network_session_mut(&mut inner.config, id) else {
return Ok(());
};
if session.peer == Some(known) || session.other_peers.contains(&known) {
if session.peer != Some(known) && !session.other_peers.contains(&known) {
session.other_peers.push(known);
}
if inner.config == before {
return Ok(());
}
session.other_peers.push(known);
config::save(&self.paths, &inner.config)?;
Ok(())
}
@ -2178,10 +2271,179 @@ impl Daemon {
if *session == before {
return Ok(());
}
drop_unused_peers(&mut inner.config);
config::save(&self.paths, &inner.config)?;
Ok(())
}
/// Returns the name of the session advertised at `address`, if one is.
fn advertised_at(&self, address: SocketAddr) -> Option<String> {
self.peers()
.into_iter()
.find(|(peer, _)| peer.is_at(address))
.map(|(peer, _)| peer.name)
}
/// Follows the sessions discovery sees now.
async fn follow_discovery(self: &Arc<Self>) {
let advertised: Vec<DiscoveredPeer> =
self.peers().into_iter().map(|(peer, _)| peer).collect();
self.follow_advertised(&advertised).await;
}
/// Returns this daemon's public key in hexadecimal, as its sessions advertise it.
pub fn identity_key(&self) -> String {
hex::encode(self.identity.public_key())
}
/// Brings what each network port holds about the machines it connects to into line with
/// the sessions advertised now, and moves a machine whose link is down to where its session
/// is advertised (R-105, R-106).
///
/// A network port invites the address it connected to until it answers. A session that
/// comes back on another port, or a machine given another address, never answers there, and
/// its advertisement is the only thing that says where it went. The new address is stored,
/// so a restart goes straight to it.
pub async fn follow_advertised(self: &Arc<Self>, advertised: &[DiscoveredPeer]) {
let _following = self.following.lock().await;
// Decide what the advertisements ask for, machine by machine.
let steps: Vec<(
EndpointId,
midi_harbor_core::ids::PeerId,
SocketAddr,
bool,
Step,
)> = {
let inner = self.inner.read().await;
let mut steps = Vec::new();
for endpoint in &inner.config.endpoints {
let EndpointKind::NetworkSession(session) = &endpoint.kind else {
continue;
};
for id in session.peer.iter().chain(session.other_peers.iter()) {
let Some(known) = inner.config.peers.iter().find(|known| known.id == *id)
else {
continue;
};
let Some(held) = known
.addresses
.iter()
.find_map(|address| address.parse::<SocketAddr>().ok())
else {
continue;
};
let followed = Followed {
held,
trusted: known.trusted,
advertised_as: known.advertised_as.as_deref(),
identity: known
.key
.as_deref()
.zip(known.port_id)
.and_then(|(key, port)| PortIdentity::new(key, port)),
};
if let Some(step) = crate::discovery::next_step(&followed, advertised) {
steps.push((endpoint.id, *id, held, known.trusted, step));
}
}
}
steps
};
for (endpoint, peer, held, trusted, step) in steps {
let session = self.sessions.read().await.get(&endpoint).map(Arc::clone);
let Some(session) = session else {
continue;
};
match step {
// Keep what the session where the machine is says about it. Its identity is
// kept only once proved there: an advertisement can name any address.
Step::Learn { name, identity } => {
let proved = match identity {
Some(identity) if session.prove(held, identity).await => Some(identity),
_ => None,
};
if name.is_none() && proved.is_none() {
continue;
}
self.change_known_peer(peer, |known| {
if let Some(name) = name {
known.advertised_as = Some(name);
}
if let Some(identity) = proved {
known.key = Some(identity.key_text());
known.port_id = Some(identity.port);
}
})
.await;
}
// Move the link. The session refuses for a machine carrying MIDI, whose
// advertisement elsewhere may be another machine that took the name.
Step::Move { to, name, prove } => {
if let Some(identity) = prove
&& !session.prove(to, identity).await
{
debug!(%endpoint, %to, "a session advertised as a known port did not prove it");
continue;
}
if !session.move_machine(held, to).await {
continue;
}
let stored = held.to_string();
self.change_known_peer(peer, |known| {
known.advertised_as = Some(name);
if let Some(address) = known
.addresses
.iter_mut()
.find(|address| **address == stored)
{
*address = to.to_string();
}
})
.await;
// Trust is held by host, so a trusted machine proved on another host is
// trusted there from now on.
if trusted && to.ip().to_canonical() != held.ip().to_canonical() {
self.push_invitation_policy().await;
}
let _ = self.changes.send(Change::EndpointChanged(endpoint));
}
}
}
}
/// Changes one remembered machine and stores the configuration.
async fn change_known_peer(
&self,
peer: midi_harbor_core::ids::PeerId,
change: impl FnOnce(&mut config::PeerConfig),
) {
let mut inner = self.inner.write().await;
let Some(known) = inner.config.peers.iter_mut().find(|known| known.id == peer) else {
return;
};
change(known);
if let Err(error) = config::save(&self.paths, &inner.config) {
error!(peer = %peer, error = %error, "failed to store what is known about a machine");
}
}
/// Watches the sessions discovery sees come and go, and follows each machine a network port
/// connects to to where it is advertised.
fn watch_discovery(self: &Arc<Self>) {
let Some(discovery) = self.discovery.clone() else {
return;
};
let daemon = Arc::clone(self);
tokio::spawn(async move {
loop {
discovery.changed().await;
daemon.follow_discovery().await;
}
});
}
/// Creates a network session with its automatic port, persists it, and starts listening.
pub async fn create_network_session(
self: &Arc<Self>,
@ -3506,6 +3768,9 @@ impl Daemon {
name,
addresses: vec![address],
trusted: true,
advertised_as: None,
key: None,
port_id: None,
});
}
config::save(&self.paths, &inner.config)?;
@ -4195,14 +4460,19 @@ mod known_peer_tests {
addresses: vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10))],
port,
is_self: false,
identity: None,
};
(peer, name.to_owned())
}
/// Proves how remembered and advertised machines merge into one list. A remembered machine is
/// Proves how remembered and advertised machines merge into one list. A trusted machine is
/// matched by host, so its advertisement marks it discovered under the name the user gave it
/// instead of listing it twice; two sessions one machine advertises, which Apple's Network
/// MIDI lets a Mac run side by side, are each listed when nothing remembers that host.
///
/// Regression: a machine remembered without trust at a port it no longer listened on was
/// matched by host too, so another session that host advertised was listed as the old
/// connection, on this network, at the dead port.
#[test]
fn a_machine_is_listed_once_however_it_is_known() {
let studio_mac = config::PeerConfig {
@ -4210,6 +4480,18 @@ mod known_peer_tests {
name: "Studio Mac".to_owned(),
addresses: vec!["192.0.2.10:5004".to_owned()],
trusted: true,
advertised_as: None,
key: None,
port_id: None,
};
let old_connection = config::PeerConfig {
id: PeerId::new(),
name: "192.0.2.10".to_owned(),
addresses: vec!["192.0.2.10:5004".to_owned()],
trusted: false,
advertised_as: None,
key: None,
port_id: None,
};
let cases = [
(
@ -4219,11 +4501,29 @@ mod known_peer_tests {
vec![("Apple Two", true, false), ("Studio", true, false)],
),
(
"a remembered machine that advertises is listed once, as remembered",
vec![studio_mac],
"a trusted machine that advertises is listed once, as remembered",
vec![studio_mac.clone()],
vec![advertised("Studio", 5004)],
vec![("Studio Mac", true, true)],
),
(
"a trusted machine is marked by a session on any port of its host",
vec![studio_mac],
vec![advertised("Studio", 5006)],
vec![("Studio Mac", true, true)],
),
(
"an untrusted address is marked by the session advertised there",
vec![old_connection.clone()],
vec![advertised("Studio", 5004)],
vec![("192.0.2.10", true, false)],
),
(
"an untrusted address does not take a session on another port",
vec![old_connection],
vec![advertised("Studio", 5006)],
vec![("192.0.2.10", false, false), ("Studio", true, false)],
),
];
for (name, remembered, discovered, want) in cases {
let known = merge_known_peers(&remembered, discovered);

View file

@ -21,6 +21,7 @@ use midi_harbor_daemon::Daemon;
use midi_harbor_ipc::{HarborClient, pb, transport};
use midi_harbor_platform::fake::{FakeMidiPlatform, Injected};
use midi_harbor_platform::midi::MidiPlatform;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@ -490,6 +491,75 @@ async fn a_client_connects_a_second_machine_alongside_and_disconnects_one() {
);
}
/// Proves that a machine added by address is connected to by the identifier the listing gives
/// it, and is still listed once the network port disconnects from it.
///
/// Regression: the identifier was looked up only among the machines advertising themselves, so
/// connecting to a remembered machine failed with "no peer named" and its identifier. A machine
/// the user added is theirs to forget; only one recorded by connecting to it goes with the
/// connection.
#[tokio::test]
async fn a_client_connects_to_a_remembered_machine_by_its_listed_identifier() {
let (daemon, _platform, socket) = serving("remembered").await;
let stage = daemon
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
.await
.expect("the network port Stage is created");
let (_front, front_port) = accepting("remembered-front", "Front of House").await;
let mut client = HarborClient::new(
transport::connect(&socket)
.await
.expect("the client connects to the daemon's socket"),
);
let added = client
.add_manual_peer(pb::AddManualPeerRequest {
address: "127.0.0.1".to_owned(),
port: u32::from(front_port),
name: Some("Front of House".to_owned()),
trusted: Some(false),
})
.await
.expect("the client adds Front of House by address")
.into_inner();
client
.connect_peer(pb::ConnectPeerRequest {
session_endpoint_id: stage.id.to_string(),
peer_id: added.id.clone(),
alongside: false,
})
.await
.expect("the client connects Stage to the machine by its identifier");
let peer = daemon
.session_status(stage.id)
.await
.expect("Stage is running")
.peer_address;
assert_eq!(
peer,
Some(SocketAddr::from(([127, 0, 0, 1], front_port))),
"Stage connects to the address the machine was added at"
);
client
.disconnect_peer(pb::DisconnectPeerRequest {
session_endpoint_id: stage.id.to_string(),
})
.await
.expect("the client disconnects Stage");
// Whatever is advertising on the network the test runs on is listed too.
let listed = client
.list_peers(pb::ListPeersRequest::default())
.await
.expect("the daemon lists its known machines")
.into_inner()
.peers;
assert!(
listed.iter().any(|peer| peer.id == added.id),
"a machine the user added and named is kept when the connection goes: {listed:?}"
);
}
/// Proves that the gRPC contract adds a machine untrusted, switches its trust on and off by
/// identifier or by name, reports the change both in its reply and in the listing, and treats an
/// unset trust as trusted.

View file

@ -14,6 +14,8 @@ use midi_harbor_core::endpoint::{EndpointKind, InvitationPolicy, NetworkSession}
use midi_harbor_core::failure::FailureReason;
use midi_harbor_core::ids::EndpointId;
use midi_harbor_core::state::ConnectionPhase;
use midi_harbor_daemon::discovery::DiscoveredPeer;
use midi_harbor_daemon::identity::PortIdentity;
use midi_harbor_daemon::{Daemon, DaemonError, NetworkPortChange};
use midi_harbor_platform::fake::FakeMidiPlatform;
use midi_harbor_platform::midi::MidiPlatform;
@ -567,7 +569,10 @@ async fn machines_until(
/// place, and that a machine no longer taking part cannot be disconnected again.
///
/// The peer disconnected by the user is forgotten, so it is not connected to again on the next
/// start.
/// start, and it leaves the remembered machines with the connection that put it there.
///
/// Regression: the machine stayed remembered at the address it was connected at, so the network
/// port went on offering an old connection after the far side had stopped listening there.
#[tokio::test]
async fn a_second_machine_joins_beside_the_first_and_each_can_be_disconnected_alone() {
let (near, stage, far, front) = joined().await;
@ -632,6 +637,20 @@ async fn a_second_machine_joins_beside_the_first_and_each_can_be_disconnected_al
remembered.other_peers.is_empty(),
"the machine promoted to peer is still remembered beside it: {remembered:?}"
);
let known: Vec<Vec<String>> = near
.read(|config, _| {
config
.peers
.iter()
.map(|known| known.addresses.clone())
.collect()
})
.await;
assert_eq!(
known,
vec![vec![to_booth.to_string()]],
"only the machine still connected is remembered, not the one disconnected"
);
let left = machines_until(&near, stage, |machines| {
machines.len() == 1 && machines[0].joined
})
@ -1385,3 +1404,233 @@ async fn a_hand_written_port_and_network_port_of_one_name_are_kept_and_reported(
});
assert!(reported, "the clash was not recorded in the history");
}
/// Builds an advertisement of a session as discovery reports one, since a test runner cannot be
/// relied on to carry multicast.
fn advertisement(
name: &str,
address: SocketAddr,
identity: Option<PortIdentity>,
) -> DiscoveredPeer {
DiscoveredPeer {
id: midi_harbor_core::ids::PeerId::new(),
name: name.to_owned(),
fullname: format!("{name}._apple-midi._udp.local."),
addresses: vec![address.ip()],
port: address.port(),
is_self: false,
identity,
}
}
/// Starts a near daemon over a hand-written configuration whose network port Stage connects to
/// the machine written as `peer`, the lines of one entry under `peers`. Returns the daemon, Stage
/// and the paths, to read the configuration back from disk.
async fn stage_connecting_to(
label: &str,
peer: &str,
) -> (Arc<Daemon>, EndpointId, midi_harbor_core::paths::Paths) {
let root = common::scratch("midi-harbor-network-ports")
.join(format!("{label}-{}", uuid::Uuid::new_v4()));
let paths = midi_harbor_core::paths::Paths::rooted_at(root);
std::fs::create_dir_all(paths.config_dir()).expect("the configuration directory is created");
std::fs::write(
paths.config_file(),
format!(
"preferences:\n\
\x20 advertise_sessions: false\n\
endpoints:\n\
- name: Stage\n\
\x20 kind: network_port\n\
\x20 control_port: 0\n\
\x20 peer: 6f1d4c1e-3b0a-4a52-9d57-0c2f5a8e7b11\n\
peers:\n\
- id: 6f1d4c1e-3b0a-4a52-9d57-0c2f5a8e7b11\n\
\x20 name: Front of House\n\
{peer}"
),
)
.expect("the configuration is written");
let near = Daemon::start(
paths.clone(),
Arc::new(FakeMidiPlatform::new()) as Arc<dyn MidiPlatform>,
)
.await
.expect("the near daemon starts over the configuration");
let stage = near
.read(|config, _| {
config
.endpoints
.iter()
.find(|endpoint| endpoint.name.as_str() == "Stage")
.map(|endpoint| endpoint.id)
})
.await
.expect("Stage is loaded from the configuration");
(near, stage, paths)
}
/// Returns the one remembered machine, read back from disk.
fn stored_machine(paths: &midi_harbor_core::paths::Paths) -> midi_harbor_core::config::PeerConfig {
midi_harbor_core::config::load(paths)
.expect("the configuration is read back from disk")
.config
.peers
.into_iter()
.next()
.expect("one machine is remembered")
}
/// Proves that a machine whose session is advertised on another port is connected to there once
/// its link is down, with the new address stored for the next start, and that a machine
/// carrying MIDI is left where it is (R-105).
///
/// A session that comes back on a port the system chose again was invited at its old port for
/// good, although it was advertised on the new one. An advertisement naming a connected machine
/// elsewhere may be another machine that took the name, so it moves nothing.
#[tokio::test]
async fn a_machine_whose_session_moved_is_followed_to_where_it_is_advertised() {
let (_front_machine, _, to_front) = accepting_machine("moved-front", "Front of House").await;
let (_booth_machine, _, to_booth) = accepting_machine("moved-booth", "Booth").await;
// Where Front of House was when Stage connected to it. Nothing listens there now.
let gone = free_pair();
let (near, stage, paths) = stage_connecting_to(
"moved",
&format!(
"\x20 addresses: [\"127.0.0.1:{gone}\"]\n\
\x20 advertised_as: Front of House\n"
),
)
.await;
// Its link down, the machine is followed to the port its session is advertised on.
near.follow_advertised(&[advertisement("Front of House", to_front, None)])
.await;
assert!(
connected(&near, stage).await,
"Stage did not connect to the session where it is advertised now"
);
assert_eq!(
stored_machine(&paths).addresses,
vec![to_front.to_string()],
"the new address is not stored, so a restart would invite the old one"
);
// Carrying MIDI, it is left where it is.
near.follow_advertised(&[advertisement("Front of House", to_booth, None)])
.await;
let machines = machines_until(&near, stage, |machines| all_joined(machines, &[to_front])).await;
assert!(
all_joined(&machines, &[to_front]),
"a connected machine was moved by an advertisement: {machines:?}"
);
assert_eq!(
stored_machine(&paths).addresses,
vec![to_front.to_string()],
"a connected machine's stored address was changed by an advertisement"
);
}
/// Proves that a trusted machine is followed to another host only by the session that proves
/// it is the network port connected to before, and that the trust goes with it (R-106).
///
/// Trust is held by host, and an advertisement can claim any key. A machine claiming Front of
/// House's key from another host is asked to sign a challenge with it; Booth cannot, and is not
/// followed. The IPv6 loopback address stands for the host Front of House left, and the IPv4 one
/// for the host it is on now.
#[tokio::test]
async fn a_trusted_machine_is_followed_to_another_host_once_it_proves_which_port_it_is() {
let (front_machine, front, to_front) =
accepting_machine("proved-front", "Front of House").await;
let (_booth_machine, _, to_booth) = accepting_machine("proved-booth", "Booth").await;
let front_of_house = PortIdentity::new(&front_machine.identity_key(), front)
.expect("Front of House has a key and an identifier");
let gone = free_pair();
let held = format!("[::1]:{gone}");
let (near, stage, paths) = stage_connecting_to(
"proved",
&format!(
"\x20 addresses: [\"{held}\"]\n\
\x20 trusted: true\n\
\x20 advertised_as: Front of House\n\
\x20 key: {}\n\
\x20 port_id: {front}\n",
front_machine.identity_key()
),
)
.await;
// Booth advertises itself as Front of House's port. It cannot prove it.
near.follow_advertised(&[advertisement(
"Front of House",
to_booth,
Some(front_of_house),
)])
.await;
assert_eq!(
stored_machine(&paths).addresses,
vec![held],
"a machine that did not prove the key was followed, and trusted"
);
// Front of House, renamed and on another host, proves it.
near.follow_advertised(&[advertisement("Main Stage", to_front, Some(front_of_house))])
.await;
assert!(
connected(&near, stage).await,
"Stage did not follow the port that proved itself"
);
let stored = stored_machine(&paths);
assert_eq!(
(stored.addresses, stored.advertised_as, stored.trusted),
(
vec![to_front.to_string()],
Some("Main Stage".to_owned()),
true
),
"the proved port's new address and name are not stored with its trust"
);
}
/// Proves that the key and identifier a session advertises are kept with the machine only once
/// the port at the address connected to proves them (R-106).
///
/// An advertisement can name any address, so a forged one at a trusted machine's address would
/// otherwise plant a key for its forger to prove later from anywhere.
#[tokio::test]
async fn a_machines_key_is_kept_only_once_proved_where_it_is_connected() {
let (front_machine, front, to_front) =
accepting_machine("learnt-front", "Front of House").await;
let (booth_machine, _, _) = accepting_machine("learnt-booth", "Booth").await;
let front_of_house = PortIdentity::new(&front_machine.identity_key(), front)
.expect("Front of House has a key and an identifier");
let forged = PortIdentity::new(&booth_machine.identity_key(), front).expect("Booth has a key");
let (near, stage, paths) =
stage_connecting_to("learnt", &format!("\x20 addresses: [\"{to_front}\"]\n")).await;
assert!(
connected(&near, stage).await,
"Stage connects to Front of House where it is remembered"
);
near.follow_advertised(&[advertisement("Front of House", to_front, Some(forged))])
.await;
let stored = stored_machine(&paths);
assert_eq!(
(stored.advertised_as.as_deref(), stored.key, stored.port_id),
(Some("Front of House"), None, None),
"the session's name is kept, and a key the port did not prove is not"
);
near.follow_advertised(&[advertisement(
"Front of House",
to_front,
Some(front_of_house),
)])
.await;
let stored = stored_machine(&paths);
assert_eq!(
(stored.key, stored.port_id),
(Some(front_machine.identity_key()), Some(front)),
"the key and identifier the port proved are not kept"
);
}

View file

@ -17,16 +17,22 @@ use std::sync::mpsc::Sender;
pub const SERVICE: &str = "_apple-midi._udp";
/// Registers `name` on `port` with the platform responder and keeps it published until
/// `running` is cleared.
/// `running` is cleared. `properties` are published with it as its TXT record.
///
/// Blocks for the life of the advertisement, so the caller gives it a thread. `ready` receives
/// one answer once the responder has accepted or refused the name, so a rejected registration is
/// reported rather than leaving a session that believes it is advertised.
pub fn advertise(name: &str, port: u16, running: &AtomicBool, ready: &Sender<Result<(), String>>) {
pub fn advertise(
name: &str,
port: u16,
properties: &[(String, String)],
running: &AtomicBool,
ready: &Sender<Result<(), String>>,
) {
#[cfg(windows)]
windows::advertise(name, port, running, ready);
windows::advertise(name, port, properties, running, ready);
#[cfg(not(windows))]
zeroconf::advertise(name, port, running, ready);
zeroconf::advertise(name, port, properties, running, ready);
}
/// Reports whether this machine has a responder to register with.

View file

@ -87,6 +87,7 @@ pub(super) fn present() -> bool {
pub(super) fn advertise(
name: &str,
port: u16,
properties: &[(String, String)],
running: &AtomicBool,
ready: &Sender<Result<(), String>>,
) {
@ -100,9 +101,16 @@ pub(super) fn advertise(
// Describe the service.
let instance_name = wide(&format!("{}.{SERVICE}.local", instance_label(name)));
let host_name = wide(&format!("{}.local", computer_name()));
// SAFETY: both strings are NUL-terminated and outlive the call, which copies them. Null
// addresses ask the responder to answer with the host's own, and a zero property count means
// the key and value arrays are not read.
// The TXT record, as two arrays of strings the same length.
let keys: Vec<Vec<u16>> = properties.iter().map(|(key, _)| wide(key)).collect();
let values: Vec<Vec<u16>> = properties.iter().map(|(_, value)| wide(value)).collect();
let key_pointers: Vec<PCWSTR> = keys.iter().map(|key| key.as_ptr()).collect();
let value_pointers: Vec<PCWSTR> = values.iter().map(|value| value.as_ptr()).collect();
let property_count = u32::try_from(properties.len()).unwrap_or(0);
// SAFETY: every string is NUL-terminated and outlives the call, which copies them. Null
// addresses ask the responder to answer with the host's own. The key and value arrays each
// hold `property_count` pointers to those strings, or zero is passed for an unrepresentable
// count and neither is read.
let instance = unsafe {
(api.construct)(
instance_name.as_ptr(),
@ -112,9 +120,9 @@ pub(super) fn advertise(
port,
0,
0,
0,
std::ptr::null(),
std::ptr::null(),
property_count,
key_pointers.as_ptr(),
value_pointers.as_ptr(),
)
};
if instance.is_null() {

View file

@ -12,6 +12,7 @@ use tracing::{debug, warn};
pub(super) fn advertise(
name: &str,
port: u16,
properties: &[(String, String)],
running: &AtomicBool,
ready: &std::sync::mpsc::Sender<Result<(), String>>,
) {
@ -27,6 +28,16 @@ pub(super) fn advertise(
let mut service = ::zeroconf::MdnsService::new(service_type, port);
service.set_name(name);
if !properties.is_empty() {
let mut record = ::zeroconf::TxtRecord::new();
for (key, value) in properties {
if let Err(error) = record.insert(key, value) {
let _ = ready.send(Err(error.to_string()));
return;
}
}
service.set_txt_record(record);
}
let announced = Arc::new(AtomicBool::new(false));
let callback_announced = Arc::clone(&announced);

View file

@ -0,0 +1,233 @@
//! Midi Harbor's identity exchange, an extension to the AppleMIDI control protocol.
//!
//! One network port asks another to prove which port it is. The answer carries the public key
//! of the daemon that runs the port, the port's identifier, and a signature over both with the
//! challenge. It lets a port that has moved to another address be recognised as the port it was,
//! which an advertised name alone cannot do (R-106).
//!
//! The packets travel on the control port under the control signature, with commands of their
//! own. No other implementation knows them, so they are sent only to a session that advertises
//! a key. This module carries the bytes; signing and verifying belong to the caller.
use crate::control::SIGNATURE;
/// The version of the identity exchange this implementation speaks.
pub const IDENTITY_VERSION: u32 = 1;
/// Bytes in a challenge's nonce.
pub const NONCE_LEN: usize = 32;
/// Bytes in an Ed25519 public key.
pub const KEY_LEN: usize = 32;
/// Bytes in a port identifier, a UUID.
pub const PORT_ID_LEN: usize = 16;
/// Bytes in an Ed25519 signature.
pub const SIGNATURE_LEN: usize = 64;
/// The command bytes of a challenge.
const CHALLENGE: [u8; 2] = *b"HQ";
/// The command bytes of a proof.
const PROOF: [u8; 2] = *b"HA";
/// Bytes before the fields: the signature, the command and the version.
const HEADER_LEN: usize = 2 + 2 + 4;
/// Bytes in a challenge.
const CHALLENGE_LEN: usize = HEADER_LEN + NONCE_LEN;
/// Bytes in a proof.
const PROOF_LEN: usize = HEADER_LEN + NONCE_LEN + KEY_LEN + PORT_ID_LEN + SIGNATURE_LEN;
/// Why bytes could not be read as an identity packet.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IdentityError {
/// The bytes are some other packet, which is ordinary: MIDI and session control share the
/// port.
#[error("not an identity packet")]
NotIdentity,
/// The packet ended before a field it must carry.
#[error("packet is {actual} bytes, needs at least {expected}")]
TooShort {
/// How many bytes were present.
actual: usize,
/// How many were needed.
expected: usize,
},
/// The sender speaks a version of the exchange this implementation does not.
#[error("identity exchange version {found}, expected {IDENTITY_VERSION}")]
UnsupportedVersion {
/// The version the sender declared.
found: u32,
},
}
/// A packet of the identity exchange.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdentityPacket {
/// Asks the port listening where this is sent to prove which port it is.
Challenge {
/// Chosen at random by the asker, so an old proof cannot answer a new challenge.
nonce: [u8; NONCE_LEN],
},
/// Answers a challenge.
Proof {
/// The challenge's nonce, tying the answer to its question.
nonce: [u8; NONCE_LEN],
/// The public key of the daemon that runs the port.
key: [u8; KEY_LEN],
/// The port's identifier, which a rename does not change.
port_id: [u8; PORT_ID_LEN],
/// The signature over the nonce, the asker's address and the port's identifier.
signature: [u8; SIGNATURE_LEN],
},
}
impl IdentityPacket {
/// Encodes the packet for the wire.
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(PROOF_LEN);
out.extend_from_slice(&SIGNATURE);
match self {
Self::Challenge { nonce } => {
out.extend_from_slice(&CHALLENGE);
out.extend_from_slice(&IDENTITY_VERSION.to_be_bytes());
out.extend_from_slice(nonce);
}
Self::Proof {
nonce,
key,
port_id,
signature,
} => {
out.extend_from_slice(&PROOF);
out.extend_from_slice(&IDENTITY_VERSION.to_be_bytes());
out.extend_from_slice(nonce);
out.extend_from_slice(key);
out.extend_from_slice(port_id);
out.extend_from_slice(signature);
}
}
out
}
/// Parses a packet received on the control port.
///
/// Bytes past the last field are ignored, so a later version can add to a packet without
/// this one refusing it.
pub fn parse(bytes: &[u8]) -> Result<Self, IdentityError> {
// Validate the framing before reading anything.
if bytes.get(..2) != Some(SIGNATURE.as_slice()) {
return Err(IdentityError::NotIdentity);
}
let (challenge, expected) = match bytes.get(2..4) {
Some(command) if command == CHALLENGE => (true, CHALLENGE_LEN),
Some(command) if command == PROOF => (false, PROOF_LEN),
_ => return Err(IdentityError::NotIdentity),
};
if bytes.len() < expected {
return Err(IdentityError::TooShort {
actual: bytes.len(),
expected,
});
}
let version = u32::from_be_bytes(field(bytes, 4)?);
if version != IDENTITY_VERSION {
return Err(IdentityError::UnsupportedVersion { found: version });
}
// Read the fields, each following the last.
let nonce = field(bytes, HEADER_LEN)?;
if challenge {
return Ok(Self::Challenge { nonce });
}
let key_at = HEADER_LEN + NONCE_LEN;
let port_id_at = key_at + KEY_LEN;
let signature_at = port_id_at + PORT_ID_LEN;
Ok(Self::Proof {
nonce,
key: field(bytes, key_at)?,
port_id: field(bytes, port_id_at)?,
signature: field(bytes, signature_at)?,
})
}
}
/// Reads a fixed-size field without risking an out-of-bounds read.
fn field<const N: usize>(bytes: &[u8], offset: usize) -> Result<[u8; N], IdentityError> {
let end = offset.saturating_add(N);
bytes
.get(offset..end)
.and_then(|slice| slice.try_into().ok())
.ok_or(IdentityError::TooShort {
actual: bytes.len(),
expected: end,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Proves both packets survive the wire, that anything else on the control port is passed
/// over as not an identity packet, and that a truncated packet or another version is refused
/// without reading past its end.
///
/// A challenge is 8 + 32 = 40 bytes and a proof 8 + 32 + 32 + 16 + 64 = 152. `FF FF 49 4E`
/// opens an AppleMIDI invitation, which shares the port.
#[test]
fn identity_packets_survive_the_wire_and_nothing_else_is_taken_for_one() {
let challenge = IdentityPacket::Challenge { nonce: [7; 32] };
let proof = IdentityPacket::Proof {
nonce: [7; 32],
key: [8; 32],
port_id: [9; 16],
signature: [10; 64],
};
assert_eq!(challenge.encode().len(), 40, "a challenge is 40 bytes");
assert_eq!(proof.encode().len(), 152, "a proof is 152 bytes");
for packet in [challenge.clone(), proof.clone()] {
assert_eq!(
IdentityPacket::parse(&packet.encode()),
Ok(packet),
"a packet must read back as it was sent"
);
}
let mut newer = challenge.encode();
newer[7] = 2;
let mut longer = challenge.encode();
longer.extend_from_slice(&[1, 2, 3]);
let cases: [(&str, Vec<u8>, Result<IdentityPacket, IdentityError>); 5] = [
(
"an AppleMIDI invitation",
vec![0xFF, 0xFF, b'I', b'N', 0, 0, 0, 2],
Err(IdentityError::NotIdentity),
),
(
"nothing at all",
Vec::new(),
Err(IdentityError::NotIdentity),
),
(
"a proof cut short",
proof.encode()[..100].to_vec(),
Err(IdentityError::TooShort {
actual: 100,
expected: 152,
}),
),
(
"a later version",
newer,
Err(IdentityError::UnsupportedVersion { found: 2 }),
),
("a challenge with more after it", longer, Ok(challenge)),
];
for (name, bytes, want) in cases {
assert_eq!(IdentityPacket::parse(&bytes), want, "{name}: read wrongly");
}
}
}

View file

@ -8,11 +8,13 @@
pub mod clock;
pub mod control;
pub mod identity;
pub mod journal;
pub mod packet;
pub mod session;
pub use clock::{ClockAction, ClockSync};
pub use control::{ControlPacket, Handshake, ParseError, SessionCommand};
pub use identity::{IdentityError, IdentityPacket};
pub use packet::{PacketError, RtpMidiPacket, SysExPart, SysExSegment, TimedMessage};
pub use session::{Action, Phase, Port, Role, Session, SessionFailure};

View file

@ -193,15 +193,24 @@ one is added.
## Peers
Machines this one knows, added by `network peer add`, by answering an invitation with `--always`,
or by `network connect`, which remembers where it connected without trusting it.
or by `network connect`, which remembers where it connected without trusting it. A machine
remembered only by `network connect`, with no name of its own and no trust, is removed again once
no network port connects to it.
| Field | | Default |
|---|---|---|
| `name` | What to call it. | required |
| `addresses` | Where it was last reached, as `host:port`. | none |
| `trusted` | Whether its invitations are accepted without asking. | `false` |
| `advertised_as` | The session name advertised at its address, kept by the daemon. While the link to it is down and a session of this name is advertised somewhere else, the network port connects there and the address here is rewritten: on a new port of the same host for any machine, on a new host only when `trusted` is `false`. | none |
| `key` | The public key of the Midi Harbor that runs it, in hexadecimal, kept by the daemon once the machine proves it. With `port_id` it replaces `advertised_as`: the machine is followed only to a session that advertises both, under any name, and to another host once it proves them there, trusted or not. | none |
| `port_id` | The identifier of the network port connected to, in that Midi Harbor's configuration. | none |
| `id` | A stable identifier. | generated |
The daemon's own key is not in this file. It is in `identity.key` beside it, readable only by its
owner, and is made the first time the daemon starts. Copying the configuration to another machine
leaves the key behind on purpose: two machines with one key would each pass for the other.
## Preferences
| Field | | Default |

6
fuzz/Cargo.lock generated
View file

@ -308,7 +308,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "midi-harbor-blemidi"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"midi-harbor-core",
"thiserror",
@ -317,7 +317,7 @@ dependencies = [
[[package]]
name = "midi-harbor-core"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"directories",
"jiff",
@ -343,7 +343,7 @@ dependencies = [
[[package]]
name = "midi-harbor-rtpmidi"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"midi-harbor-core",
"thiserror",

View file

@ -1,14 +1,14 @@
//! Feeds hostile datagrams to the two RTP-MIDI parsers a peer on the network can reach.
//! Feeds hostile datagrams to the three RTP-MIDI parsers a peer on the network can reach.
//!
//! Both ports read whatever arrives, so the control parser and the data parser each get every
//! input. Neither may allocate more than a small multiple of what it was sent, and whatever either
//! accepts has to come back unchanged after being encoded and parsed again: a packet the daemon
//! would read one way and send another is a peer misreading us.
//! Both ports read whatever arrives, so the control parser, the identity parser and the data
//! parser each get every input. None may allocate more than a small multiple of what it was sent,
//! and whatever one accepts has to come back unchanged after being encoded and parsed again: a
//! packet the daemon would read one way and send another is a peer misreading us.
#![no_main]
use libfuzzer_sys::fuzz_target;
use midi_harbor_rtpmidi::{ControlPacket, RtpMidiPacket};
use midi_harbor_rtpmidi::{ControlPacket, IdentityPacket, RtpMidiPacket};
/// Heap bytes a parse may hold per byte of input, which covers a vector doubling past a message
/// list at one message per input byte.
@ -33,6 +33,13 @@ fuzz_target!(
assert_eq!(ControlPacket::parse(&packet.encode()), Ok(packet));
}
let mut identity = None;
let allocations = allocation_counter::measure(|| identity = IdentityPacket::parse(data).ok());
assert!(allocations.bytes_max <= bound, "identity parse held {allocations:?}");
if let Some(packet) = identity {
assert_eq!(IdentityPacket::parse(&packet.encode()), Ok(packet));
}
let mut rtp = None;
let allocations = allocation_counter::measure(|| rtp = RtpMidiPacket::parse(data).ok());
assert!(allocations.bytes_max <= bound, "rtp parse held {allocations:?}");

View file

@ -39,6 +39,9 @@ Peer {
addresses: Vec<SocketAddr> // may be several; may change over time
source: PeerSource // Discovered | Manual
trusted: bool // "always accept from this peer"
advertised_as: Option<String> // the session name advertised at its address; followed when it moves (R-105)
key: Option<String> // its daemon's public key, once proved (R-106)
port_id: Option<EndpointId> // which of that daemon's network ports it is, once proved (R-106)
last_seen: Option<Timestamp>
is_self: bool // never offered to the user (edge case)
}

View file

@ -0,0 +1,188 @@
# Research: Remembered Machines
The investigations behind this spec, under their numbers in the project-wide research log.
---
## R-105: A machine remembered at a port it no longer listens on
**Status**: **FIXED** (2026-10-02). Built as T249 and T250.
A network port listed a machine called `192.0.2.13` as "On this network ·
192.0.2.13:51474". Connect failed with "no peer named '9c22fef1-f84d-4b45-a09b-0bfb4aaa93f4'". The
user had removed that connection. A Bonjour browser showed the host advertising one session,
under another name, at `192.0.2.13:40180`: the port the connection had been to was deleted on
that machine, and a new one made.
Four defects, the first three behind what was seen:
- **A machine stayed remembered after its connection was removed.** Connecting records the
machine among the remembered ones, named after its address, so the network port can name its
peer. Disconnecting cleared the port's reference and left the record.
- **An advertisement was matched to a remembered machine by host alone.** That is right for a
trusted machine, since trust is held by host. For a record that is only an address it marked
the old port as present and folded the new session into it, so the session at the new port was
not listed at all.
- **Connect resolved identifiers only among advertised sessions.** A remembered machine is listed
under the identifier of its record, which no advertisement carries, so Connect failed for every
remembered machine, present or not.
- **A network port invites one address until it answers.** The supervisor holds a socket address
and retries it with backoff; nothing looked at discovery again. R-049 met the same fault from
the other side and pinned Midi Harbor's own ports, which does nothing for another program's.
Found by reading the code, not seen here: the session at the new port was a different one.
**Fixes.** A machine with no name of its own, no trust and no network port connecting to it is
dropped when a port lets it go. An untrusted record is matched by an advertisement at its exact
address only. Connect resolves remembered machines by identifier or name. And the session name
advertised at a machine's address is stored with it as `advertised_as`; when discovery sees a
session come or go, or a session reports a change, each machine whose session is advertised
somewhere else is moved there if its link is down, and the new address is stored.
**Which records are dropped.** The configuration has no mark for how a record came to be, and
none was added. A record made by connecting is named after its own address, so that, without
trust and without a port using it, is the rule. A machine added by address with neither a name
nor trust is dropped by it too, once connected to and disconnected; it held nothing but the
address.
**Why trust limits following by name.** An advertisement is unauthenticated. Rewriting a trusted
machine's address to another host would let whichever machine advertised the name in without
asking. A new port on the same host changes nothing about who is trusted, so it is followed for
every machine; a new host only for an untrusted one, where the worst outcome is an invitation
sent to the wrong machine. R-106 lifts this for a machine that can prove which it is.
**Why only while the link is down.** A second machine may take the name while the first is
connected, and the first must not be dropped for it.
**Checked live** on macOS between two daemons on one Mac, each with its own home directory and
socket. Near connected to Follow Far by its discovered name at `192.0.2.20:21000` and stored
`advertised_as: Follow Far`. Far's port was moved to 21020 with `network edit --udp-port`. Near
logged the peer ending the session, and 0.95 s later "following the peer to where it is
advertised now", and listed the machine joined at `:21020` at the first look two seconds after
the move; the configuration held the new address.
**Not covered:** a program that dies without withdrawing its advertisement, where what the
browser reports depends on the old record expiring.
---
## R-106: Proving which network port a session is
**Status**: **DONE** (2026-10-02). Built as T251.
R-105 could not follow a trusted machine to another host, and could not tell a port renamed from
a different port, or a port made again under an old name from the old one. The owner asked for a
proof, by signature, of the host and of the port.
**The exchange.** Each daemon holds an Ed25519 key. Every session it advertises carries two TXT
properties: `mhkey`, the public key as 64 hexadecimal digits, and `mhport`, the network port's
identifier, a UUID that a rename does not change. A network port asks the port at an address to
prove both with two packets on the control port, under AppleMIDI's `FF FF` signature and with
commands of their own. All integers are big-endian.
| Packet | Bytes |
|---|---|
| Challenge | `FF FF` `48 51` ("HQ"), version `00 00 00 01`, nonce (32). 40 bytes |
| Proof | `FF FF` `48 41` ("HA"), version `00 00 00 01`, nonce (32), public key (32), port identifier (16), signature (64). 152 bytes |
The signature is over `midi-harbor network port identity v1`, a zero byte, the nonce, the asker's
address as the answering port saw it (16 bytes, an IPv4 address mapped into IPv6, then the port,
2 bytes), and the port identifier. Bytes after the last field are ignored, so a later version can
add to a packet.
**Why the asker's address is signed.** Without it a machine could pass a challenge on to the real
port and return the answer as its own. The real port signs the address the challenge came from,
which is then the go-between's. The asker accepts a proof only for one of its own addresses and
its own control port, and only from the address it challenged. A machine that can forge both
source addresses and read the reply already controls the network, where trust in an address
means nothing anyway.
**Why TXT, and not asking every peer.** No other implementation knows the two commands, and what
each does with an unknown one on its control port was not something to find out on someone's
show. A session that advertises `mhkey` is a Midi Harbor that answers. One that does not is
never sent a challenge. The advertised key proves nothing by itself, since anyone can copy it;
it only says whom to ask.
**When a key is kept.** A key is stored with a machine only once the port at the address the
network port connects to proves it. A forged advertisement naming a trusted machine's address
would otherwise plant a key its forger could prove later from anywhere.
**What it changes.** A machine stored with a key and port identifier is that port wherever it is
advertised and whatever it is called, and nothing else is. It is followed to another host once it
proves both there, trusted or not, and a trusted machine's trust moves with its address. A
session of the same name with another identifier is another port and is not followed, which is
the owner's case of a port deleted and made again. A machine with no key is followed as R-105
describes.
**Where the key lives.** `identity.key` beside the configuration document, readable by its owner
only, holding the 32-byte seed in hexadecimal. Not in the configuration, which is exported,
copied to other machines and pasted into bug reports: two machines sharing a key would each pass
for the other. A daemon that cannot write it uses a key for that run and says so in the log.
**A defect the live check found.** A port renamed and moved was not followed. The new name is
advertised before the old one is withdrawn, and while the old advertisement stands it says the
port is still where it was. The withdrawal arrived a second later and prompted nothing. Discovery
now prompts a look when a session goes as well as when one is resolved.
**Checked live** on macOS between two daemons on one Mac. `dns-sd -L` showed both properties on
Follow Far. Near connected to it at `192.0.2.10:21000` and stored its key and port identifier
within three seconds. Far was renamed and moved with `network edit --bonjour-name "Follow
Renamed" --udp-port 21020`; near logged "following the peer" 1.25 s after the session ended, was
joined at `:21020`, and stored the new name. Far's port was then deleted and another created as
Follow Renamed on 21040; nine seconds later near was still inviting `:21020`.
**Checked against other implementations** on 2026-10-02, with a network port advertised through
Avahi from a Fedora 43 machine on the LAN. That account could not open the ALSA sequencer, so the
daemon ran over the in-memory MIDI platform; its network side was the real one.
| Implementation | What was checked | Result |
|---|---|---|
| Apple's Network MIDI, macOS 27 | `dns-sd -L` resolves the session with both properties; Audio MIDI Setup lists it in its directory | Listed as RTP-MIDI beside the others |
| Apple's Network MIDI | The port connected out to a second Apple session by its discovered name; a note each way | Joined; note on and off arrived at Network Session 2, and the port counted the two sent back. `advertised_as` was stored and no key, so no challenge was sent |
| rtpmidid, Arch Linux | Discovered the session through Avahi and invited it when an ALSA port was subscribed; a note each way | Joined, clocks synchronised at 0.2 ms, both notes arrived |
Apple inviting the port failed at first, for a reason older than this work, and works since
R-107.
**Seen once, and not explained.** In rtpmidid's first two starts its IPv6 resolver timed out on
the session ("Timeout reached"), and rtpmidid then drops the peer whichever family resolved. Two
sessions published beside it with `avahi-publish`, one with the same TXT record and one with
none, resolved on both families, so the record is not the cause. In five later starts, with the
last committed build advertising beside the new one, every session resolved on both families and
none was dropped.
**The Linux gates** pass on Arch Linux with this change: fmt, clippy, 312 tests, and the
headless build.
**Not covered:** another host in a live check, which needs a second machine and is covered by
`a_trusted_machine_is_followed_to_another_host_once_it_proves_which_port_it_is` over the two
loopback families; the TXT record through the DNS Client service on Windows, which was compiled
and not run; rtpMIDI on Windows; and an inbound invitation from a trusted machine at a new
address, which is still asked about.
---
## R-107: Apple's invitation over a link-local address was never answered
**Status**: **FIXED** (2026-10-02). Built as T252.
The owner selected the Linux port in Audio MIDI Setup and clicked Connect. Apple reported
"fe80::2:21500 didn't respond to the connection request", and the port listed
the Mac as joining at `[fe80::1]:5004`, never joined. A note sent from the port
did not arrive.
Apple's Network MIDI invites over the IPv6 link-local address Bonjour resolves when the host
advertises one. A link-local address is reachable only with its scope, the interface it belongs
to. The supervisor kept every sender's address through `canonical`, which rebuilt it from the IP
and the port and so dropped the scope, and built the data port's address the same way. The
acceptance went to `fe80::` with no interface and was never delivered. The fault is in the code
as last committed; R-056's check of Apple inviting was over IPv6 loopback, which has no scope, and
this spec's earlier checks connected over IPv4.
**Fix.** An IPv6 sender's address is kept whole, and the data port's address is the same address
with the port changed. An IPv4 sender seen through the dual-stack socket is still kept by its
plain address.
**Checked live.** With the fix on the Linux machine the owner clicked Connect again: the port
listed the Mac joined at `[fe80::1%9]:5004` with 3.1 ms latency, a note from the
port arrived at Network Session 1, and a note sent into that session was counted by the port.
`a_machine_is_answered_at_the_address_it_was_heard_from` fails with the scope dropped again.

View file

@ -0,0 +1,166 @@
# Feature Specification: Remembered Machines
**Created**: 2026-10-02
**Status**: Implemented; checked on 2026-10-02 between two daemons on one Mac, and from Linux
against Apple's Network MIDI, each side inviting, and rtpmidid. Not yet run on Windows, and following to another host
is covered by test only
**Input**: User report, with a screenshot of a network port offering `192.0.2.13:51474` as "On this
network" and failing to connect with "no peer named '9c22fef1-…'": "The app is showing an old
connection that was removed, and no longer listening." Then a request to look into a connected
machine whose session comes back somewhere else, and on trusted machines: "If we can prove that
they are the same host with an extension to rtp midi … then sure. Follow host. Maybe use a small
ECDSA/ED2559 key and verify with a signature check?", "Maybe you can validate based on name or
midi port id too?", and that a port deleted and made again under a new name "is a different midi
port" and is not to be followed.
A network port remembers each machine it connects to, so it can connect again after a restart. It
remembered the address and nothing else, and kept the record after the connection was removed. The
old record was then listed as present at a port nothing listened on, and a network port still
connected to a machine that came back on another port or address would have invited the old one
for good (research R-105, R-106).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - A removed connection is gone (Priority: P1)
A user connects a network port to a machine found on the network, and later disconnects it. The
machine is no longer listed as one this computer knows. A session its host advertises afterwards,
on whatever port, is listed under its own name at its own address, and Connect reaches it.
**Why this priority**: it is the fault that was reported.
**Independent Test**: Connect a network port to two machines by address, disconnect one, and read
the configuration: only the other is remembered. Add a machine by name, connect to it by the
identifier the listing gives, disconnect, and list the machines: it is still there.
**Acceptance Scenarios**:
1. **Given** a machine remembered only because a network port connected to it, **When** the port
disconnects from it, connects to another in its place, or is deleted, **Then** the machine is
no longer remembered.
2. **Given** a machine the user named or trusts, **When** the network port disconnects from it,
**Then** it stays remembered.
3. **Given** a remembered machine, **When** a client connects a network port to it by the
identifier or name the listing gives, **Then** the port connects to its stored address.
4. **Given** a machine remembered without trust at one port, **When** its host advertises a
session on another port, **Then** that session is listed as its own entry at its own address,
and the remembered one is not marked as on the network.
### User Story 2 - A session that moved is followed (Priority: P1)
A network port is connected to a session on another machine. The session comes back on another
port: its program was restarted and the system chose again, or someone changed it. The network
port connects to it there without anyone touching it, and after a restart of the daemon goes
straight there.
**Why this priority**: connections that mend themselves are what Midi Harbor is for.
**Independent Test**: Start a network port whose remembered machine is at a port nothing listens
on, hand the daemon an advertisement of that machine's session at a port where one does, and
check the port connects and the configuration holds the new address.
**Acceptance Scenarios**:
1. **Given** a network port connected to a machine by a session it advertises, **When** the link
is down and the session is advertised on another port of the same host, **Then** the port
connects there and the new address is stored.
2. **Given** the same, **When** the session is advertised on another host and the machine is
neither trusted nor a Midi Harbor, **Then** the port connects there.
3. **Given** the same, **When** the machine is trusted, is not a Midi Harbor, and the session is
advertised on another host, **Then** nothing moves.
4. **Given** a machine carrying MIDI, **When** a session of its name is advertised somewhere
else, **Then** nothing moves.
### User Story 3 - A Midi Harbor port is known for certain (Priority: P2)
A network port is connected to a network port of another Midi Harbor, which the user trusts. That
machine is given another address, or its port is renamed. The network port follows it, and the
trust goes with it, because the session at the new address proved it is the same port of the same
daemon. A different port, or another machine using the name, is not followed.
**Why this priority**: it removes the one case story 2 has to refuse, without weakening what
trust means.
**Independent Test**: Start a network port whose trusted machine is remembered with a key at an
address nothing listens on. Advertise that key from a daemon that does not hold it and check
nothing moves; advertise it from the daemon that does and check the port connects there.
**Acceptance Scenarios**:
1. **Given** a network port connected to a Midi Harbor network port, **When** that session is
advertised where it is connected to, **Then** the port is asked to prove its daemon's key and
its identifier there, and they are stored once it does.
2. **Given** a machine stored with a key and identifier, **When** its link is down and a session
advertising them appears on another host and proves them, **Then** the port connects there,
trusted or not, and a trusted machine is trusted at its new host.
3. **Given** the same, **When** the session advertising them cannot prove them, **Then** nothing
moves.
4. **Given** the same, **When** the port is renamed, **Then** it is followed under its new name.
5. **Given** the same, **When** the port is deleted and another is made, under the same name or
not, **Then** the new one is not followed.
### Edge Cases
- **The advertisement arrives before the link is noticed lost**: nothing moves then, and the
machine is followed when the link is reported lost.
- **A session renamed**: its new name is advertised before the old one is withdrawn, so the
machine is followed once the old advertisement goes.
- **A machine connected to by typed address**: it is followed too, once a session is advertised
at that address.
- **A machine that never advertises**: it has nothing to follow, and is invited at its address
as before.
- **Records written before this**: they take a name, and a key where there is one, the next time
a session is advertised at their address. One left by a removed connection stays until it is
forgotten or connected to and disconnected.
- **The machine asleep**: a network port waiting out sleep reconnects to the new address on
waking.
- **A Midi Harbor that lost its key**: it is asked again where it is connected to, and its new
key replaces the old. Moved before then, it is not followed.
- **Other implementations**: Apple's Network MIDI, rtpMIDI and rtpmidid advertise no key and are
never sent the identity exchange.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-P01**: System MUST forget a machine that was remembered only because a network port
connected to it once no network port connects to it, and MUST keep one the user named or trusts.
- **FR-P02**: System MUST connect a network port to a remembered machine named by the identifier
or name under which it is listed.
- **FR-P03**: System MUST mark a machine remembered without trust as on the network only when a
session is advertised at its stored address, and MUST list every other session its host
advertises as an entry of its own.
- **FR-P04**: System MUST keep, with a machine a network port connects to, the session name
advertised at its address, and while the link to it is down MUST connect to it where a session
of that name is advertised and store that address: on another port of its host for any machine,
on another host only for one that is not trusted.
- **FR-P05**: System MUST give each daemon a key of its own, kept outside the configuration
document, advertise its public half and each network port's identifier with the port's session,
and prove both to a network port that challenges it.
- **FR-P06**: System MUST keep the key and identifier of a Midi Harbor network port it connects
to once proved at the address connected to, and from then on MUST treat a session as that
machine only when it advertises both, whatever its name, and MUST follow it to another host
only once it proves both there. Trust MUST move with a machine followed this way.
- **FR-P07**: System MUST NOT send the identity exchange to a session that does not advertise a
key.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-P01**: After a connected machine's session comes back advertised on another port, the
network port is carrying MIDI with it again within SC-003's ten seconds, with nothing done by
the user.
- **SC-P02**: A machine that does not hold a trusted machine's key is never trusted by
advertising that machine's name, key or identifier.
## Assumptions
- A session name on the local network identifies a session well enough to connect out to it, as
it does in Apple's Network MIDI. It is not proof of who answers, so it never moves trust.
- The platform's browser reports a session again when its port changes, and reports its
withdrawal.
- Trust in an address is as strong as the network makes addresses. The proof does not strengthen
that; it stops trust moving to an address on the say of an advertisement.

View file

@ -0,0 +1,9 @@
# Tasks: Remembered Machines
Tasks by their numbers in the project-wide sequence, which continues across every spec. Each was
built and checked in one piece, so they are not broken into phases.
- [x] T249 Forget a machine remembered only for a connection once no network port connects to it, list an untrusted record as on the network only when a session is advertised at its address, and connect to a remembered machine by its listed identifier, per FR-P01, FR-P02, FR-P03 (R-105) — done: `drop_unused_peers` runs when a network port disconnects, replaces its peer or is deleted; `merge_known_peers` matches a trusted machine by host and any other by address; `resolve_peer` looks among remembered machines after advertised ones. The machines test checks a disconnected machine leaves the configuration, a contract test connects by a listed identifier and finds a named machine kept after disconnecting, and the merge test gains the untrusted cases.
- [x] T250 Follow a machine to where its session is advertised when its link is down, per FR-P04, SC-P01 (R-105), Constitution Principle I — done: `PeerConfig.advertised_as` holds the session name advertised at the machine's address; discovery wakes a watcher when a session is resolved or goes, and a session reporting a change prompts the same look; the session supervisor's `Move` command refuses for a machine carrying MIDI. No contract change. A test starts a network port whose machine is at a dead port, hands the daemon an advertisement as discovery reports one, and checks the connection, the stored address, and that a connected machine is not moved; `next_step` is unit tested for port, host, trust and identity. Checked live between two daemons on one Mac (R-105).
- [x] T251 Prove which network port a session is, and follow a proved port to another host or under another name, per FR-P05, FR-P06, FR-P07, SC-P02 (R-106) — done: an Ed25519 key per daemon in `identity.key`; `mhkey` and `mhport` in each session's TXT record through all three responders; `IdentityPacket` in the RTP-MIDI crate, read by the packet fuzz target; the supervisor answers a challenge and sends one with `Prove`; `PeerConfig.key` and `port_id` are stored once proved at the address connected to. New dependencies `ed25519-dalek` and `hex`. No contract change. Tests over real sockets: a daemon without the key advertising it is not followed and one with it is, with trust kept; a forged key at a connected machine's address is not stored and the real one is. Removing the comparison with the expected key fails both. The packet round trip and the proof's refusals are unit tested. Checked live between two daemons on one Mac (R-106), and the packet fuzz target ran 1,474,155 inputs in 61 s without a finding. The Linux gates pass, and a port advertised through Avahi was listed by Audio MIDI Setup, connected to Apple's session, and was invited by rtpmidid, with a note each way (R-106). Not run: the Windows responder.
- [x] T252 Answer a machine at the address it was heard from, scope included, so an invitation over an IPv6 link-local address is accepted (R-107), per FR-011, Constitution Principle I — done: `canonical` keeps an IPv6 address whole and `on_port` moves an address to the data port without rebuilding it. A unit test over the two functions covers an IPv4-mapped sender and a link-local one on each port. Checked live: Apple's Session 1 invited a port on Linux over `fe80::` and joined, with a note each way.

View file

@ -28,6 +28,7 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
| [015-mac-menus](015-mac-menus/spec.md) | The menus on macOS: File, Edit, View, Window and Help |
| [016-app-store-submission](016-app-store-submission/spec.md) | Building the package App Store Connect takes |
| [017-appimage](017-appimage/spec.md) | The AppImage for Linux, and registering the daemon from it |
| [018-remembered-machines](018-remembered-machines/spec.md) | Machines a network port connected to: forgetting old connections, following a session that moved, proving which port a session is; answering Apple over a link-local address |
## Where each number is
@ -144,3 +145,10 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
- **Success criteria**: SC-I01
- **Research**: R-104
- **Tasks**: T246–T248
### 018-remembered-machines
- **Requirements**: FR-P01, FR-P02, FR-P03, FR-P04, FR-P05, FR-P06, FR-P07
- **Success criteria**: SC-P01, SC-P02
- **Research**: R-105–R-107
- **Tasks**: T249–T252