The management port is movable, and the client no longer needs mDNS to find it #230

Merged
enricobuehler merged 4 commits from worktree-mgmt-port-single-source into main 2026-08-14 18:25:14 +00:00
43 changed files with 936 additions and 59 deletions
@@ -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
@@ -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,
@@ -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
@@ -19,13 +19,16 @@ data class DiscoveredHost(
val pairingRequired: Boolean = false,
val mac: List<String> = 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,
)
}
@@ -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<String> = 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))
@@ -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
+18 -9
View File
@@ -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<Host> {
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");
@@ -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)]
@@ -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)
@@ -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).
@@ -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 {
@@ -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`.
+3 -1
View File
@@ -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<serde_json::Value> = games
+36 -9
View File
@@ -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<u16> {
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.
+18 -1
View File
@@ -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,
+9 -2
View File
@@ -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).
+19
View File
@@ -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,
},
+5 -2
View File
@@ -104,7 +104,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
let mut state = LibraryState::default();
let games = match library::fetch_games(
&target.addr,
library::DEFAULT_MGMT_PORT,
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
&identity,
pin,
) {
@@ -120,7 +120,10 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
}
// Seed cached posters; queue the art pipeline for the rest.
let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT);
let base = library::base_url(
&target.addr,
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
);
let cache = art_cache_dir();
let mut jobs: VecDeque<(String, Vec<String>)> = VecDeque::new();
for g in &games {
+9
View File
@@ -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<String>,
/// 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<u16>,
/// 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<AppCtx>) -> 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<AppCtx>) -> 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(),
};
+134
View File
@@ -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<u16>,
/// 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));
+32
View File
@@ -144,6 +144,30 @@ pub struct HostConfig {
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
pub host_name: Option<String>,
/// `PUNKTFUNK_MGMT_BIND` — the management API's listen address (`IP:PORT`), equivalent to the
/// `--mgmt-bind` CLI flag, which still wins when both are given. Unset = `0.0.0.0:47990`.
///
/// This exists so moving the port SURVIVES: `--mgmt-bind` lives in a unit file / service
/// registration that a package upgrade rewrites, whereas `host.env` is operator-owned and is
/// the documented place every other knob lives. The motivating case is coexistence with a
/// Sunshine fork — 47990 is *their* web UI port as well as our management API, and it is the
/// only port the two share once the GameStream planes are off, so moving it is the whole fix.
///
/// Kept as the raw string rather than a parsed `SocketAddr`: this crate is the
/// parse-once-from-env layer, and `main.rs` owns turning a bad value into the same
/// `bad --mgmt-bind (want IP:PORT)` error the flag produces, from one place.
pub mgmt_bind: Option<String>,
/// `PUNKTFUNK_NATIVE_PORT` — the native punktfunk/1 (QUIC) control port, equivalent to the
/// `--native-port` CLI flag, which still wins. Unset = 9777.
///
/// Same survives-an-upgrade argument as [`Self::mgmt_bind`]: `--native-port` lives in an
/// ExecStart a package rewrites. Unlike the mgmt port, the CLIENT side of moving this already
/// worked — `KnownHost.port` is persisted per host and `--connect HOST:PORT` names it — so this
/// key is the last piece of making the native port genuinely movable.
///
/// Raw string, parsed in `main.rs`, for the same reason as `mgmt_bind`: a typo'd port must be a
/// startup ERROR, not a silent fall back to 9777 while the operator believes they moved it.
pub native_port: Option<String>,
/// `PUNKTFUNK_GAMESTREAM` — enable the GameStream/Moonlight-compat planes (nvhttp pairing,
/// RTSP, ENet control, `_nvstream` mDNS) from `host.env`, equivalent to the `--gamestream`
/// CLI flag (either source turns it on). **Default OFF** — the secure native-only host: the
@@ -374,6 +398,14 @@ impl HostConfig {
host_name: val("PUNKTFUNK_HOST_NAME")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Blank-is-unset, like `host_name` above: an operator who comments a value out by
// emptying it (`PUNKTFUNK_MGMT_BIND=`) means "default", not "parse the empty string".
mgmt_bind: val("PUNKTFUNK_MGMT_BIND")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
native_port: val("PUNKTFUNK_NATIVE_PORT")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Default OFF, explicit-on grammar: the Moonlight-compat planes are opt-in
// everywhere (see the field doc); `--gamestream` on the CLI also turns them on.
gamestream: env_on("PUNKTFUNK_GAMESTREAM").unwrap_or(false),
+21 -4
View File
@@ -39,6 +39,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// [`SessionOpts::on_connected`]'s callback: the host's certificate fingerprint, then the
/// management-API port from its `Welcome` (`0` = it advertised none).
///
/// A named type rather than the inline `Box<dyn FnMut(...)>` because adding the second parameter
/// tipped it over `clippy::type_complexity` — factoring it out is what that lint asks for, and it
/// gives the two positional arguments somewhere to be documented.
pub type ConnectedFn = Box<dyn FnMut([u8; 32], u16)>;
pub struct SessionOpts {
pub window_title: String,
/// Start fullscreen (gamescope / `--fullscreen`).
@@ -84,9 +92,14 @@ pub struct SessionOpts {
pub allow_vrr: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
/// binary's business — this loop stays store-agnostic).
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
/// Called once on `Connected` with the host's fingerprint and the management-API port the
/// host reported in its `Welcome` (`0` = it advertised none). Trust persistence is the
/// binary's business — this loop stays store-agnostic.
///
/// The port rides along because this is the one moment a client is guaranteed to have it
/// WITHOUT mDNS: the session it just authenticated carries it. A client that saves it here
/// can browse the library of a host it has only ever reached by address.
pub on_connected: Option<ConnectedFn>,
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
/// warning rather than killing the session. Browse mode requires one.
@@ -1377,9 +1390,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts);
st.capture = Some(cap);
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
// Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to
// "where is this host's library", which the binary persists so it survives
// without ever needing an mDNS advert.
let mgmt_port = c.mgmt_port();
st.connector = Some(c);
if let Some(f) = opts.on_connected.as_mut() {
f(fingerprint);
f(fingerprint, mgmt_port);
}
if let Some(o) = overlay.as_mut() {
o.session_phase(SessionPhase::Streaming);
+36
View File
@@ -3826,6 +3826,42 @@ fn build_clip_event(
out
}
/// The host's management-API port, from this session's `Welcome` — where its game library is
/// served (distinct from the streaming ports). `0` means the host did not advertise one: an older
/// host, or the standalone `punktfunk1-host` binary, which has no management API. Treat `0` as
/// "unknown" and fall back to your own default (47990), never as a port to dial.
///
/// This exists so a client does NOT need mDNS to find the library. The port used to live only in
/// the host's mDNS TXT, so a host that had moved it off 47990 — the supported way to coexist with
/// a Sunshine fork, whose web UI owns that port — was reachable only where multicast worked. Read
/// this after connect and prefer it over any cached or default value. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `port` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_mgmt_port(
c: *const PunktfunkConnection,
port: *mut u16,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
// it is written; a non-null one is a caller-owned writable slot.
unsafe {
if !port.is_null() {
*port = c.inner.mgmt_port();
}
}
PunktfunkStatus::Ok
})
}
/// The host capability bitfield the session's `Welcome` carried — a bitfield of
/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD` /
/// `PUNKTFUNK_HOST_CAP_PEN`. A client tests `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide
@@ -73,4 +73,8 @@ pub(crate) struct Negotiated {
/// [`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the
/// embedder via [`NativeClient::host_caps`] so a native client greys out unsupported toggles.
pub(crate) host_caps: u8,
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), `0` when it did not
/// advertise one. Surfaced to the embedder via [`crate::NativeClient::mgmt_port`] so a client
/// can reach the game library without ever having seen an mDNS advert.
pub(crate) mgmt_port: u16,
}
+16
View File
@@ -268,6 +268,9 @@ pub struct NativeClient {
/// The host capability bitfield ([`crate::quic::Welcome::host_caps`]) — see
/// [`NativeClient::host_caps`].
pub host_caps: u8,
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), or `0` when the host
/// did not advertise one — see [`NativeClient::mgmt_port`].
pub mgmt_port: u16,
/// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>,
@@ -723,6 +726,7 @@ impl NativeClient {
next_xfer_id: AtomicU32::new(1),
pen_seq: AtomicU16::new(0),
host_caps: negotiated.host_caps,
mgmt_port: negotiated.mgmt_port,
probe,
shutdown,
end_reason,
@@ -1378,6 +1382,18 @@ impl NativeClient {
self.host_caps
}
/// The host's management-API port, from this session's [`crate::quic::Welcome`] — where its
/// game library is served. `0` when the host did not advertise one (an older host, or the
/// standalone `punktfunk1-host` binary, which has no management API); the caller then keeps
/// its own default.
///
/// This is the mDNS-free answer to "where is the library": it arrives over the connection the
/// client has already authenticated, so a host reached by IP over a VPN — or on any network
/// where multicast never worked — no longer has to be assumed to be on 47990.
pub fn mgmt_port(&self) -> u16 {
self.mgmt_port
}
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
@@ -255,6 +255,7 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
codec: welcome.codec,
shard_payload: welcome.shard_payload,
host_caps: welcome.host_caps,
mgmt_port: welcome.mgmt_port,
},
welcome.host_caps,
))
+10 -1
View File
@@ -176,7 +176,16 @@ pub use stats::Stats;
/// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
/// client-local: nothing new goes on the wire — the width is computed from frame indices the client
/// already receives — so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 19;
/// v20: `punktfunk_connection_mgmt_port` — reads the host's management-API port out of the
/// session's `Welcome`, so a client can find the game library WITHOUT mDNS. The port previously
/// existed only in the host's mDNS TXT, which made a host that had moved it off 47990 (the
/// supported way to share a machine with a Sunshine fork, whose web UI owns that port) reachable
/// only where multicast worked — over a VPN, a routed subnet, or for a host added by IP, the
/// library silently fell back to a port nothing was listening on. A NEW symbol, not a widened one:
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 20;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+1
View File
@@ -340,6 +340,7 @@ mod tests {
audio_channels: 2,
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
mgmt_port: 0,
cipher: 0,
key_chacha: None,
};
+96 -4
View File
@@ -211,6 +211,22 @@ pub struct Welcome {
/// advertised, so an unknown id reaching us is a bug, and falling back would yield an
/// undecryptable session with a confusing failure signature.
pub cipher: u8,
/// The host's management-API port — where its game library is served, distinct from every
/// other port here (`udp_port` is the data plane; the control plane is the QUIC port the
/// client already dialed). `0` = not advertised (an older host), and the client falls back to
/// the compiled-in 47990.
///
/// **Why this is on the wire at all:** the port was previously discoverable ONLY from the
/// mDNS `mgmt` TXT. A host that moved it off 47990 — the supported way to share a machine with
/// a Sunshine fork, whose web UI owns that port — therefore had a working library only where
/// multicast worked. Carrying it in the `Welcome` means the client learns it over the
/// connection it has already authenticated, so a VPN-only, routed-subnet or manually-added
/// host needs no discovery at all.
///
/// Appended AFTER the cipher block (offset 69, or 101 when a ChaCha key precedes it) rather
/// than at the next free fixed offset, and emitting it forces the `cipher` placeholder — see
/// the note in [`Welcome::encode`]. `0` when an older host omitted it.
pub mgmt_port: u16,
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
@@ -473,11 +489,24 @@ impl Welcome {
self.key_chacha.is_some(),
"key_chacha present iff cipher == 1"
);
if self.cipher != CIPHER_AES_128_GCM {
//
// ⚠ `mgmt_port` follows the cipher block, so emitting it FORCES the cipher byte even for
// an AES session — the placeholder discipline `Hello::encode` already uses for
// `audio_channels`/`preferred_codec`. Without that, an AES Welcome carrying a mgmt port
// would put the port's low byte at offset 68, exactly where every 0.28.x client reads
// `cipher` — and that decode is deliberately fail-closed on an unknown id, so the whole
// handshake would break against currently-shipped clients. An explicit `cipher = 0` is
// harmless by comparison: a current client reads AES (correct), and a pre-cipher client
// stops before 68 regardless.
let mgmt_present = self.mgmt_port != 0;
if self.cipher != CIPHER_AES_128_GCM || mgmt_present {
b.push(self.cipher);
if let Some(k) = &self.key_chacha {
b.extend_from_slice(k);
}
if mgmt_present {
b.extend_from_slice(&self.mgmt_port.to_le_bytes());
}
}
b
}
@@ -488,9 +517,12 @@ impl Welcome {
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
// key_chacha[69..101] (everything from compositor on is an optional trailing byte; an
// older host stops earlier; cipher/key_chacha are present only when ChaCha was
// negotiated).
// key_chacha[69..101] mgmt_port[69..71 | 101..103] (everything from compositor on is an
// optional trailing byte; an older host stops earlier; cipher/key_chacha are present only
// when ChaCha was negotiated). `mgmt_port` is the one field whose offset is NOT fixed: it
// follows the cipher block, so it starts at 69 for an AES session and 101 when a 32-byte
// ChaCha key precedes it. Emitting it forces the cipher byte (see `encode`), so "cipher
// absent" and "mgmt_port present" can never both hold.
if b.len() < 53 || &b[0..4] != MAGIC {
return Err(PunktfunkError::InvalidArg("bad Welcome"));
}
@@ -518,6 +550,18 @@ impl Welcome {
}
_ => return Err(PunktfunkError::InvalidArg("bad Welcome")),
};
// The mgmt port sits after the cipher block, so its offset depends on whether a ChaCha key
// preceded it. Absent (an older host, or one that did not advertise) → `0` = unknown, and
// the client falls back to the compiled-in default.
let mgmt_off = if cipher == CIPHER_CHACHA20_POLY1305 {
101
} else {
69
};
let mgmt_port = b
.get(mgmt_off..mgmt_off + 2)
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0);
Ok(Welcome {
abi_version: u32at(4),
udp_port: u16at(8),
@@ -585,6 +629,7 @@ impl Welcome {
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
// snapshots; the client keeps sending legacy per-transition events).
host_caps: b.get(67).copied().unwrap_or(0),
mgmt_port,
cipher,
key_chacha,
})
@@ -671,6 +716,7 @@ mod tests {
audio_channels: 2,
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
host_caps: HOST_CAP_GAMEPAD_STATE,
mgmt_port: 0,
cipher: 0,
key_chacha: None,
};
@@ -736,6 +782,7 @@ mod tests {
audio_channels: 2,
codec: CODEC_HEVC,
host_caps: 0,
mgmt_port: 0,
cipher: CIPHER_AES_128_GCM,
key_chacha: None,
};
@@ -779,6 +826,48 @@ mod tests {
let cha_cfg = cha.session_config(Role::Client);
assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32));
cha_cfg.validate().expect("ChaCha config validates");
// ── mgmt_port, the trailing field after the cipher block ──────────────────────────────
//
// ⚠ THE HAZARD THIS PINS: `mgmt_port` follows `cipher`, and `cipher` is emitted only when
// non-default. Appending the port to an AES Welcome without forcing the cipher byte would
// land the port's LOW BYTE at offset 68 — exactly where every shipped client reads
// `cipher`, whose decode is fail-closed on an unknown id. 47991 is 0xBB57, so byte 68
// would read 0x57 = 87, an unknown id, and EVERY 0.28.x client would fail the handshake
// against a host that had merely moved its mgmt port. Assert the placeholder is there.
let mgmt = Welcome {
mgmt_port: 47991,
..base
};
let menc = mgmt.encode();
assert_eq!(menc.len(), 68 + 1 + 2, "cipher placeholder + LE u16 port");
assert_eq!(
menc[68], CIPHER_AES_128_GCM,
"the cipher byte MUST be present (as 0) so a current client still reads AES here"
);
assert_eq!(&menc[69..71], &47991u16.to_le_bytes());
assert_eq!(Welcome::decode(&menc).unwrap(), mgmt);
// With ChaCha the port sits after the 32-byte key instead, at 101..103.
let both = Welcome {
mgmt_port: 47991,
cipher: CIPHER_CHACHA20_POLY1305,
key_chacha: Some(k32),
..base
};
let benc = both.encode();
assert_eq!(benc.len(), 68 + 1 + 32 + 2);
assert_eq!(&benc[101..103], &47991u16.to_le_bytes());
assert_eq!(Welcome::decode(&benc).unwrap(), both);
// A host that advertises no mgmt port emits nothing extra — an AES Welcome stays exactly
// 68 bytes, so this field costs the common case zero and cannot perturb an old client.
assert_eq!(base.encode().len(), 68);
// ...and an old host's Welcome decodes to 0 = unknown, never to a port we might dial.
assert_eq!(Welcome::decode(&enc).unwrap().mgmt_port, 0);
assert_eq!(Welcome::decode(&cenc).unwrap().mgmt_port, 0);
// A truncated tail (one byte of the port) is not half a port: it reads as unknown.
assert_eq!(Welcome::decode(&menc[..70]).unwrap().mgmt_port, 0);
}
#[test]
@@ -873,6 +962,7 @@ mod tests {
audio_channels: 2,
codec: CODEC_PYROWAVE,
host_caps: 0,
mgmt_port: 0,
cipher: 0,
key_chacha: None,
}
@@ -947,6 +1037,7 @@ mod tests {
audio_channels: 2,
codec: CODEC_H264,
host_caps: 0,
mgmt_port: 0,
cipher: 0,
key_chacha: None,
}
@@ -1058,6 +1149,7 @@ mod tests {
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE,
mgmt_port: 0,
cipher: 0,
key_chacha: None,
};
+39 -5
View File
@@ -761,6 +761,9 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
// paired clients can browse the game library out of the box (the bearer admin surface stays
// loopback-gated in `mgmt::require_auth` regardless of the bind).
let mut mgmt_bind_explicit = false;
// Same question for the native port: an explicit `--native-port` out-ranks
// `PUNKTFUNK_NATIVE_PORT` from host.env, resolved after the loop.
let mut native_port_explicit = false;
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
@@ -793,7 +796,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
"--native-port" => {
native_port = next()?
.parse()
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?;
native_port_explicit = true;
}
"--data-port" => {
data_port = Some(
@@ -844,9 +848,34 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
//
// Same two-source shape as `--gamestream` / `PUNKTFUNK_GAMESTREAM` below, and for the same
// reason: the packaged units ship a fixed ExecStart, so `host.env` is the only route a package
// user has to move this that an upgrade won't overwrite. CLI wins — it is the more explicit of
// the two and the one a support instruction reaches for.
if !mgmt_bind_explicit {
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
opts.bind = match pf_host_config::config().mgmt_bind.as_deref() {
Some(s) => s
.parse()
.map_err(|_| anyhow::anyhow!("bad PUNKTFUNK_MGMT_BIND '{s}' (want IP:PORT)"))?,
None => std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT)),
};
}
// Same two-source resolution as the mgmt bind above. A bad value is FATAL rather than ignored:
// silently serving on 9777 while host.env says otherwise is the failure that reads as "I moved
// the port and the client still can't reach me".
if !native_port_explicit {
if let Some(s) = pf_host_config::config().native_port.as_deref() {
native_port = s
.parse()
.map_err(|_| anyhow::anyhow!("bad PUNKTFUNK_NATIVE_PORT '{s}' (want a port)"))?;
}
}
// Publish the resolved port for the console, right here rather than inside `serve`: the
// console's unit gates on `mgmt-token` (persisted a few lines above), so writing the endpoint
// in the same function keeps the two files effectively simultaneous. A console that still wins
// that race falls back to 47990 and its `Restart=always` retry picks the file up.
mgmt::publish_endpoint(opts.bind);
let native = native::NativeServe {
port: native_port,
require_pairing: !open,
@@ -999,10 +1028,13 @@ USAGE:
punktfunk-host spike [OPTIONS] captureencodefile pipeline spike (dev tool)
SERVE OPTIONS:
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 paired clients
--mgmt-bind <IP:PORT> management API address (or PUNKTFUNK_MGMT_BIND in host.env, which
this flag overrides). Default: 0.0.0.0:47990 paired clients
reach the read-only surface, incl. the game library, over mTLS;
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
bind loopback only)
bind loopback only. Move the PORT (e.g. 0.0.0.0:47991) to share a
machine with Sunshine/Apollo/Vibeshine, whose web UI owns 47990
clients follow via mDNS and the console via mgmt-endpoint
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
admin endpoints it guards are honored only from a loopback peer
(the co-located web console), never over the LAN
@@ -1013,7 +1045,9 @@ SERVE OPTIONS:
Also PUNKTFUNK_GAMESTREAM=1 in host.env (how a packaged install
opts in the shipped units run native-only)
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
--native-port <PORT> native QUIC port (default 9777)
--native-port <PORT> native QUIC port (or PUNKTFUNK_NATIVE_PORT in host.env, which
this flag overrides). Default 9777. Clients follow via mDNS, and
a manually-added host keeps whatever port it was added with
--data-port <PORT> pin the per-session video data plane to this fixed UDP port and
stream direct (no hole-punch) open exactly this port in a host
firewall to avoid the ~2.5 s punch-timeout. Default (unset) or
+90
View File
@@ -57,8 +57,98 @@ pub(crate) use plugins::ui_credential;
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
/// number Sunshine users already associate with "the config UI".
///
/// ⚠ That last part is also why it is the ONE port a Sunshine fork and a GameStream-off Punktfunk
/// still collide on (47990 is their web UI). Moving it is supported — see [`publish_endpoint`] and
/// `PUNKTFUNK_MGMT_BIND` — and every consumer derives the real port rather than assuming this one.
pub const DEFAULT_PORT: u16 = 47990;
/// The file [`publish_endpoint`] writes the effective mgmt URL to, next to `mgmt-token`.
const ENDPOINT_FILE: &str = "mgmt-endpoint";
/// The port the management API actually bound, recorded once by [`publish_endpoint`].
static EFFECTIVE_PORT: std::sync::OnceLock<u16> = std::sync::OnceLock::new();
/// The mgmt port this process is serving on, or `0` when there is no management API at all — the
/// standalone `punktfunk1-host` binary, which never calls [`publish_endpoint`].
///
/// The native handshake reads this to put the port in every session's `Welcome`, so a client learns
/// it over the connection it has already authenticated instead of needing the mDNS advert. Resolved
/// ONCE, from the same value the endpoint file carries, so the wire, the file and the advert cannot
/// disagree — the whole point of this being a lookup rather than a fourth place to compute a port.
///
/// ⚠ `0` matters: advertising 47990 from a host with no mgmt API would point clients at a port
/// nothing is listening on, which is strictly worse than saying nothing and letting them fall back.
pub fn effective_port() -> u16 {
EFFECTIVE_PORT.get().copied().unwrap_or(0)
}
/// Publish the mgmt API's *effective* loopback URL to `<config-dir>/mgmt-endpoint`, in the same
/// `KEY=VALUE` form as `mgmt-token` so the bundled console can source it directly as a systemd
/// `EnvironmentFile` (and `windows::service::spawn_web` can read it with `read_env_file_value`).
///
/// **Why this exists:** the port used to be a literal `47990` in five places — this constant, the
/// Windows service's console launch, `scripts/punktfunk-web.service`, the NixOS module, and the
/// console's own default. Moving the listener therefore silently broke the console, because nothing
/// downstream had any way to learn the new port. Now the host is the single source of truth and
/// publishes what it actually bound; consumers keep a 47990 fallback purely so an OLD host with a
/// NEW console still works.
///
/// Always loopback, never `bind`'s own address: the console proxies over loopback by design (see
/// the module docs — the bearer-token admin surface is confined to loopback peers), so a wide
/// `0.0.0.0` bind must not be echoed here as a LAN URL.
///
/// Best-effort: a console that cannot read this simply falls back to 47990, which is strictly what
/// it did before, so a write failure must not stop the host from serving.
pub fn publish_endpoint(bind: SocketAddr) {
// Record it for [`effective_port`] BEFORE the write: the native handshake reads that to put the
// port in every Welcome, and a failed file write must not also cost us the in-band answer.
let _ = EFFECTIVE_PORT.set(bind.port());
let dir = pf_paths::config_dir();
if let Err(e) = pf_paths::create_private_dir(&dir) {
tracing::warn!(error = %e, "could not create the config dir to publish the mgmt endpoint");
return;
}
match write_endpoint(&dir, bind.port()) {
Ok(path) => {
tracing::debug!(path = %path.display(), port = bind.port(), "published mgmt endpoint")
}
Err(e) => tracing::warn!(
dir = %dir.display(),
error = %e,
"could not publish the mgmt endpoint — a console on another port will fall back to 47990"
),
}
}
/// The IO half of [`publish_endpoint`], taking the directory so it is testable without touching
/// `PUNKTFUNK_CONFIG_DIR` (which every other test in this process shares).
///
/// Deliberately NOT `pf_paths::write_secret_file`: this is not a secret — the same port is already
/// in the mDNS TXT record — and locking it to SYSTEM/Administrators on Windows would keep a
/// user-session console from reading the very thing it is published for. The 0700 config dir is the
/// access control that matters.
fn write_endpoint(dir: &std::path::Path, port: u16) -> std::io::Result<std::path::PathBuf> {
let path = dir.join(ENDPOINT_FILE);
// Write-then-rename rather than a plain truncating write: the console's systemd unit may source
// this file at any moment, including while the host is restarting and rewriting it. A torn read
// would hand systemd an EMPTY `PUNKTFUNK_MGMT_URL`, which is worse than a missing file — the
// built-in default only applies to an UNSET variable, not a set-but-blank one. `rename` over an
// existing path is atomic on Unix and replaces on Windows, so a reader sees old or new, never
// half. (The consumers treat blank as unset too — this is the belt to that pair of braces.)
let tmp = dir.join(format!("{ENDPOINT_FILE}.tmp"));
std::fs::write(&tmp, endpoint_line(port))?;
std::fs::rename(&tmp, &path)?;
Ok(path)
}
/// The published line. Must stay valid as BOTH a systemd `EnvironmentFile` entry and input to
/// `windows::service::read_env_file_value` — i.e. exactly one `KEY=VALUE` line, no quoting, and no
/// `=` inside the value (a URL has none).
fn endpoint_line(port: u16) -> String {
format!("PUNKTFUNK_MGMT_URL=https://127.0.0.1:{port}\n")
}
/// Management server options (CLI: `serve --mgmt-bind ADDR --mgmt-token TOKEN`).
#[derive(Clone, Debug)]
pub struct Options {
+36
View File
@@ -1,6 +1,42 @@
//! Handler + auth tests for the management API, exercised through `app()`. Split out of the
//! `mgmt` facade (plan §W5).
/// The published endpoint line has to satisfy TWO parsers written independently: systemd
/// (`EnvironmentFile=`) and `windows::service::read_env_file_value`. This pins the shape both need
/// — one `KEY=VALUE` line — and re-implements the Windows reader's split, so a change to the format
/// fails here rather than silently pointing the console at the wrong port on the one platform CI
/// cannot exercise.
#[test]
fn published_endpoint_line_parses_the_way_both_consumers_read_it() {
let dir = std::env::temp_dir().join(format!(
"pf-mgmt-endpoint-{}-{:p}",
std::process::id(),
&0u8 as *const u8
));
std::fs::create_dir_all(&dir).unwrap();
let path = super::write_endpoint(&dir, 47991).unwrap();
assert_eq!(path.file_name().unwrap(), super::ENDPOINT_FILE);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47991\n");
// `read_env_file_value`'s exact logic: first non-blank line, split once on '=', take the value.
let line = contents
.lines()
.find(|l| !l.trim().is_empty())
.unwrap()
.trim();
let value = line.split_once('=').map_or(line, |(_, v)| v).trim();
assert_eq!(value, "https://127.0.0.1:47991");
// The value must survive that split intact — i.e. carry no '=' of its own.
assert!(!value.contains('='));
// Loopback whatever the listener binds: the console proxies over loopback by design, so a wide
// 0.0.0.0 bind must never be echoed here as a LAN URL.
assert!(value.starts_with("https://127.0.0.1:"));
let _ = std::fs::remove_dir_all(&dir);
}
use super::*;
use crate::encode::Codec;
#[cfg(feature = "gamestream")]
@@ -658,9 +658,14 @@ pub(super) async fn negotiate(
} else {
0
},
// Where this host serves its game library, so the client never has to have seen an mDNS
// advert to find it. `0` on the standalone punktfunk1-host binary (no management API),
// and the client then keeps its compiled-in default.
mgmt_port: crate::mgmt::effective_port(),
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
// pre-cipher wire form. The host's own data plane picks the cipher up via
// pre-cipher wire form — unless a mgmt port rides along, which forces the cipher
// placeholder (see `Welcome::encode`). The host's own data plane picks the cipher up via
// `welcome.session_config` — no other host change.
cipher: if chacha {
punktfunk_core::quic::CIPHER_CHACHA20_POLY1305
+10 -1
View File
@@ -1063,13 +1063,22 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| read_env_file_value(&data.join("web-password")));
// The mgmt URL resolves env-over-file too, for the same reason the token does — except the file
// here is written by `mgmt::publish_endpoint` on every `serve`, so a host moved off 47990 (a
// Sunshine fork owns that port as its web UI) carries the console with it instead of leaving it
// proxying to a port nothing is listening on. The literal stays only as the last-resort default.
let mgmt_url = std::env::var("PUNKTFUNK_MGMT_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| read_env_file_value(&data.join("mgmt-endpoint")))
.unwrap_or_else(|| "https://127.0.0.1:47990".into());
let mut overrides: Vec<(&str, String)> = vec![
("PORT", "47992".into()),
("HOST", "0.0.0.0".into()),
// The /api proxy hop to the host's loopback HTTPS mgmt API. The host's self-signed cert is
// accepted only inside the proxy code (per-request TLS), never process-wide.
("PUNKTFUNK_MGMT_URL", "https://127.0.0.1:47990".into()),
("PUNKTFUNK_MGMT_URL", mgmt_url),
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure.
(
"PUNKTFUNK_UI_TLS_CERT",
+2
View File
@@ -195,6 +195,7 @@ it — leave it or delete it, it makes no difference.
|---|---|---|
| `PUNKTFUNK_HOST_NAME` | free text, e.g. `Living Room` | The name this host shows up under in Moonlight and in the Punktfunk clients. Default: the machine's own hostname — so a box called `bazzite-htpc` can present itself as `Living Room` without renaming the machine. Takes effect on host restart. Spaces and accents are fine; `.` becomes `-` (a dot would split the name in client lists) and it's capped at 63 characters. The machine's real hostname is still what the host answers to on the network. |
| `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. |
| `PUNKTFUNK_NATIVE_PORT` | port *(default: `9777`)* | The native punktfunk/1 (QUIC) control port clients connect on — same as `serve --native-port`, which overrides it. Clients discover the port over mDNS, and a host you added by hand keeps whatever port you added it with, so moving this needs no change on the client. A value that isn't a port is a startup error rather than a silent fall back to 9777. |
| `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. |
| `PUNKTFUNK_IDLE_TIMEOUT_MS` | ms (default `8000`) | How long the host waits before declaring a client that vanished (cable pulled, Wi-Fi dropped) gone — which is when a kept virtual display starts its linger. Lower it (e.g. `3000`) to reclaim displays sooner; it's clamped to ≥1 s and the keep-alive scales with it, so a live session never false-disconnects. A deliberate quit is instant regardless. Same as `--idle-timeout-ms` on `punktfunk1-host`. |
| `PUNKTFUNK_JUMBO` | `1` | Stream in **jumbo frames** — ~9000-byte packets instead of the standard ~1500-byte ones, so a high-bitrate session spends less CPU and per-packet overhead on a wired LAN. Off by default, and safe to turn on: see the note below the table. |
@@ -217,6 +218,7 @@ it — leave it or delete it, it makes no difference.
| `PUNKTFUNK_MGMT_TOKEN` | token | Bearer token for the management API. If unset it's auto-generated and persisted to `~/.config/punktfunk/mgmt-token` (the bundled web console sources it). Set only to pin a specific token. |
| `PUNKTFUNK_UI_PASSWORD` | password | Web-console login password. Normally generated on first start and stored in `~/.config/punktfunk/web-password` — see [Forgot your Password?](/docs/forgot-password). |
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console reads it from `~/.config/punktfunk/mgmt-endpoint`, which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
+32
View File
@@ -29,6 +29,38 @@ and capture/display glitches.
If you only want to try Punktfunk without removing the other host, at least make sure the other
host is fully **stopped** first (they cannot both run at once).
### If you must run both anyway
Still unsupported, and you are on your own for the parts below — but if you keep Punktfunk's
GameStream compat **off** (the default), the overlap narrows to two things you can move.
1. **The port.** With compat off, the Punktfunk *host* binds only UDP 9777, UDP 5353 and TCP
**47990** — the web console is a separate service on 47992/47993, which nothing else wants — and
47990 is the only one the other host wants, as its web UI. Whoever starts first takes it; the
loser is not symmetric, because Punktfunk treats the failure as fatal and exits (the streaming
plane goes with the console), while Sunshine merely loses its config UI. That is why it can look
like it "worked until one day it didn't" — it is a boot race, not a setting. Move ours:
```sh
# ~/.config/punktfunk/host.env
PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
```
Nothing else needs changing: clients learn the port from discovery, and the web console reads it
from `~/.config/punktfunk/mgmt-endpoint`, which the host rewrites on every start. A host added
manually **by IP address** is the exception — it assumes 47990 and its library will stop loading,
so re-add it from discovery. (You can move the other host instead: Sunshine and its forks derive
every port from one base setting.)
2. **The display, on Windows.** Punktfunk defaults to an *exclusive* topology — while streaming it
disables the other displays so its virtual one is the whole desktop, and re-asserts that every
two seconds. Apollo-family forks are virtual-display-driven, so their monitor is what gets
switched off, repeatedly. Set `PUNKTFUNK_NO_ISOLATE=1`, or pick a different topology in the web
console, before blaming the other host.
To see who currently holds the port: `ss -lptn 'sport = :47990'` on Linux,
`netstat -ano | findstr :47990` on Windows.
## The host isn't found on the network
- Make sure the host is actually running — on Linux `systemctl --user status punktfunk-host` (or you
+26 -1
View File
@@ -105,7 +105,16 @@
// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
// client-local: nothing new goes on the wire — the width is computed from frame indices the client
// already receives — so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 19
// v20: `punktfunk_connection_mgmt_port` — reads the host's management-API port out of the
// session's `Welcome`, so a client can find the game library WITHOUT mDNS. The port previously
// existed only in the host's mDNS TXT, which made a host that had moved it off 47990 (the
// supported way to share a machine with a Sunshine fork, whose web UI owns that port) reachable
// only where multicast worked — over a VPN, a routed subnet, or for a host added by IP, the
// library silently fell back to a port nothing was listening on. A NEW symbol, not a widened one:
// every existing function keeps its signature and behaviour, and an embedder that never calls it
// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 20
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -3125,6 +3134,22 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The host's management-API port, from this session's `Welcome` — where its game library is
// served (distinct from the streaming ports). `0` means the host did not advertise one: an older
// host, or the standalone `punktfunk1-host` binary, which has no management API. Treat `0` as
// "unknown" and fall back to your own default (47990), never as a port to dial.
//
// This exists so a client does NOT need mDNS to find the library. The port used to live only in
// the host's mDNS TXT, so a host that had moved it off 47990 — the supported way to coexist with
// a Sunshine fork, whose web UI owns that port — was reachable only where multicast worked. Read
// this after connect and prefer it over any cached or default value. Safe any time after connect.
//
// # Safety
// `c` is a valid connection handle; `port` is writable (NULL is skipped).
PunktfunkStatus punktfunk_connection_mgmt_port(const PunktfunkConnection *c, uint16_t *port);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The host capability bitfield the session's `Welcome` carried — a bitfield of
// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD` /
+8 -1
View File
@@ -649,7 +649,11 @@ in
# below went on promising the behaviour it removes.
unitConfig.StartLimitIntervalSec = 0;
environment = {
PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990";
# PUNKTFUNK_MGMT_URL is deliberately absent: the host publishes the port it actually bound
# to ~/.config/punktfunk/mgmt-endpoint (mgmt::publish_endpoint), sourced below, and the
# server falls back to https://127.0.0.1:47990 on its own when that file does not exist.
# Hardcoding it here would have to out-rank the file, which is a directive-ordering
# question in the generated unit — so we simply do not create the conflict.
PORT = "47992";
HOST = "0.0.0.0";
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
@@ -663,6 +667,9 @@ in
EnvironmentFile = [
"%h/.config/punktfunk/mgmt-token"
"-%h/.config/punktfunk/web-password"
# The host's effective mgmt URL — see the `environment` note above. Optional: absent on
# a host predating mgmt::publish_endpoint, and the server default covers that.
"-%h/.config/punktfunk/mgmt-endpoint"
];
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
# `always`, not `on-failure`: a console that exits 0 has still stopped serving, and
+15
View File
@@ -21,6 +21,21 @@
# ship a `punktfunk-gamestream` firewalld service / ufw profile for exactly this).
#PUNKTFUNK_GAMESTREAM=1
# Where the management API listens (default 0.0.0.0:47990). Two uses:
# * 127.0.0.1:47990 keeps it off the LAN — at the cost of paired clients browsing your library.
# * MOVING THE PORT is how you share a machine with Sunshine/Apollo/Vibeshine: 47990 is their web
# UI as well as our management API, and with PUNKTFUNK_GAMESTREAM off it is the ONLY port the
# two still share. Nothing else needs editing — clients learn the port from discovery and the
# web console reads it from ~/.config/punktfunk/mgmt-endpoint, which the host rewrites on start.
# Running two Moonlight-compatible hosts at once is still unsupported; see the troubleshooting
# page. On Windows also see PUNKTFUNK_NO_ISOLATE — the display topology is the second conflict.
#PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
# The native punktfunk/1 (QUIC) control port clients connect on. Default 9777. Clients discover it
# over mDNS, and a host added by hand keeps whatever port it was added with, so moving this is safe
# on both sides. A typo here is a startup ERROR rather than a silent fall back to 9777.
#PUNKTFUNK_NATIVE_PORT=9778
# Video source (GameStream/Moonlight sessions only): `virtual` creates a per-client virtual
# output at the client's exact resolution+refresh (the flagship mode, and the default);
# `portal` captures an existing monitor.
+9 -1
View File
@@ -25,7 +25,15 @@ Type=simple
# creates it first, but a manual operator may inject PUNKTFUNK_UI_PASSWORD another way).
EnvironmentFile=%h/.config/punktfunk/mgmt-token
EnvironmentFile=-%h/.config/punktfunk/web-password
Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
# The host's ACTUAL mgmt port: `serve` writes this file (mgmt::publish_endpoint) with the port it
# really bound, so moving the listener — the fix for sharing a box with a Sunshine fork, which owns
# 47990 as its web UI — needs no edit here. Optional ('-'): an older host never wrote it, and the
# server's own built-in default (https://127.0.0.1:47990, util/auth.ts) then applies unchanged.
#
# Deliberately NOT paired with an `Environment=PUNKTFUNK_MGMT_URL=` default line: whether a file or
# an Environment= assignment wins is a question of directive order, and the answer differs between
# this hand-written unit and the one the NixOS module generates. One source, no precedence puzzle.
EnvironmentFile=-%h/.config/punktfunk/mgmt-endpoint
Environment=PORT=47992
Environment=HOST=0.0.0.0
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
+6
View File
@@ -12,6 +12,12 @@ PUNKTFUNK_UI_PASSWORD=change-me
# Management API the console proxies to. It serves HTTPS (the host's own identity cert) and
# requires auth (mTLS or the bearer below). Keep this loopback — the login-gated web server is
# the only path to it.
#
# ON A PACKAGED INSTALL YOU DO NOT SET THIS. The host writes the port it actually bound to
# ~/.config/punktfunk/mgmt-endpoint in this same KEY=VALUE form, and the shipped units source that
# file — so a host moved off 47990 (PUNKTFUNK_MGMT_BIND, e.g. to coexist with a Sunshine fork whose
# web UI owns that port) carries the console with it. This line is for dev, where you run the two
# halves by hand. Setting it explicitly always wins over the file.
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
# REQUIRED: bearer token for the management API, injected server-side by the /api proxy (never
+7 -1
View File
@@ -105,7 +105,13 @@ export function uiPassword(): string {
* loopback hop via Bun's per-request `tls` option (routes/api/[...].ts, util/forward.ts). There is
* deliberately no process-wide NODE_TLS_REJECT_UNAUTHORIZED see .env.example. */
export function mgmtUrl(): string {
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
// Blank counts as UNSET, which `??` alone would not do. On a packaged install this value comes
// from ~/.config/punktfunk/mgmt-endpoint (written by the host's `serve` with the port it really
// bound, so a host moved off 47990 to coexist with a Sunshine fork carries the console with it),
// sourced as a systemd EnvironmentFile. An empty or truncated file would otherwise set the
// variable to "" and send every proxy hop to a URL that cannot parse.
const url = process.env.PUNKTFUNK_MGMT_URL?.trim();
return url ? url : "https://127.0.0.1:47990";
}
/** Bearer token for the management API, injected server-side. */
+7
View File
@@ -41,6 +41,13 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi
set "PORT=47992"
set "HOST=0.0.0.0"
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
rem ...unless the host published a different one. `serve` writes mgmt-endpoint in the same single
rem KEY=VALUE form as the token above, carrying the port it ACTUALLY bound - so a host moved off
rem 47990 (PUNKTFUNK_MGMT_BIND, e.g. to share the box with a Sunshine fork whose web UI owns that
rem port) brings the console with it. Imported AFTER the default so it wins; absent on an older host,
rem and then the default above stands.
set "ENDPOINTFILE=%PFDATA%\mgmt-endpoint"
if exist "%ENDPOINTFILE%" for /f "usebackq tokens=1* delims==" %%A in ("%ENDPOINTFILE%") do set "%%A=%%B"
rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.