feat(host): PUNKTFUNK_HOST_NAME names the host in Moonlight and the clients
A box called `bazzite-htpc` had no way to present itself as "Living Room" short of renaming the machine. The new knob overrides the name everywhere a human sees it: the GameStream serverinfo <hostname> element and the mDNS service instance name both adverts carry. Unset (the default) is the machine's own hostname, exactly as before. Free text is the point, so the DNS-level name is now a separate concern from the display name. The instance label may contain spaces and accents; an A-record target may not, and mdns-sd rejects the whole ServiceInfo if the target is not a legal name — which would take discovery down rather than merely look wrong. dns_label() sanitizes the target and passes an already-legal name through byte-for-byte, so hosts without the override advertise precisely what they always did. The display name loses `.` (it would split the label, and clients derive the name as the first label of the fullname, so "Ben's PC v1.2" would arrive as "Ben's PC v1") and is capped at the 63-byte DNS-SD ceiling on a char boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit bbf72261a12e0e67601f549d40db65ae61979268)
This commit is contained in:
@@ -61,6 +61,12 @@ pub fn env_on(name: &str) -> Option<bool> {
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostConfig {
|
||||
/// `PUNKTFUNK_HOST_NAME` — the name this host shows up under in Moonlight (the serverinfo
|
||||
/// `<hostname>` element) and in Punktfunk's own clients (the mDNS service *instance* name both
|
||||
/// adverts carry). Unset/blank = the machine's own hostname, which is what it always was. Free
|
||||
/// 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_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||
pub encoder_pref: String,
|
||||
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
|
||||
@@ -159,6 +165,9 @@ impl HostConfig {
|
||||
// (`PUNKTFUNK_IDD_PUSH` was removed: IDD-push is the sole Windows capture path, so the knob
|
||||
// only split dispatch — capture ignored it while the vdisplay manager obeyed it, and `=0`
|
||||
// produced dead-swap-chain reuse on reconnect. A stale setting in an old host.env is ignored.)
|
||||
host_name: val("PUNKTFUNK_HOST_NAME")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
|
||||
@@ -51,6 +51,33 @@ fn mdns_off_value(s: &str) -> bool {
|
||||
/// Wire protocol id advertised in the `proto` TXT record.
|
||||
pub const NATIVE_PROTO: &str = "punktfunk/1";
|
||||
|
||||
/// The DNS label a display name advertises its A record under (`<label>.local.`), which is a
|
||||
/// different thing from the service *instance* name: an instance name may be free text
|
||||
/// ("Living Room PC" — see `PUNKTFUNK_HOST_NAME`), a host name may not, and `mdns-sd` rejects the
|
||||
/// whole `ServiceInfo` if the target isn't a legal name — which would take discovery down entirely
|
||||
/// rather than just look wrong. Anything outside `[A-Za-z0-9.-]` becomes `-`; a name that is
|
||||
/// already legal (every machine hostname, i.e. every host without the override) passes through
|
||||
/// byte-for-byte, so this changes nothing for hosts that don't set the knob.
|
||||
pub(crate) fn dns_label(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in name.trim().chars() {
|
||||
if out.len() >= 63 {
|
||||
break;
|
||||
}
|
||||
out.push(if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
});
|
||||
}
|
||||
let out = out.trim_matches(['-', '.']).to_string();
|
||||
if out.is_empty() {
|
||||
"punktfunk-host".to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
@@ -70,7 +97,9 @@ pub fn advertise_native(
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let host_name = format!("{hostname}.local.");
|
||||
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
|
||||
// has to be a legal DNS name, hence the separate sanitized label.
|
||||
let host_name = format!("{}.local.", dns_label(hostname));
|
||||
let mut props: HashMap<String, String> = HashMap::new();
|
||||
props.insert("proto".into(), NATIVE_PROTO.into());
|
||||
props.insert("fp".into(), fingerprint.to_string());
|
||||
@@ -116,7 +145,24 @@ pub fn advertise_native(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mdns_off_value;
|
||||
use super::{dns_label, mdns_off_value};
|
||||
|
||||
#[test]
|
||||
fn dns_label_passes_machine_names_through_and_tames_display_names() {
|
||||
// Every host WITHOUT PUNKTFUNK_HOST_NAME must advertise exactly what it did before.
|
||||
for plain in ["bazzite-htpc", "DESKTOP-1A2B3C", "box.lan", "steamdeck"] {
|
||||
assert_eq!(dns_label(plain), plain);
|
||||
}
|
||||
// A free-text display name becomes a legal label instead of poisoning the record.
|
||||
assert_eq!(dns_label("Living Room PC"), "Living-Room-PC");
|
||||
assert_eq!(dns_label(" Ben's Rig! "), "Ben-s-Rig");
|
||||
assert_eq!(dns_label("Wohnzimmer-PC ☕"), "Wohnzimmer-PC");
|
||||
// Degenerate input still yields something registerable.
|
||||
assert_eq!(dns_label("***"), "punktfunk-host");
|
||||
assert_eq!(dns_label(""), "punktfunk-host");
|
||||
// DNS caps a label at 63 bytes.
|
||||
assert!(dns_label(&"a".repeat(200)).len() <= 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mdns_off_grammar() {
|
||||
|
||||
@@ -13,7 +13,9 @@ pub struct Advert {
|
||||
|
||||
pub fn advertise(host: &Host) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let host_name = format!("{}.local.", host.hostname);
|
||||
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
|
||||
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
|
||||
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
|
||||
@@ -395,7 +395,15 @@ pub fn serve(
|
||||
})
|
||||
}
|
||||
|
||||
/// The name this host shows up under everywhere a human sees it: Moonlight's host tile (the
|
||||
/// serverinfo `<hostname>` element) and Punktfunk's own client lists (the mDNS service *instance*
|
||||
/// name of both adverts). `PUNKTFUNK_HOST_NAME` wins — that's the point of the knob, a box whose
|
||||
/// machine name is `bazzite-htpc` can present itself as "Living Room" — otherwise it's the machine's
|
||||
/// own hostname, as it always was.
|
||||
fn hostname_string() -> String {
|
||||
if let Some(n) = pf_host_config::config().host_name.as_deref() {
|
||||
return sanitize_display_name(n);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(n) = std::env::var_os("COMPUTERNAME") {
|
||||
let s = n.to_string_lossy().trim().to_string();
|
||||
@@ -410,6 +418,34 @@ fn hostname_string() -> String {
|
||||
.unwrap_or_else(|| "punktfunk-host".to_string())
|
||||
}
|
||||
|
||||
/// Make an operator-supplied host name safe to carry as an mDNS service instance name. Spaces and
|
||||
/// punctuation are fine there ("Living Room PC" is a perfectly legal instance name), but two things
|
||||
/// are not: a `.` splits the instance label — and clients derive the display name as the first label
|
||||
/// of the fullname (`pf-client-core::discovery`), so "Ben's PC v1.2" would arrive as "Ben's PC v1" —
|
||||
/// and DNS-SD caps a label at 63 bytes. Control characters go too.
|
||||
fn sanitize_display_name(raw: &str) -> String {
|
||||
let cleaned: String = raw
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.map(|c| if c == '.' { '-' } else { c })
|
||||
.collect();
|
||||
// Truncate on a char boundary so multi-byte names can't produce invalid UTF-8.
|
||||
let mut out = String::new();
|
||||
for c in cleaned.trim().chars() {
|
||||
if out.len() + c.len_utf8() > 63 {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
let out = out.trim().to_string();
|
||||
if out.is_empty() {
|
||||
"punktfunk-host".to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it.
|
||||
fn load_or_create_uniqueid() -> Result<String> {
|
||||
let path = pf_paths::config_dir().join("uniqueid");
|
||||
@@ -489,6 +525,30 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_name_tests {
|
||||
use super::sanitize_display_name;
|
||||
|
||||
/// The display name rides the mDNS service INSTANCE label, and clients read it back as the
|
||||
/// first label of the fullname — so a `.` truncates the name in every client list. Split from
|
||||
/// the env read: `PUNKTFUNK_HOST_NAME` is process-global and must not race the parallel suite.
|
||||
#[test]
|
||||
fn display_name_survives_free_text_but_loses_the_label_breakers() {
|
||||
assert_eq!(sanitize_display_name("Living Room PC"), "Living Room PC");
|
||||
assert_eq!(sanitize_display_name(" Wohnzimmer "), "Wohnzimmer");
|
||||
// A dot would otherwise cut the name short client-side ("Ben's PC v1").
|
||||
assert_eq!(sanitize_display_name("Ben's PC v1.2"), "Ben's PC v1-2");
|
||||
assert_eq!(sanitize_display_name("Küche ☕"), "Küche ☕");
|
||||
assert_eq!(sanitize_display_name("tab\there"), "tabhere");
|
||||
// Never empty — an empty instance name is not registerable.
|
||||
assert_eq!(sanitize_display_name(" "), "punktfunk-host");
|
||||
// 63-byte DNS-SD label ceiling, truncated on a char boundary.
|
||||
let long = sanitize_display_name(&"ü".repeat(100));
|
||||
assert!(long.len() <= 63, "{} bytes", long.len());
|
||||
assert_eq!(long, "ü".repeat(31));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -814,7 +814,11 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
# PUNKTFUNK_HOST_CMD=serve --gamestream\n\
|
||||
\n\
|
||||
# Force a specific render GPU by name substring (multi-GPU boxes only):\n\
|
||||
# PUNKTFUNK_RENDER_ADAPTER=4090\n";
|
||||
# PUNKTFUNK_RENDER_ADAPTER=4090\n\
|
||||
\n\
|
||||
# The name this host shows up under in Moonlight and the Punktfunk clients\n\
|
||||
# (default: the machine's own computer name):\n\
|
||||
# PUNKTFUNK_HOST_NAME=Living Room\n";
|
||||
// Write host.env DACL-locked to SYSTEM/Administrators: it controls the SYSTEM service's
|
||||
// environment + launched command line, so a local user must not be able to read or tamper with
|
||||
// it (security-review 2026-06-28 #3).
|
||||
|
||||
@@ -126,6 +126,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `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_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. |
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# CPU automatically. No need to set it. Set to 0 only to force the CPU path.
|
||||
# PUNKTFUNK_ZEROCOPY=0
|
||||
|
||||
# The name this host shows up under in Moonlight and the Punktfunk clients. Defaults to the
|
||||
# machine's hostname; set it to give the box a friendly name without renaming the machine.
|
||||
#PUNKTFUNK_HOST_NAME=Living Room
|
||||
|
||||
# --- Session anchors (rarely needed) -------------------------------------------------------
|
||||
# As a `systemctl --user` service the host inherits the correct XDG_RUNTIME_DIR from systemd and
|
||||
# derives the bus (`unix:path=$XDG_RUNTIME_DIR/bus`) itself. Set these ONLY when running the host
|
||||
|
||||
@@ -30,6 +30,10 @@ PUNKTFUNK_VDISPLAY=pf
|
||||
# Capture the secure desktop (UAC / lock / login) so the stream survives those transitions.
|
||||
PUNKTFUNK_SECURE_DDA=1
|
||||
|
||||
# The name this host shows up under in Moonlight and the Punktfunk clients. Defaults to the
|
||||
# machine's computer name; set it to give the box a friendly name without renaming Windows.
|
||||
#PUNKTFUNK_HOST_NAME=Living Room
|
||||
|
||||
# Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\.
|
||||
RUST_LOG=info
|
||||
|
||||
|
||||
Reference in New Issue
Block a user