diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 80da2fb..bd0b62d 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -243,3 +243,8 @@ nvenc = ["dep:nvidia-video-codec-sdk"] # so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the # FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`. amf-qsv = ["dep:ffmpeg-next"] + +# Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging +# builds of this crate never execute the winresource block). +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" diff --git a/crates/punktfunk-host/build.rs b/crates/punktfunk-host/build.rs index d6013ad..8760a98 100644 --- a/crates/punktfunk-host/build.rs +++ b/crates/punktfunk-host/build.rs @@ -17,4 +17,21 @@ fn main() { .unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into())); println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}"); println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION"); + + // Windows identity resources: the branded icon + version info. Task Manager / Explorer show a + // process by its version-info FileDescription — without one the host appears as a bare + // "punktfunk-host.exe" with no icon. Same winresource pattern as clients/windows and + // punktfunk-tray (cfg(windows) = build HOST, so Linux packaging builds skip it; CARGO_CFG_WINDOWS + // = TARGET). + #[cfg(windows)] + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + let icon = "../../packaging/windows/branding/punktfunk.ico"; + println!("cargo:rerun-if-changed={icon}"); + winresource::WindowsResource::new() + .set_icon_with_id(icon, "1") + .set("FileDescription", "Punktfunk Host") + .set("ProductName", "Punktfunk") + .compile() + .expect("embed windows icon/version resources"); + } } diff --git a/crates/punktfunk-host/src/windows/service.rs b/crates/punktfunk-host/src/windows/service.rs index 17d987a..fd61b0e 100644 --- a/crates/punktfunk-host/src/windows/service.rs +++ b/crates/punktfunk-host/src/windows/service.rs @@ -57,7 +57,7 @@ use windows::Win32::System::Threading::{ /// SCM service name (the key under HKLM\SYSTEM\CurrentControlSet\Services). Stable identity. const SERVICE_NAME: &str = "PunktfunkHost"; -const SERVICE_DISPLAY: &str = "punktfunk streaming host"; +const SERVICE_DISPLAY: &str = "Punktfunk Host"; const SERVICE_DESCRIPTION: &str = "Low-latency desktop/game streaming host. Launches the punktfunk host into the active session."; @@ -302,6 +302,10 @@ fn run_service() -> Result<()> { .context("set RUNNING")?; tracing::info!("punktfunk service started — supervising host in the active console session"); + // Best-effort: warn if this network is Public (streaming ports are firewalled off there unless + // the operator opted in). Own thread — a slow `Get-NetConnectionProfile` never delays the host. + std::thread::spawn(warn_if_public_network); + load_host_env(); let result = supervise(stop, session); @@ -683,7 +687,14 @@ fn install(args: &[String]) -> Result<()> { if let Some(on) = gamestream { apply_gamestream_choice(on); } - add_firewall_rules(); + // Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too. + // Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently — + // remove any prior rules first so an upgrade tightens the scope instead of leaving a stale + // all-profiles rule behind the new one. + let allow_public = allow_public_network(args); + set_fw_public_marker(allow_public); + remove_firewall_rules(); + add_firewall_rules(allow_public); println!( "\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start", @@ -839,8 +850,28 @@ fn apply_gamestream_choice(enable: bool) { // ── firewall + sc helpers ──────────────────────────────────────────────────────────────────────── +/// The `netsh` `profile=` scope for punktfunk's inbound rules. Default = **Domain + Private** — the +/// trusted-network profiles punktfunk is meant to run on; `allow_public` widens it to **all profiles +/// including Public** (untrusted networks like café/hotel Wi-Fi — opt-in only). Shared with the +/// web-console rule in `install.rs` so both surfaces scope the same way. +pub(crate) fn firewall_profile_arg(allow_public: bool) -> &'static str { + if allow_public { + "profile=any" + } else { + "profile=domain,private" + } +} + +/// The `--allow-public-network` install opt-in (the installer's "Allow connections on Public +/// networks" task forwards it). Absent = the secure default (Domain + Private only). +pub(crate) fn allow_public_network(args: &[String]) -> bool { + args.iter().any(|a| a == "--allow-public-network") +} + /// Inbound firewall rules for the streaming ports (best-effort; logs but never fails the install). -fn add_firewall_rules() { +/// Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when `allow_public`. +fn add_firewall_rules(allow_public: bool) { + let profile = firewall_profile_arg(allow_public); // (name suffix, protocol, ports) let rules = [ ("TCP", "TCP", "47984,47989,48010,47990"), @@ -860,14 +891,22 @@ fn add_firewall_rules() { "action=allow", &format!("protocol={proto}"), &format!("localport={ports}"), + profile, ], ); if ok { - println!("Firewall rule added: {name} ({ports})"); + println!("Firewall rule added: {name} ({ports}) [{profile}]"); } else { eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)"); } } + if !allow_public { + println!( + "Note: streaming ports are open on Private/Domain networks only. On a network Windows \ + classifies as Public, clients won't connect — set that network to Private, or reinstall \ + with the 'Allow connections on Public networks' option." + ); + } } fn remove_firewall_rules() { @@ -886,6 +925,62 @@ fn remove_firewall_rules() { } } +/// Marker file recording that the operator opted into opening the firewall on **Public** networks +/// (`--allow-public-network`). Its presence suppresses the startup Public-network warning (they made +/// an informed choice); absence = the secure default. +fn fw_public_marker() -> std::path::PathBuf { + crate::gamestream::config_dir().join("fw-allow-public") +} + +/// Record (or clear) the Public-firewall opt-in marker to match this install's choice. +fn set_fw_public_marker(allow_public: bool) { + let path = fw_public_marker(); + if allow_public { + let _ = std::fs::write(&path, b"1\n"); + } else { + let _ = std::fs::remove_file(&path); + } +} + +/// Best-effort: is any active network connection classified **Public** by Windows? Uses +/// `Get-NetConnectionProfile` (per-interface category: Public / Private / DomainAuthenticated). +/// `None` when it can't be determined — the caller then skips the warning. +fn active_network_is_public() -> Option { + let out = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-NetConnectionProfile).NetworkCategory", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout); + Some(s.lines().any(|l| l.trim().eq_ignore_ascii_case("Public"))) +} + +/// One-shot startup diagnostic: if the operator did NOT opt into Public networks and this machine's +/// current network is classified Public, the streaming ports are firewalled off there — turn that +/// silent "clients can't connect" into an actionable WARN. Best-effort; meant to run on its own +/// thread so it never delays the host launch. +fn warn_if_public_network() { + if fw_public_marker().exists() { + return; // operator opted into Public — their informed choice, no warning + } + if active_network_is_public() == Some(true) { + tracing::warn!( + "this machine's current network is classified Public (an untrusted-network profile), so \ + punktfunk's streaming ports are firewalled off here and clients on this network can't \ + reach the host. Fix: set the network to Private (Windows Settings > Network > \ + properties) — or, only for a network you trust, reinstall with the 'Allow connections \ + on Public networks' option." + ); + } +} + /// Run an `sc.exe` command, passing its output through (used by start/stop/status). fn sc(args: &[&str]) -> Result<()> { let status = std::process::Command::new("sc") diff --git a/crates/punktfunk-tray/build.rs b/crates/punktfunk-tray/build.rs index a442d62..a18b39e 100644 --- a/crates/punktfunk-tray/build.rs +++ b/crates/punktfunk-tray/build.rs @@ -22,6 +22,9 @@ fn main() { println!("cargo:rerun-if-changed={path}"); res.set_icon_with_id(path, id); } + // Task Manager / Explorer identity (matches the host's "Punktfunk Host"). + res.set("FileDescription", "Punktfunk Tray"); + res.set("ProductName", "Punktfunk"); res.compile().expect("embed windows icon resources"); } } diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 3c63036..37519ff 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -112,7 +112,9 @@ SetupIconFile={#BrandingDir}\punktfunk.ico WizardImageFile={#BrandingDir}\wizard-image-*.bmp WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp UninstallDisplayName=punktfunk host {#MyAppVersion} -; The branded multi-size .ico (installed below) - the host exe embeds no icon resource. +; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a +; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file +; copy stays as the uninstall-entry icon. UninstallDisplayIcon={app}\punktfunk.ico [Languages]