diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index a8cdb595..56b6fe0c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -243,6 +243,15 @@ fun ConnectScreen( knownHostStore.learnOs(dh.host, dh.port, dh.os) any = true } + // And the mgmt port, so a host that moved off 47990 keeps its library once this + // device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi). + val mgmt = dh.mgmtPort + if (mgmt != null && + knownHostStore.get(dh.host, dh.port)?.let { it.mgmtPort != mgmt } == true + ) { + knownHostStore.learnMgmtPort(dh.host, dh.port, mgmt) + any = true + } } any } @@ -313,13 +322,24 @@ fun ConnectScreen( // What the stream screen is handed: the settings this connect actually used, plus the HOST's // clipboard decision (a property of the record, not a global). A host we never saved — a // connect that failed to pin — falls back to the on default the setting always had. - fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession( - handle, - settings.effectiveFor(profile), - clipboardSync = record?.clipboardSync ?: true, - profileName = profile?.name, - hostId = record?.id, - ) + fun session(handle: Long, record: KnownHost?, profile: StreamProfile?): ActiveSession { + // The session's own Welcome carries where this host serves its library. Save it now: this + // is the only source that does not need an mDNS advert, so it is what makes a host that + // moved off 47990 browsable over a VPN or when it was added by address. 0 = not + // advertised, and learnMgmtPort ignores it. + if (record != null) { + NativeBridge.nativeHostMgmtPort(handle).takeIf { it > 0 }?.let { + knownHostStore.learnMgmtPort(record.address, record.port, it) + } + } + return ActiveSession( + handle, + settings.effectiveFor(profile), + clipboardSync = record?.clipboardSync ?: true, + profileName = profile?.name, + hostId = record?.id, + ) + } // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint // the host presented (as an unpaired known host) so the next connect goes straight through and it diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index d2e287ff..104c1e34 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -59,7 +59,6 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.request.ImageRequest import io.unom.punktfunk.components.launcherIcon -import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT import io.unom.punktfunk.kit.library.GameEntry import io.unom.punktfunk.kit.library.LibraryClient import io.unom.punktfunk.kit.library.LibraryResult @@ -120,14 +119,16 @@ fun LibraryScreen( } val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) } - LaunchedEffect(host.address, host.port, host.fpHex) { + // Keyed on the mgmt port too: a discovery tick can learn it after this screen is composed, and + // the fetch must redo itself against the real port rather than stay on a stale 47990 failure. + LaunchedEffect(host.address, host.port, host.fpHex, host.effectiveMgmtPort) { state = LibState.Loading state = withContext(Dispatchers.IO) { val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull() ?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.") when (val res = LibraryClient.fetch( address = host.address, - mgmtPort = DEFAULT_MGMT_PORT, + mgmtPort = host.effectiveMgmtPort, certPem = id.certPem, keyPem = id.privateKeyPem, fpHex = host.fpHex, diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index b4fec28e..18661462 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -477,6 +477,16 @@ object NativeBridge { // cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host // copies arrive as "offer:" events, fetched eagerly into the system clipboard. + /** + * The management-API port the host reported in this session's `Welcome` — where its game + * library is served — or 0 if it advertised none (older host, or no management API). + * + * Persist it on the host record: unlike the mDNS `mgmt` TXT, this arrives over the connection + * we have already authenticated, so it is what makes a host that moved off 47990 browsable + * over a VPN, a routed subnet, or when it was added by address. + */ + external fun nativeHostMgmtPort(handle: Long): Int + /** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */ external fun nativeClipSupported(handle: Long): Boolean diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt index 18b6bd39..a36b7256 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt @@ -19,13 +19,16 @@ data class DiscoveredHost( val pairingRequired: Boolean = false, val mac: List = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN) val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts + // TXT "mgmt" — the management-API port the library is served on, distinct from `port` (the + // native QUIC plane). null on an older host / older native lib, meaning "assume 47990". + val mgmtPort: Int? = null, ) /** Field separator the native browse uses inside one record (ASCII Unit Separator). */ private const val FIELD_SEP = '\u001F' /** - * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os`), + * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`), * or null if it's malformed. Fields past the 6th are optional — an older native lib omits them * (`mac` 7th, `os` 8th). Pure — unit-tested without Android (see ParseRecordTest). The native side * already applied the protocol gate and address selection, so this is just field marshaling. @@ -46,6 +49,9 @@ fun parseHostRecord(record: String): DiscoveredHost? { mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() } else emptyList(), os = if (f.size > 7) sanitizeOsChain(f[7]) else "", + // 9th field, absent on an older native lib. `0` (and anything out of range) means "not + // advertised" → null, and the caller falls back to 47990. + mgmtPort = if (f.size > 8) f[8].toIntOrNull()?.takeIf { it in 1..65535 } else null, ) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt index 9970fdac..187b0d13 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt @@ -32,6 +32,16 @@ data class KnownHost( * first learned (or forever, against an older host). */ val os: String = "", + /** + * The host's management-API port (mDNS `mgmt` TXT), where the game library is served — NOT + * [port], which is the native QUIC plane. Learned while online and kept for the same reason as + * [mac] and [os], except this one is load-bearing: a host that moved its mgmt port off 47990 + * (the supported way to share a machine with a Sunshine fork, whose web UI owns that port) + * served its library only while mDNS was reachable, because the advert was the sole place the + * real port ever existed. `null` until learned — resolve with [effectiveMgmtPort]. + * Mirrors the Apple client's `StoredHost.mgmtPort` and the Rust `KnownHost.mgmt_port`. + */ + val mgmtPort: Int? = null, /** Stable record identity — see the class doc. Minted here for a genuinely new record. */ val id: String = newRecordId(), /** @@ -54,7 +64,16 @@ data class KnownHost( * that no longer exist are dropped when the cards are rendered. */ val pinnedProfileIds: List = emptyList(), -) +) { + /** + * Where this host's management API actually is: the port learned from its advert, else 47990. + * The twin of the Apple client's `StoredHost.effectiveMgmtPort` and the Rust + * `KnownHost::effective_mgmt_port`. Resolve through this — the constant is the FALLBACK, not + * the answer. + */ + val effectiveMgmtPort: Int + get() = mgmtPort ?: io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT +} /** * Persists trusted hosts — the pinned-fingerprint store *and* the saved-hosts list — keyed by @@ -130,6 +149,17 @@ class KnownHostStore(context: Context) { save(h.copy(os = os)) } + /** + * Learn/refresh a saved host's management-API port from its live advert — same contract as + * [learnMac]. This is the one that keeps a moved mgmt port working once mDNS isn't reachable. + */ + fun learnMgmtPort(address: String, port: Int, mgmtPort: Int) { + if (mgmtPort <= 0) return + val h = get(address, port) ?: return + if (h.mgmtPort == mgmtPort) return + save(h.copy(mgmtPort = mgmtPort)) + } + /** Forget [host] (the next connect re-pairs / re-TOFUs). */ fun remove(host: KnownHost) { prefs.edit().remove(host.id).apply() @@ -180,6 +210,10 @@ class KnownHostStore(context: Context) { paired = j.optBoolean("paired", false), mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() }, os = j.optString("os", ""), + // 0 (or absent) = never learned. `optInt` cannot express "missing", hence the sentinel + // rather than a bare default — a record written before this field existed must decode + // to null and fall back to 47990, not to port 0. + mgmtPort = j.optInt("mgmt", 0).takeIf { it > 0 }, // A record without an id can only be one this build wrote before the migration ran, or // a hand-edited file; minting here keeps the parse total rather than dropping a host. id = j.optString("id", "").ifEmpty { newRecordId() }, @@ -266,6 +300,7 @@ class KnownHostStore(context: Context) { .put("paired", host.paired) .put("mac", host.mac.joinToString(",")) .put("os", host.os) + .put("mgmt", host.mgmtPort ?: 0) .put("clip", host.clipboardSync) .put("profile", host.profileId ?: "") .put("pins", JSONArray(host.pinnedProfileIds)) diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseRecordTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseRecordTest.kt index 8eab07ab..d7bec49f 100644 --- a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseRecordTest.kt +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/discovery/ParseRecordTest.kt @@ -47,6 +47,31 @@ class ParseRecordTest { rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/fedora/bazzite"), )!! assertEquals("linux/fedora/bazzite", h.os) + // A record from a native lib predating the 9th field: no mgmt port, so the caller falls + // back to 47990. Absent must read as "unknown", never as port 0. + assertNull(h.mgmtPort) + } + + @Test + fun ninthFieldCarriesTheMgmtPort() { + // 47991, not the 47990 default — a host that MOVED its mgmt port is the whole reason this + // field is on the wire, and a test pinned to the default would pass against a hardcode. + val h = parseHostRecord( + rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch", "47991"), + )!! + assertEquals(47991, h.mgmtPort) + } + + @Test + fun mgmtPortOutOfRangeOrUnparsableReadsAsUnknown() { + // Unauthenticated advert data: 0 (the "not advertised" sentinel the Rust side emits), + // a non-number, and an out-of-range value must all mean "assume the default" rather than + // produce a port the client would then fail to connect to. + val base = arrayOf("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch") + assertNull(parseHostRecord(rec(*base, "0"))!!.mgmtPort) + assertNull(parseHostRecord(rec(*base, "not-a-port"))!!.mgmtPort) + assertNull(parseHostRecord(rec(*base, "70000"))!!.mgmtPort) + assertNull(parseHostRecord(rec(*base, ""))!!.mgmtPort) } @Test diff --git a/clients/android/native/src/discovery.rs b/clients/android/native/src/discovery.rs index 7a32a58e..5c49d5bc 100644 --- a/clients/android/native/src/discovery.rs +++ b/clients/android/native/src/discovery.rs @@ -32,7 +32,7 @@ const PROTO: &str = "punktfunk/1"; /// Field separator inside one serialized record (ASCII Unit Separator — never in a field value). const FIELD_SEP: char = '\u{1f}'; -/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os` +/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt` /// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips /// the framing bytes from every field so no value can break it. New fields append (the Kotlin /// parser tolerates both arities), never reorder. @@ -49,6 +49,10 @@ struct Host { /// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host /// card's OS icon. Empty if absent (older host). os: String, + /// Management-API port from the mDNS `mgmt` TXT — where the game library is served, distinct + /// from `port` (the native QUIC plane). `0` if absent. Kotlin persists it on the host record so + /// a host that moved off 47990 keeps its library once mDNS is no longer reachable. + mgmt: u16, } impl Host { @@ -61,7 +65,7 @@ impl Host { s.replace(['\n', '\r', FIELD_SEP], "") } format!( - "{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}", + "{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}", clean(&self.key), clean(&self.name), clean(&self.addr), @@ -70,6 +74,7 @@ impl Host { clean(&self.pair), clean(&self.mac), clean(&self.os), + self.mgmt, ) } } @@ -193,6 +198,8 @@ fn resolve(info: &ResolvedService) -> Option { pair: val("pair"), mac: val("mac"), os: val("os"), + // 0 = the host didn't advertise one (older host); Kotlin then falls back to 47990. + mgmt: val("mgmt").parse().unwrap_or(0), }) } @@ -213,7 +220,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt } /// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot, -/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts / +/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt` (`␟` = U+001F). Empty string = no hosts / /// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build). #[unsafe(no_mangle)] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>( @@ -277,10 +284,11 @@ mod tests { pair: "required".into(), mac: "aa:bb:cc:dd:ee:ff".into(), os: "linux/fedora/bazzite".into(), + mgmt: 47991, }; let encoded = h.encode(); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); - assert_eq!(fields.len(), 8); + assert_eq!(fields.len(), 9); assert_eq!(fields[0], "host-123"); assert_eq!(fields[1], "home-worker-2"); assert_eq!(fields[2], "192.168.1.70"); @@ -289,6 +297,9 @@ mod tests { assert_eq!(fields[5], "required"); assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff"); assert_eq!(fields[7], "linux/fedora/bazzite"); + // A NON-default port on purpose: the whole point of carrying this field is the host that + // moved off 47990, so a test pinned to the default would pass against a hardcoded value. + assert_eq!(fields[8], "47991"); assert!( !encoded.contains('\n'), "a record must never contain the record separator" @@ -308,13 +319,11 @@ mod tests { pair: "required\n".into(), mac: "aa:bb\u{1f}cc".into(), os: "linux\u{1f}evil/arch".into(), + // A numeric field cannot smuggle a separator — it is formatted from a u16, not cleaned. + mgmt: 47991, }; let encoded = h.encode(); - assert_eq!( - encoded.matches(FIELD_SEP).count(), - 7, - "exactly eight fields" - ); + assert_eq!(encoded.matches(FIELD_SEP).count(), 8, "exactly nine fields"); assert!(!encoded.contains('\n') && !encoded.contains('\r')); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); assert_eq!(fields[0], "kinjected"); diff --git a/clients/android/native/src/session/clipboard.rs b/clients/android/native/src/session/clipboard.rs index dd02bb65..eda84d88 100644 --- a/clients/android/native/src/session/clipboard.rs +++ b/clients/android/native/src/session/clipboard.rs @@ -50,6 +50,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport client(handle).is_some_and(|h| h.client.host_caps() & HOST_CAP_CLIPBOARD != 0) } +/// `NativeBridge.nativeHostMgmtPort(handle)` — the management-API port the host reported in this +/// session's `Welcome`, or `0` if it advertised none (older host / no management API). +/// +/// Kotlin persists this on the host record, which is what lets the library screen reach a host that +/// moved its mgmt port off 47990 WITHOUT ever having seen an mDNS advert — the VPN / routed-subnet +/// / added-by-address cases, where the `mgmt` TXT the discovery path relies on never arrives. +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostMgmtPort( + _env: EnvUnowned, + _this: JObject, + handle: jlong, +) -> jint { + client(handle).map_or(0, |h| jint::from(h.client.mgmt_port())) +} + /// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing /// clipboard-related happens on either side until an `enabled: true` crosses. #[unsafe(no_mangle)] diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 44b54005..fa226be6 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -354,9 +354,14 @@ struct ContentView: View { // Persist on the next runloop tick: HostStore is an ObservableObject, and mutating // its @Published from inside .onChange (a view-update callback) trips SwiftUI's // "Publishing changes from within view updates". A one-tick delay is imperceptible. + // The session's own Welcome told us where this host's library lives — the one + // source that does not need an mDNS advert, so it also covers a host reached by + // address over a VPN. 0 = not advertised; updateMgmtPort ignores it. + let liveMgmtPort = model.connection?.hostMgmtPort let store = store DispatchQueue.main.async { store.markConnected(host.id) + store.updateMgmtPort(host.id, port: liveMgmtPort) if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) } } case .idle: @@ -1262,6 +1267,9 @@ struct ContentView: View { if let live = discovery.hosts.first(where: { host.matches($0) }) { store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark + // ...and the mgmt port, so the library keeps working against a host that moved it once + // this device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi). + store.updateMgmtPort(host.id, port: live.mgmtPort) } else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty { // Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the // dial times out. With auto-wake off, connects go straight through (no packet). @@ -1320,6 +1328,7 @@ struct ContentView: View { guard !model.isBusy else { return } let host = StoredHost( name: d.name, address: d.host, port: d.port, + mgmtPort: d.mgmtPort, macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses, osChain: d.osChain.isEmpty ? nil : d.osChain) store.add(host) diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 2c5f754e..77504e62 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -162,6 +162,17 @@ final class HostStore: ObservableObject { hosts[i].osChain = chain } + /// Learn/refresh this host's management-API port from its live advert — same contract as + /// `updateMacs`. Until this existed, `StoredHost.mgmtPort` was declared and read but never + /// written, so `effectiveMgmtPort` always answered 47990 and a host that had moved its mgmt + /// port simply had no working library here. + func updateMgmtPort(_ hostID: UUID, port: UInt16?) { + guard let port, port > 0, + let i = hosts.firstIndex(where: { $0.id == hostID }), + hosts[i].mgmtPort != port else { return } + hosts[i].mgmtPort = port + } + /// Bind this host to a settings profile, or to "Default settings" (nil) — the ONLY way the /// default changes. A one-off "Connect with ▸" deliberately never lands here (§5.2: /// predictable, not sticky). diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index d6df9346..5f6b9235 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -61,6 +61,16 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { /// (`sanitizeOsChain`) — drives the host card's OS mark and is persisted like the MACs. /// Empty when not advertised (older host). Advisory/unauthenticated like the rest. public let osChain: String + /// The host's management-API port (mDNS `mgmt` TXT) — where the game library is served, NOT + /// `port`, which is the native QUIC plane. nil when not advertised (older host), and the + /// client then assumes `punktfunkDefaultMgmtPort`. + /// + /// Persisted onto the saved host like the MACs and the OS chain, and for a sharper reason: + /// `StoredHost.mgmtPort` has existed all along but nothing ever wrote it, so + /// `effectiveMgmtPort` always resolved to 47990. A host that moved its mgmt port off 47990 — + /// the supported way to share a machine with a Sunshine fork, whose web UI owns that port — + /// therefore had no working library on any Apple client at all. + public let mgmtPort: UInt16? } @MainActor @@ -211,12 +221,12 @@ public final class HostDiscovery: ObservableObject { public static func debugAdvert( id: String, name: String, host: String, port: UInt16 = 9777, fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true, - macAddresses: [String] = [], osChain: String = "" + macAddresses: [String] = [], osChain: String = "", mgmtPort: UInt16? = nil ) -> DiscoveredHost { DiscoveredHost( id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex, requiresPairing: requiresPairing, allowsTofu: allowsTofu, - macAddresses: macAddresses, osChain: osChain) + macAddresses: macAddresses, osChain: osChain, mgmtPort: mgmtPort) } #endif @@ -429,6 +439,7 @@ public final class HostDiscovery: ObservableObject { var id: String? var macs: [String] = [] var osChain = "" + var mgmtPort: UInt16? if case let .bonjour(txt) = result.metadata { fp = entry(txt, "fp") pair = entry(txt, "pair") @@ -438,13 +449,16 @@ public final class HostDiscovery: ObservableObject { .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } osChain = sanitizeOsChain(entry(txt, "os") ?? "") + // Unauthenticated input, so range-check rather than trust: a non-numeric or 0 value + // means "not advertised" and the client falls back to the default. + mgmtPort = entry(txt, "mgmt").flatMap(UInt16.init).flatMap { $0 > 0 ? $0 : nil } } return DiscoveredHost( id: (id?.isEmpty == false) ? id! : name, name: name, host: address, port: port, fingerprintHex: fp, requiresPairing: pair == "required", allowsTofu: pair == "optional", macAddresses: macs, - osChain: osChain) + osChain: osChain, mgmtPort: mgmtPort) } private static func key(_ result: NWBrowser.Result) -> String { diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index f033d191..b9d0591b 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -452,6 +452,14 @@ public final class PunktfunkConnection { /// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / /// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say. public private(set) var hostCaps: UInt8 = 0 + /// The host's management-API port, from this session's `Welcome` — where its game library is + /// served. `0` when the host advertised none (an older host, or one with no management API); + /// resolve through `StoredHost.effectiveMgmtPort` rather than dialing a `0`. + /// + /// Read this after a connect and persist it: it is the only source that does not depend on + /// mDNS, so it is what makes a moved mgmt port work for a host reached over a VPN or added by + /// address on a network where discovery never functions. + public private(set) var hostMgmtPort: UInt16 = 0 /// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) — the gate for /// offering the clipboard toggle. Absent on an older host, or one whose operator policy /// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark. @@ -677,6 +685,12 @@ public final class PunktfunkConnection { var caps: UInt8 = 0 _ = punktfunk_connection_host_caps(handle, &caps) hostCaps = caps + // Where this host serves its game library, straight from the session's Welcome. 0 = the + // host advertised none (older host / no management API), and the caller keeps whatever it + // already had. This is the answer that does NOT require an mDNS advert to have been seen. + var mgmt: UInt16 = 0 + _ = punktfunk_connection_mgmt_port(handle, &mgmt) + hostMgmtPort = mgmt } /// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`. diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 8b198fe4..53e1f6c1 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -796,7 +796,9 @@ from the config directory for a true factory reset." ); return NEEDS_INTERACTION; } - match library::fetch_games(&host.addr, library::DEFAULT_MGMT_PORT, &identity, pin) { + // The port this host actually serves its library on — learned from its advert and saved, + // falling back to 47990. Reaching for the constant here is what broke a moved port. + match library::fetch_games(&host.addr, host.effective_mgmt_port(), &identity, pin) { Ok(games) => { if has(args, "--json") { let rows: Vec = games diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 360055fb..76e12576 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -1108,6 +1108,18 @@ impl HostsPage { { crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os); } + // Same for its management port — and this one is not cosmetic: without it a host + // that moved off 47990 loses its library the moment mDNS is unavailable, because + // the advert was the only place the real port ever lived. + if let Some(a) = self + .adverts + .values() + .find(|a| matches(k, a) && a.mgmt_port.is_some()) + { + if let Some(p) = a.mgmt_port { + crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p); + } + } saved.push_back(HostCard { connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()), kind: CardKind::Saved { @@ -1183,18 +1195,33 @@ impl HostsPage { }); } - /// The advertised mgmt port for the host `req` points at, when a matching live - /// advert carries the `mgmt` TXT. + /// The mgmt port for the host `req` points at: a matching live advert's `mgmt` TXT first, + /// else the port a previous advert taught us and we saved on the host record. + /// + /// The saved rung is not redundant. Reading the advert alone meant a host that had moved its + /// mgmt port off 47990 served its library on the LAN and nowhere else — over a VPN, a routed + /// subnet, or any multicast-dead network there is no advert to read, and the fallback silently + /// went back to a port nothing was listening on. `None` here still means "assume the default". fn mgmt_port_for(&self, req: &ConnectRequest) -> Option { - self.adverts + let matches_req = |fp: &str, addr: &str, port: u16| { + req.fp_hex + .as_deref() + .is_some_and(|want| !fp.is_empty() && fp == want) + || (addr == req.addr && port == req.port) + }; + if let Some(p) = self + .adverts .values() - .find(|a| { - req.fp_hex - .as_deref() - .is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp) - || (a.addr == req.addr && a.port == req.port) - }) + .find(|a| matches_req(&a.fp_hex, &a.addr, a.port)) .and_then(|a| a.mgmt_port) + { + return Some(p); + } + crate::trust::KnownHosts::load() + .hosts + .iter() + .find(|h| matches_req(&h.fp_hex, &h.addr, h.port)) + .and_then(|h| h.mgmt_port) } /// Rename a saved host — an entry in an alert, then upsert + refresh. diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 8e60d50b..8f8c377e 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -73,8 +73,11 @@ pub fn run(target: Option<&str>) -> u8 { paired: k.is_some_and(|h| h.paired) || fake, saved: k.is_some(), online: false, + // Explicit --mgmt wins; else the port this host's advert taught us and we saved; + // else 47990. The middle rung is what survives mDNS being unavailable later. mgmt_port: arg_value("--mgmt") .and_then(|p| p.parse().ok()) + .or_else(|| k.and_then(|h| h.mgmt_port)) .unwrap_or(library::DEFAULT_MGMT_PORT), can_wake: false, last_used: k.and_then(|h| h.last_used), @@ -181,7 +184,7 @@ pub fn run(target: Option<&str>) -> u8 { vsync: settings_at_start.vsync, allow_vrr: settings_at_start.allow_vrr, json_status, - on_connected: Some(Box::new(move |fingerprint: [u8; 32]| { + on_connected: Some(Box::new(move |fingerprint: [u8; 32], mgmt_port: u16| { let fp_hex = trust::hex(&fingerprint); trust::touch_last_used(&fp_hex); // A request-access connect just succeeded → the operator approved us. Save the @@ -191,6 +194,10 @@ pub fn run(target: Option<&str>) -> u8 { trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true); } } + // Where this host serves its library, from the session's own Welcome — recorded + // AFTER the persist above so a host saved by this very connect gets it too. `0` = + // the host advertised none, and the call is a no-op. + trust::learn_mgmt_port_by_fp(&fp_hex, mgmt_port); })), overlay: Some(Box::new(overlay)), window_size: crate::session_main::window_size(&settings_at_start), @@ -682,6 +689,12 @@ impl ServiceState { || (d.addr == h.addr && d.port == h.port) }); let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false); + // Write the advertised mgmt port down while the host is visible, so this console + // keeps working against a moved port once it is not. No-op (and no disk write) + // when unchanged, so this is safe on every refresh tick. + if let Some(p) = advert.and_then(|d| d.mgmt_port) { + pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p); + } let row = HostRow { key: key.clone(), name: host_display_name(&h.name, &h.addr), @@ -691,8 +704,12 @@ impl ServiceState { paired: h.paired, saved: true, online, + // Live advert first, then what we saved from an earlier one, then 47990 — + // the same three rungs `os` uses just below. Reading the advert ALONE is why + // a host on a moved mgmt port lost its library the moment mDNS went quiet. mgmt_port: advert .and_then(|d| d.mgmt_port) + .or(h.mgmt_port) .unwrap_or(library::DEFAULT_MGMT_PORT), can_wake: !online && !h.mac.is_empty(), last_used: h.last_used, diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 7588d695..e06bc5af 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -986,9 +986,16 @@ mod session_main { vsync: settings.vsync, allow_vrr: settings.allow_vrr, json_status: true, - on_connected: Some(Box::new(|fingerprint: [u8; 32]| { + on_connected: Some(Box::new(|fingerprint: [u8; 32], mgmt_port: u16| { + let fp = trust::hex(&fingerprint); // This host's card carries the accent bar in the desktop client now. - trust::touch_last_used(&trust::hex(&fingerprint)); + trust::touch_last_used(&fp); + // Save where this host serves its library, learned from the session's own + // Welcome rather than an mDNS advert — so it keeps working on a network where + // discovery never does. `0` = the host advertised none; leave what we have. + if mgmt_port != 0 { + trust::learn_mgmt_port_by_fp(&fp, mgmt_port); + } })), // The Skia console UI (stats OSD, capture HUD) — compiled out of the // power-user build (`--no-default-features` drops the `ui` feature). diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 801c2541..9729a722 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -691,6 +691,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { fp_hex: Some(k.fp_hex.clone()), pair_optional: false, mac: k.mac.clone(), + mgmt_port: k.mgmt_port, profile: None, launch: None, }; @@ -715,6 +716,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { }) { crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os); } + // Same for its management port — load-bearing, unlike the two above: a host moved off + // 47990 loses its library entirely once mDNS is gone unless we write the port down. + if let Some(p) = hosts + .iter() + .find(|h| { + (h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) + && h.mgmt_port.is_some() + }) + .and_then(|h| h.mgmt_port) + { + crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p); + } let can_wake = !online && !k.mac.is_empty(); let menu = { let (svc, target) = (props.svc.clone(), target.clone()); @@ -1046,6 +1059,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), pair_optional: h.pair == "optional", mac: h.mac.clone(), + mgmt_port: h.mgmt_port, profile: None, launch: None, }; @@ -1140,6 +1154,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { fp_hex: None, pair_optional: false, mac: Vec::new(), + // Added by hand, so nothing has told us where its mgmt API is: fall back to + // 47990 (exactly today's behaviour) until an advert teaches us otherwise. + // A host that moved its mgmt port AND is never visible on mDNS still needs the + // host to announce the port in-band — see the note in `Target::mgmt_port`. + mgmt_port: None, profile: None, launch: None, }, diff --git a/clients/windows/src/app/library.rs b/clients/windows/src/app/library.rs index 8b444e3b..367c5c74 100644 --- a/clients/windows/src/app/library.rs +++ b/clients/windows/src/app/library.rs @@ -104,7 +104,7 @@ pub(crate) fn start_fetch(ctx: &Arc, set_library: &AsyncSetState, set_library: &AsyncSetState)> = VecDeque::new(); for g in &games { diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index a8f8b2f0..d73754b2 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -103,6 +103,11 @@ pub(crate) struct Target { /// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a /// magic packet before connecting to an offline host. Empty when none is known. pub(crate) mac: Vec, + /// This host's management-API port (saved store or live advert), where the library screen + /// fetches from. `None` = unknown, use [`pf_client_core::library::DEFAULT_MGMT_PORT`]. Carried + /// on the target for the same reason as `mac`: the library screen has no `KnownHost` in hand, + /// and assuming 47990 there is what made a moved mgmt port work on the LAN but not over a VPN. + pub(crate) mgmt_port: Option, /// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the /// host's binding for this launch, `Some("")` forces the global defaults on a bound host, /// `None` honors the binding. It never rebinds anything — the default changes only through @@ -406,6 +411,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { fp_hex: p.host.fp_hex.clone(), pair_optional: false, mac: p.host.mac.clone(), + mgmt_port: p.host.mgmt_port, profile: p.profile_override.clone(), launch: None, // routed explicitly below (initiate_launch*) }; @@ -447,6 +453,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { fp_hex: u.fp.clone(), pair_optional: false, mac: Vec::new(), + // A link carries no mgmt port (nor a MAC), so this stays unknown until + // an advert teaches it — same fallback as the hand-added case. + mgmt_port: None, profile: u.profile.clone(), launch: u.launch.clone(), }; diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index ef2fbf10..5d761ee6 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -313,6 +313,20 @@ pub struct KnownHost { /// sleep. `default` (and elided when empty) so pre-existing stores load unchanged. #[serde(default, skip_serializing_if = "String::is_empty")] pub os: String, + /// The host's management-API port (mDNS `mgmt` TXT), where the game library is served — + /// distinct from `port`, which is the native QUIC plane. Learned from the advert while the + /// host is online and persisted here for the same reason as `mac` and `os`: so it survives the + /// advert going away. + /// + /// That is not a cosmetic loss like a missing OS icon. A host that moved its mgmt port off + /// 47990 — the supported fix for sharing a machine with a Sunshine fork, whose web UI owns + /// that port — was reachable only for as long as mDNS was: on a VPN, a routed subnet, or a + /// multicast-dead network the library silently went blank, because the port the client had + /// already been told was never written down. `None` = never learned, resolve via + /// [`KnownHost::effective_mgmt_port`]. Optional + `default` so pre-existing stores load + /// (the Apple client's `StoredHost.mgmtPort` is the same field for the same reason). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mgmt_port: Option, /// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md /// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a /// host your clipboard is a trust decision about that host. Default off; the host must @@ -353,6 +367,7 @@ impl Default for KnownHost { last_used: None, mac: Vec::new(), os: String::new(), + mgmt_port: None, clipboard_sync: false, profile_id: None, pinned_profiles: Vec::new(), @@ -362,6 +377,17 @@ impl Default for KnownHost { } impl KnownHost { + /// Where this host's management API actually is: the port learned from its advert, else the + /// compiled-in 47990. The twin of the Apple client's `StoredHost.effectiveMgmtPort`. + /// + /// Every library/art call resolves through this rather than reaching for + /// [`crate::library::DEFAULT_MGMT_PORT`] directly — that constant is the FALLBACK, not the + /// answer, and call sites that treated it as the answer are why a moved port only worked while + /// mDNS was up. + pub fn effective_mgmt_port(&self) -> u16 { + self.mgmt_port.unwrap_or(crate::library::DEFAULT_MGMT_PORT) + } + /// This host's pinned profiles that still exist, in card order, without duplicates — what /// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design /// §5.2a: a pin is presentation state, never a reason to show an error. @@ -506,6 +532,13 @@ impl KnownHosts { if !entry.os.is_empty() { h.os = entry.os; } + // And for the learned mgmt port. Stated explicitly rather than left to the + // does-not-mention-it rule below: this one is load-bearing (a host that moved off + // 47990 is unreachable for the library without it), so a reconnect upsert that + // carries `None` must visibly not clear what a discovery taught us. + if entry.mgmt_port.is_some() { + h.mgmt_port = entry.mgmt_port; + } // Everything below is state the user set ON this record, which a refresh (a // reconnect, a re-pair, a rediscovery) never carries and therefore must never // clear: the per-host clipboard decision — which survives today only because this @@ -581,6 +614,9 @@ impl KnownHosts { if h.os.is_empty() { h.os = old.os; } + if h.mgmt_port.is_none() { + h.mgmt_port = old.mgmt_port; + } if h.profile_id.is_none() { h.profile_id = old.profile_id; } @@ -692,6 +728,27 @@ pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) { let _ = known.save(); } +/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT), +/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when +/// unchanged, so the hosts page can call it on every discovery tick without churning the store. +/// +/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight +/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN. +pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) { + if mgmt_port == 0 { + return; + } + let mut known = KnownHosts::load(); + let Some(h) = learn_target(&mut known, fp_hex, addr, port) else { + return; + }; + if h.mgmt_port == Some(mgmt_port) { + return; + } + h.mgmt_port = Some(mgmt_port); + let _ = known.save(); +} + /// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by /// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when /// a woken host reappears on a different IP than the stored one, so this and future connects dial the @@ -725,6 +782,28 @@ pub fn touch_last_used(fp_hex: &str) { } } +/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by +/// fingerprint alone — the identity a just-connected client is certain of. +/// +/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire +/// where an advert is visible, whereas this fires on any successful connect, including a host +/// added by IP on a network where discovery has never worked. No-op — and no disk write — when +/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect. +pub fn learn_mgmt_port_by_fp(fp_hex: &str, mgmt_port: u16) { + if fp_hex.is_empty() || mgmt_port == 0 { + return; + } + let mut known = KnownHosts::load(); + let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else { + return; + }; + if h.mgmt_port == Some(mgmt_port) { + return; + } + h.mgmt_port = Some(mgmt_port); + let _ = known.save(); +} + /// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST /// stores this client under (its paired-devices list); the 90 s budget covers a /// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin. @@ -1781,6 +1860,9 @@ mod tests { last_used: Some(1000), mac: vec!["aa:bb:cc:dd:ee:ff".into()], os: "linux/fedora/bazzite".into(), + // Deliberately NOT 47990: a host that moved its mgmt port is the case this field + // exists for, so the default would make the assertions below pass vacuously. + mgmt_port: Some(47991), clipboard_sync: true, profile_id: Some("aaaaaaaaaaaa".into()), pinned_profiles: vec!["bbbbbbbbbbbb".into()], @@ -1804,6 +1886,9 @@ mod tests { assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); // The learned OS chain rides the same rule as `mac`: a carrier-less upsert keeps it. assert_eq!(h.os, "linux/fedora/bazzite"); + // And the learned mgmt port. If a reconnect could reset this to None the host would fall + // back to 47990 and its library would 404 — the exact regression this rule prevents. + assert_eq!(h.mgmt_port, Some(47991)); assert!(h.clipboard_sync); assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa")); assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]); @@ -1823,6 +1908,51 @@ mod tests { assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]); } + /// The mgmt port a host advertises has to OUTLIVE the advert: a store written before the field + /// existed must load, resolve to 47990, and then take and keep a learned value. Without the + /// middle rung a host moved off 47990 (to share a box with a Sunshine fork, whose web UI owns + /// that port) served its library on the LAN and nowhere else — over a VPN or a routed subnet + /// there is no advert to read and the client silently went back to a dead port. + #[test] + fn mgmt_port_survives_a_store_that_predates_it_and_then_persists() { + // A store written before the field existed: no `mgmt_port` key at all. + let old = r#"{"hosts":[{ + "name": "Gaming PC", "addr": "192.168.1.50", "port": 9777, + "fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "paired": true + }]}"#; + let mut k: KnownHosts = serde_json::from_str(old).unwrap(); + assert_eq!(k.hosts[0].mgmt_port, None, "absent key decodes to None"); + assert_eq!( + k.hosts[0].effective_mgmt_port(), + crate::library::DEFAULT_MGMT_PORT, + "unknown resolves to the compiled-in default, i.e. today's behaviour" + ); + // Unset stays out of the serialized form, so an untouched store is byte-stable. + assert!(!serde_json::to_string(&k).unwrap().contains("mgmt_port")); + + // Learning one (what a discovery tick does) takes effect and round-trips. + k.hosts[0].mgmt_port = Some(47991); + assert_eq!(k.hosts[0].effective_mgmt_port(), 47991); + let round: KnownHosts = serde_json::from_str(&serde_json::to_string(&k).unwrap()).unwrap(); + assert_eq!(round.hosts[0].mgmt_port, Some(47991)); + + // A re-key carries it onto the surviving record — otherwise a host that regenerated its + // identity would silently drop back to 47990. + let fresh = fp('a'); + let mut k2 = k; + k2.upsert_trusted(KnownHost { + name: "Gaming PC".into(), + addr: "192.168.1.50".into(), + port: 9777, + fp_hex: fresh.clone(), + paired: true, + ..Default::default() + }); + let kept = k2.hosts.iter().find(|h| h.fp_hex == fresh).unwrap(); + assert_eq!(kept.mgmt_port, Some(47991), "re-key must not lose the port"); + } + /// A host that regenerated its identity (reinstall, wiped ProgramData, re-key) ends up with /// ONE record for its address — the live one. This is the `.173` lockout: `upsert` keys on /// the fingerprint, so the re-paired host used to be appended beside the dead record, and @@ -1840,6 +1970,7 @@ mod tests { last_used: Some(1000), mac: vec!["aa:bb:cc:dd:ee:ff".into()], os: "windows".into(), + mgmt_port: Some(47991), clipboard_sync: true, profile_id: Some("aaaaaaaaaaaa".into()), pinned_profiles: vec!["bbbbbbbbbbbb".into()], @@ -1864,6 +1995,9 @@ mod tests { // What describes the BOX rides along, so a reinstall doesn't cost the user their setup. assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); assert_eq!(h.os, "windows"); + // The mgmt port describes the BOX, not the retired certificate: a reinstall must not send + // the library back to 47990 on a host that serves it somewhere else. + assert_eq!(h.mgmt_port, Some(47991)); assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa")); assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]); assert_eq!(h.last_used, Some(1000)); diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 0f734a47..e4f7eb82 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -144,6 +144,30 @@ pub struct HostConfig { /// text ("Living Room PC"); the DNS-level `