From c42ce88921493bd58bc9b1109458a1342564cf84 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 01:07:26 +0200 Subject: [PATCH] refactor(host/W6.1): extract secret/config-dir helpers into the pf-paths leaf crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second de-coupling for the host crate carve (plan §W6.1 leaf). config_dir / create_private_dir / write_secret_file (+ the Windows DACL helpers) were pub(crate) in the gamestream junk drawer, yet consumed by vdisplay, stats, gpu, library, mgmt_token, native_pairing and the Windows service — many of which become pf-media / pf-vdisplay, for which crate::gamestream would be an illegal upward edge. New leaf crate pf-paths (pure std + tracing) owns them; ~40 call sites across 14 files repoint to pf_paths::. gamestream keeps only its own concerns. Verified: Linux (home-worker-5) clippy -p pf-paths -p punktfunk-host --all-targets -D warnings + tests (347 pass, incl. secrets_are_written_owner_only); Windows (192.168.1.158) clippy --features nvenc,amf-qsv --all-targets green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 8 + Cargo.toml | 1 + crates/pf-paths/Cargo.toml | 13 ++ crates/pf-paths/src/lib.rs | 172 +++++++++++++++++ crates/punktfunk-host/Cargo.toml | 2 + crates/punktfunk-host/src/gamestream/apps.rs | 2 +- crates/punktfunk-host/src/gamestream/cert.rs | 8 +- crates/punktfunk-host/src/gamestream/mod.rs | 181 +----------------- crates/punktfunk-host/src/gpu.rs | 8 +- crates/punktfunk-host/src/hooks.rs | 6 +- crates/punktfunk-host/src/library/art.rs | 4 +- crates/punktfunk-host/src/mgmt_token.rs | 9 +- .../src/native_pairing/store.rs | 6 +- crates/punktfunk-host/src/stats_recorder.rs | 4 +- .../punktfunk-host/src/vdisplay/identity.rs | 4 +- crates/punktfunk-host/src/vdisplay/policy.rs | 12 +- crates/punktfunk-host/src/windows/install.rs | 2 +- .../src/windows/monitor_devnode.rs | 4 +- crates/punktfunk-host/src/windows/service.rs | 20 +- 19 files changed, 248 insertions(+), 218 deletions(-) create mode 100644 crates/pf-paths/Cargo.toml create mode 100644 crates/pf-paths/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index aea56ba0..53b7f526 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2808,6 +2808,13 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "pf-paths" +version = "0.12.0" +dependencies = [ + "tracing", +] + [[package]] name = "pf-presenter" version = "0.12.0" @@ -3122,6 +3129,7 @@ dependencies = [ "opus", "parking_lot", "pf-driver-proto", + "pf-paths", "pipewire", "punktfunk-core", "pyrowave-sys", diff --git a/Cargo.toml b/Cargo.toml index edf8067c..81e61161 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/pf-console-ui", "crates/pf-ffvk", "crates/pf-driver-proto", + "crates/pf-paths", "crates/pyrowave-sys", "clients/probe", "clients/linux", diff --git a/crates/pf-paths/Cargo.toml b/crates/pf-paths/Cargo.toml new file mode 100644 index 00000000..aa1e07d3 --- /dev/null +++ b/crates/pf-paths/Cargo.toml @@ -0,0 +1,13 @@ +# Host config-dir + owner-private file helpers, extracted into a leaf crate so the subsystem crates +# (pf-media, pf-vdisplay) and the orchestrator can share them without depending on the `gamestream` +# module they used to live in (plan §W6). Pure std + tracing; no platform I/O stack. +[package] +name = "pf-paths" +version = "0.12.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Host config-directory resolution + owner-private file/dir creation (0600/0700 or SYSTEM/Admins DACL)." +publish = false + +[dependencies] +tracing = "0.1" diff --git a/crates/pf-paths/src/lib.rs b/crates/pf-paths/src/lib.rs new file mode 100644 index 00000000..c526edf0 --- /dev/null +++ b/crates/pf-paths/src/lib.rs @@ -0,0 +1,172 @@ +//! Host config-dir + owner-private file helpers — a leaf crate so the subsystem crates +//! (`pf-media`, `pf-vdisplay`) and the orchestrator can all reach them WITHOUT depending on the +//! `gamestream` module they used to live in (plan §2.4 / §W6: the secret helpers were shared +//! vocabulary parked above their consumers in the junk drawer). Pure std + `tracing`; no I/O stack. +//! +//! - [`config_dir`] resolves the per-host config directory (XDG / `%ProgramData%`, `PUNKTFUNK_CONFIG_DIR` override). +//! - [`create_private_dir`] makes it owner-private (0700 / restrictive DACL). +//! - [`write_secret_file`] writes an owner-only secret (0600 / SYSTEM+Admins DACL). + +use std::path::PathBuf; + +/// The host config dir (host identity, pairing state, mgmt token, library) — created on demand. +/// Linux: `$XDG_CONFIG_HOME/punktfunk` or `~/.config/punktfunk`. Windows: `%ProgramData%\punktfunk` +/// (machine-wide — the SYSTEM service and the interactive user share ONE dir that survives logout). +/// `PUNKTFUNK_CONFIG_DIR` overrides on both platforms (used by the Windows service config / tests). +pub fn config_dir() -> PathBuf { + if let Some(dir) = std::env::var_os("PUNKTFUNK_CONFIG_DIR").filter(|s| !s.is_empty()) { + return PathBuf::from(dir); + } + // Windows: %ProgramData% (e.g. C:\ProgramData\punktfunk) — machine-wide, SYSTEM-readable, + // persists across user logout, correct for a SYSTEM service. Falls back to %APPDATA% then CWD. + #[cfg(target_os = "windows")] + let base = std::env::var_os("ProgramData") + .or_else(|| std::env::var_os("APPDATA")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + #[cfg(not(target_os = "windows"))] + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("punktfunk") +} + +/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable +/// by other local users via a traversable config path). On Windows, applies a restrictive DACL +/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in +/// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review +/// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too. +pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + let r = std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir); + // `recursive` doesn't re-chmod an existing dir — tighten it so an old 0755 dir gets locked. + if dir.exists() { + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + r + } + #[cfg(not(unix))] + { + let r = std::fs::create_dir_all(dir); + #[cfg(windows)] + restrict_dir_to_system_admins(dir); + r + } +} + +/// Best-effort Windows DACL lockdown of the config *directory* (the companion to +/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users` +/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the +/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts +/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a +/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full +/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing +/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files +/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs +/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal. +#[cfg(windows)] +fn restrict_dir_to_system_admins(dir: &std::path::Path) { + let icacls = std::env::var("SystemRoot") + .map(|r| format!("{r}\\System32\\icacls.exe")) + .unwrap_or_else(|_| "icacls".to_string()); + // Reset ownership of the directory object to Administrators first, so a dir a non-admin may have + // pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning + // the dir itself is what defeats the pre-creation; recursing a large captures tree each call is + // needless churn (secret files are individually owner-locked by `write_secret_file`). + let _ = std::process::Command::new(&icacls) + .arg(dir.as_os_str()) + .args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let status = std::process::Command::new(&icacls) + .arg(dir.as_os_str()) + .args([ + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM + "/grant:r", + "*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators + "/grant:r", + "*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS + "/grant:r", + "*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant) + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match status { + Ok(s) if s.success() => {} + _ => tracing::warn!( + dir = %dir.display(), + "config-dir DACL hardening did not fully succeed — a local user may be able to plant config files" + ), + } +} + +/// Write `contents` to `path` as an **owner-only secret**: created and re-chmod'd **0600** on Unix +/// (never even briefly group/world-readable), and DACL-restricted to SYSTEM/Administrators/owner on +/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used +/// for the host private key and the persisted trust stores so a local unprivileged user can neither +/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing). +pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(path)?; + f.write_all(contents)?; + f.flush()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(windows)] + restrict_to_system_admins(path); + Ok(()) +} + +/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to +/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually +/// running user keeps access). Without this the host key under the default Users-readable +/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs +/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust +/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour). +#[cfg(windows)] +fn restrict_to_system_admins(path: &std::path::Path) { + let icacls = std::env::var("SystemRoot") + .map(|r| format!("{r}\\System32\\icacls.exe")) + .unwrap_or_else(|_| "icacls".to_string()); + let status = std::process::Command::new(icacls) + .arg(path.as_os_str()) + .args([ + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(F)", // NT AUTHORITY\SYSTEM + "/grant:r", + "*S-1-5-32-544:(F)", // BUILTIN\Administrators + "/grant:r", + "*S-1-3-4:(F)", // OWNER RIGHTS + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match status { + Ok(s) if s.success() => {} + _ => tracing::warn!( + path = %path.display(), + "icacls hardening did not succeed — this secret may be readable by other local users" + ), + } +} diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 09674a96..9dca068a 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +# Config-dir + owner-private file helpers (moved out of the gamestream junk drawer, plan §W6). +pf-paths = { path = "../pf-paths" } # M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP). quinn = "0.11" anyhow = "1" diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index a9b93b0c..a2972357 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -28,7 +28,7 @@ pub struct AppEntry { fn config_path() -> Option { // `config_dir()` resolves XDG/HOME on Linux and %APPDATA% on Windows (no HOME needed). - Some(super::config_dir().join("apps.json")) + Some(pf_paths::config_dir().join("apps.json")) } fn parse_compositor(s: &str) -> Option { diff --git a/crates/punktfunk-host/src/gamestream/cert.rs b/crates/punktfunk-host/src/gamestream/cert.rs index 02094ea9..073f310e 100644 --- a/crates/punktfunk-host/src/gamestream/cert.rs +++ b/crates/punktfunk-host/src/gamestream/cert.rs @@ -2,8 +2,8 @@ //! during pairing AND presented as the TLS server cert on 47984 (Moonlight pins it). The //! cert's own X.509 signature bytes are an input to the pairing hashes, so we extract them. -use super::config_dir; use anyhow::{anyhow, Context, Result}; +use pf_paths::config_dir; use rsa::pkcs1v15::SigningKey; use rsa::pkcs8::DecodePrivateKey; use rsa::RsaPrivateKey; @@ -36,11 +36,11 @@ impl ServerIdentity { // The private key is the trust root for EVERY surface (TLS server cert, pairing // signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only // DACL) so a local user can't read it and impersonate the host. The dir is 0700. - super::create_private_dir(&dir).ok(); - super::write_secret_file(&key_path, k.as_bytes()) + pf_paths::create_private_dir(&dir).ok(); + pf_paths::write_secret_file(&key_path, k.as_bytes()) .with_context(|| format!("write {}", key_path.display()))?; // The cert is public (handed to clients), but write it owner-only too for consistency. - super::write_secret_file(&cert_path, c.as_bytes()) + pf_paths::write_secret_file(&cert_path, c.as_bytes()) .with_context(|| format!("write {}", cert_path.display()))?; tracing::info!(path = %cert_path.display(), "generated punktfunk host certificate (RSA-2048, key 0600)"); (c, k) diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 5f7ffa96..c929d892 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -26,7 +26,6 @@ mod video; use anyhow::{Context, Result}; use std::net::{IpAddr, Ipv4Addr, UdpSocket}; -use std::path::PathBuf; use std::sync::Arc; /// nvhttp ports (Moonlight derives all stream ports by offset from the HTTP base 47989). @@ -307,168 +306,6 @@ pub fn serve( }) } -/// The host config dir (host identity, pairing state, mgmt token, library) — created on demand. -/// Linux: `$XDG_CONFIG_HOME/punktfunk` or `~/.config/punktfunk`. Windows: `%ProgramData%\punktfunk` -/// (machine-wide — the SYSTEM service and the interactive user share ONE dir that survives logout). -/// `PUNKTFUNK_CONFIG_DIR` overrides on both platforms (used by the Windows service config / tests). -pub(crate) fn config_dir() -> PathBuf { - if let Some(dir) = std::env::var_os("PUNKTFUNK_CONFIG_DIR").filter(|s| !s.is_empty()) { - return PathBuf::from(dir); - } - // Windows: %ProgramData% (e.g. C:\ProgramData\punktfunk) — machine-wide, SYSTEM-readable, - // persists across user logout, correct for a SYSTEM service. Falls back to %APPDATA% then CWD. - #[cfg(target_os = "windows")] - let base = std::env::var_os("ProgramData") - .or_else(|| std::env::var_os("APPDATA")) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - #[cfg(not(target_os = "windows"))] - let base = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .unwrap_or_else(|| PathBuf::from(".")); - base.join("punktfunk") -} - -/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable -/// by other local users via a traversable config path). On Windows, applies a restrictive DACL -/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in -/// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review -/// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too. -pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; - let r = std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir); - // `recursive` doesn't re-chmod an existing dir — tighten it so an old 0755 dir gets locked. - if dir.exists() { - let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); - } - r - } - #[cfg(not(unix))] - { - let r = std::fs::create_dir_all(dir); - #[cfg(windows)] - restrict_dir_to_system_admins(dir); - r - } -} - -/// Best-effort Windows DACL lockdown of the config *directory* (the companion to -/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users` -/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the -/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts -/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a -/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full -/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing -/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files -/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs -/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal. -#[cfg(windows)] -fn restrict_dir_to_system_admins(dir: &std::path::Path) { - let icacls = std::env::var("SystemRoot") - .map(|r| format!("{r}\\System32\\icacls.exe")) - .unwrap_or_else(|_| "icacls".to_string()); - // Reset ownership of the directory object to Administrators first, so a dir a non-admin may have - // pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning - // the dir itself is what defeats the pre-creation; recursing a large captures tree each call is - // needless churn (secret files are individually owner-locked by `write_secret_file`). - let _ = std::process::Command::new(&icacls) - .arg(dir.as_os_str()) - .args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - let status = std::process::Command::new(&icacls) - .arg(dir.as_os_str()) - .args([ - "/inheritance:r", - "/grant:r", - "*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM - "/grant:r", - "*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators - "/grant:r", - "*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS - "/grant:r", - "*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant) - ]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - match status { - Ok(s) if s.success() => {} - _ => tracing::warn!( - dir = %dir.display(), - "config-dir DACL hardening did not fully succeed — a local user may be able to plant config files" - ), - } -} - -/// Write `contents` to `path` as an **owner-only secret**: created and re-chmod'd **0600** on Unix -/// (never even briefly group/world-readable), and DACL-restricted to SYSTEM/Administrators/owner on -/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used -/// for the host private key and the persisted trust stores so a local unprivileged user can neither -/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing). -pub(crate) fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let mut f = opts.open(path)?; - f.write_all(contents)?; - f.flush()?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - } - #[cfg(windows)] - restrict_to_system_admins(path); - Ok(()) -} - -/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to -/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually -/// running user keeps access). Without this the host key under the default Users-readable -/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs -/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust -/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour). -#[cfg(windows)] -fn restrict_to_system_admins(path: &std::path::Path) { - let icacls = std::env::var("SystemRoot") - .map(|r| format!("{r}\\System32\\icacls.exe")) - .unwrap_or_else(|_| "icacls".to_string()); - let status = std::process::Command::new(icacls) - .arg(path.as_os_str()) - .args([ - "/inheritance:r", - "/grant:r", - "*S-1-5-18:(F)", // NT AUTHORITY\SYSTEM - "/grant:r", - "*S-1-5-32-544:(F)", // BUILTIN\Administrators - "/grant:r", - "*S-1-3-4:(F)", // OWNER RIGHTS - ]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - match status { - Ok(s) if s.success() => {} - _ => tracing::warn!( - path = %path.display(), - "icacls hardening did not succeed — this secret may be readable by other local users" - ), - } -} - fn hostname_string() -> String { #[cfg(target_os = "windows")] if let Some(n) = std::env::var_os("COMPUTERNAME") { @@ -486,7 +323,7 @@ fn hostname_string() -> String { /// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it. fn load_or_create_uniqueid() -> Result { - let path = config_dir().join("uniqueid"); + let path = pf_paths::config_dir().join("uniqueid"); if let Ok(s) = std::fs::read_to_string(&path) { let t = s.trim(); if !t.is_empty() { @@ -496,7 +333,7 @@ fn load_or_create_uniqueid() -> Result { let id = std::fs::read_to_string("/proc/sys/kernel/random/uuid") .map(|u| u.trim().replace('-', "")) .unwrap_or_else(|_| format!("{:016x}{:016x}", std::process::id(), HTTP_PORT)); - std::fs::create_dir_all(config_dir()).ok(); + std::fs::create_dir_all(pf_paths::config_dir()).ok(); std::fs::write(&path, &id).with_context(|| format!("write {}", path.display()))?; Ok(id) } @@ -512,7 +349,7 @@ fn primary_local_ip() -> Option { /// Where the paired-client allow-list persists (survives host restarts, like Sunshine). fn paired_path() -> Option { // Same dir as the host identity (HOME/.config/punktfunk on Linux, %APPDATA%\punktfunk on Windows). - Some(config_dir().join("paired.json")) + Some(pf_paths::config_dir().join("paired.json")) } /// Load the persisted paired-client certificate DERs (empty on first run / parse failure). @@ -541,7 +378,7 @@ fn load_paired() -> Vec> { pub(crate) fn save_paired(paired: &[Vec]) { let Some(path) = paired_path() else { return }; if let Some(dir) = path.parent() { - let _ = create_private_dir(dir); + let _ = pf_paths::create_private_dir(dir); } let bytes = match serde_json::to_vec(paired) { Ok(b) => b, @@ -553,7 +390,7 @@ pub(crate) fn save_paired(paired: &[Vec]) { // Write to a sibling temp file (owner-only, so a local user can't tamper the allow-list), then // rename over the target (atomic replace on Unix and Windows). Never write `path` in place. let tmp = path.with_extension("json.tmp"); - if let Err(e) = write_secret_file(&tmp, &bytes) { + if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) { tracing::warn!(error = %e, "persisting pairings failed (temp write)"); return; } @@ -565,24 +402,24 @@ pub(crate) fn save_paired(paired: &[Vec]) { #[cfg(all(test, unix))] mod tests { - use super::{create_private_dir, write_secret_file}; use std::os::unix::fs::PermissionsExt; #[test] fn secrets_are_written_owner_only() { let dir = std::env::temp_dir().join(format!("pf-secret-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - create_private_dir(&dir).expect("create private dir"); + pf_paths::create_private_dir(&dir).expect("create private dir"); let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; assert_eq!(dmode, 0o700, "config dir must be owner-only (0700)"); let key = dir.join("key.pem"); - write_secret_file(&key, b"-----BEGIN PRIVATE KEY-----\n...").expect("write secret"); + pf_paths::write_secret_file(&key, b"-----BEGIN PRIVATE KEY-----\n...") + .expect("write secret"); let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777; assert_eq!(fmode, 0o600, "private key must be owner-only (0600)"); // Overwriting an existing secret keeps it 0600 (the truncate+reopen path). - write_secret_file(&key, b"new contents").expect("rewrite secret"); + pf_paths::write_secret_file(&key, b"new contents").expect("rewrite secret"); let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777; assert_eq!(fmode, 0o600); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/punktfunk-host/src/gpu.rs b/crates/punktfunk-host/src/gpu.rs index 7e50ea29..385fa496 100644 --- a/crates/punktfunk-host/src/gpu.rs +++ b/crates/punktfunk-host/src/gpu.rs @@ -409,10 +409,10 @@ impl GpuPrefStore { /// succeeds, so a full disk can't leave memory and file disagreeing. pub fn set(&self, pref: GpuPreference) -> Result<()> { if let Some(dir) = self.path.parent() { - crate::gamestream::create_private_dir(dir)?; + pf_paths::create_private_dir(dir)?; } let tmp = self.path.with_extension("json.tmp"); - crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&pref)?)?; + pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&pref)?)?; std::fs::rename(&tmp, &self.path)?; *self.cur.lock().unwrap() = pref; Ok(()) @@ -424,9 +424,7 @@ impl GpuPrefStore { /// capture/encode setup where no app state is threaded. pub(crate) fn prefs() -> &'static GpuPrefStore { static STORE: OnceLock = OnceLock::new(); - STORE.get_or_init(|| { - GpuPrefStore::load_from(crate::gamestream::config_dir().join("gpu-settings.json")) - }) + STORE.get_or_init(|| GpuPrefStore::load_from(pf_paths::config_dir().join("gpu-settings.json"))) } // --------------------------------------------------------------------------------------------- diff --git a/crates/punktfunk-host/src/hooks.rs b/crates/punktfunk-host/src/hooks.rs index f45d8741..a1646159 100644 --- a/crates/punktfunk-host/src/hooks.rs +++ b/crates/punktfunk-host/src/hooks.rs @@ -206,10 +206,10 @@ impl HooksStore { /// changes only if the disk write succeeds. pub fn set(&self, cfg: HooksConfig) -> Result<()> { if let Some(dir) = self.path.parent() { - crate::gamestream::create_private_dir(dir)?; + pf_paths::create_private_dir(dir)?; } let tmp = self.path.with_extension("json.tmp"); - crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?; + pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?; std::fs::rename(&tmp, &self.path)?; *self.cur.lock().unwrap() = Some(cfg); Ok(()) @@ -219,7 +219,7 @@ impl HooksStore { /// The process-wide hooks store (`/hooks.json`), loaded once on first access. pub fn store() -> &'static HooksStore { static STORE: OnceLock = OnceLock::new(); - STORE.get_or_init(|| HooksStore::load_from(crate::gamestream::config_dir().join("hooks.json"))) + STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json"))) } // ------------------------------------------------------------------------- runner diff --git a/crates/punktfunk-host/src/library/art.rs b/crates/punktfunk-host/src/library/art.rs index 372ee0e6..c2d25e03 100644 --- a/crates/punktfunk-host/src/library/art.rs +++ b/crates/punktfunk-host/src/library/art.rs @@ -19,10 +19,10 @@ fn art_cache() -> &'static std::sync::Mutex PathBuf { - crate::gamestream::config_dir().join("library-art-cache.json") + pf_paths::config_dir().join("library-art-cache.json") } /// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet diff --git a/crates/punktfunk-host/src/mgmt_token.rs b/crates/punktfunk-host/src/mgmt_token.rs index 0fa4c1e5..9147e93c 100644 --- a/crates/punktfunk-host/src/mgmt_token.rs +++ b/crates/punktfunk-host/src/mgmt_token.rs @@ -25,7 +25,7 @@ pub fn load_or_generate() -> Result { return Ok(v.to_string()); } } - let path = crate::gamestream::config_dir().join(FILE); + let path = pf_paths::config_dir().join(FILE); if let Ok(contents) = fs::read_to_string(&path) { if let Some(tok) = parse_token(&contents) { return Ok(tok); @@ -34,10 +34,9 @@ pub fn load_or_generate() -> Result { let mut buf = [0u8; 32]; rand::thread_rng().fill_bytes(&mut buf); let token = hex::encode(buf); - let dir = crate::gamestream::config_dir(); + let dir = pf_paths::config_dir(); // Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path. - crate::gamestream::create_private_dir(&dir) - .with_context(|| format!("create {}", dir.display()))?; + pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; write_token(&path, &token)?; tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)"); Ok(token) @@ -61,7 +60,7 @@ fn parse_token(contents: &str) -> Option { /// 2026-06-28 #2). fn write_token(path: &Path, token: &str) -> Result<()> { let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n"); - crate::gamestream::write_secret_file(path, line.as_bytes()) + pf_paths::write_secret_file(path, line.as_bytes()) .with_context(|| format!("write {}", path.display())) } diff --git a/crates/punktfunk-host/src/native_pairing/store.rs b/crates/punktfunk-host/src/native_pairing/store.rs index 9505a525..e685a196 100644 --- a/crates/punktfunk-host/src/native_pairing/store.rs +++ b/crates/punktfunk-host/src/native_pairing/store.rs @@ -36,7 +36,7 @@ struct PairedState { fn default_path() -> Result { // `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the // native paired-store works without a HOME env var (which a Windows service/task doesn't set). - Ok(crate::gamestream::config_dir().join("punktfunk1-paired.json")) + Ok(pf_paths::config_dir().join("punktfunk1-paired.json")) } fn load(path: &Path) -> PairedClients { @@ -48,13 +48,13 @@ fn load(path: &Path) -> PairedClients { fn save(state: &PairedState) -> Result<()> { if let Some(dir) = state.path.parent() { - crate::gamestream::create_private_dir(dir)?; + pf_paths::create_private_dir(dir)?; } // Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would // silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is // written owner-only so a local user can't inject a fingerprint to pair themselves. let tmp = state.path.with_extension("json.tmp"); - crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?; + pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?; std::fs::rename(&tmp, &state.path)?; Ok(()) } diff --git a/crates/punktfunk-host/src/stats_recorder.rs b/crates/punktfunk-host/src/stats_recorder.rs index c8b6aa2c..f2598c0f 100644 --- a/crates/punktfunk-host/src/stats_recorder.rs +++ b/crates/punktfunk-host/src/stats_recorder.rs @@ -144,7 +144,7 @@ pub struct StatsRecorder { /// The default captures directory: `~/.config/punktfunk/captures/` (next to `cert.pem`), /// resolved via the same config-dir helper the rest of the host uses. pub fn default_dir() -> PathBuf { - crate::gamestream::config_dir().join("captures") + pf_paths::config_dir().join("captures") } /// `id` charset gate, matching `^[A-Za-z0-9._-]+$` — the exact charset `capture_id` emits (which @@ -197,7 +197,7 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { impl StatsRecorder { /// Create the recorder, creating `dir` (owner-private, best-effort) if missing. pub fn new(dir: PathBuf) -> Arc { - if let Err(e) = crate::gamestream::create_private_dir(&dir) { + if let Err(e) = pf_paths::create_private_dir(&dir) { tracing::warn!(dir = %dir.display(), error = %e, "could not create stats captures dir"); } Arc::new(StatsRecorder { diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/punktfunk-host/src/vdisplay/identity.rs index 8ba146d5..b8bc1711 100644 --- a/crates/punktfunk-host/src/vdisplay/identity.rs +++ b/crates/punktfunk-host/src/vdisplay/identity.rs @@ -76,7 +76,7 @@ impl DisplayIdentityMap { /// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy /// Windows `pf-vdisplay-identity.json` if the new file is absent. pub(crate) fn load() -> Self { - let dir = crate::gamestream::config_dir(); + let dir = pf_paths::config_dir(); let path = dir.join(FILE); let bytes = std::fs::read(&path) .or_else(|_| std::fs::read(dir.join(LEGACY_FILE))) @@ -240,7 +240,7 @@ impl ScaleMap { /// Load the persisted map (empty on first run / unreadable file — a client just re-sets its /// scaling once). Drops non-finite / out-of-range entries from a hand-edited file. fn load() -> Self { - let path = crate::gamestream::config_dir().join(SCALE_FILE); + let path = pf_paths::config_dir().join(SCALE_FILE); let mut map: std::collections::BTreeMap = std::fs::read(&path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs index 115e304d..6a0ab127 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -481,10 +481,10 @@ impl DisplayPolicyStore { pub fn set(&self, policy: DisplayPolicy) -> Result<()> { let policy = policy.sanitized(); if let Some(dir) = self.path.parent() { - crate::gamestream::create_private_dir(dir)?; + pf_paths::create_private_dir(dir)?; } let tmp = self.path.with_extension("json.tmp"); - crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?; + pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?; std::fs::rename(&tmp, &self.path)?; *self.cur.lock().unwrap() = Some(policy); Ok(()) @@ -497,7 +497,7 @@ impl DisplayPolicyStore { pub fn prefs() -> &'static DisplayPolicyStore { static STORE: OnceLock = OnceLock::new(); STORE.get_or_init(|| { - DisplayPolicyStore::load_from(crate::gamestream::config_dir().join("display-settings.json")) + DisplayPolicyStore::load_from(pf_paths::config_dir().join("display-settings.json")) }) } @@ -539,7 +539,7 @@ pub struct CustomPresetInput { } fn custom_presets_path() -> PathBuf { - crate::gamestream::config_dir().join("display-presets.json") + pf_paths::config_dir().join("display-presets.json") } /// Clamp a saved preset's fields to their valid ranges — the same bounds [`DisplayPolicy::sanitized`] @@ -566,10 +566,10 @@ pub fn load_custom_presets() -> Vec { fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> { let path = custom_presets_path(); if let Some(dir) = path.parent() { - crate::gamestream::create_private_dir(dir)?; + pf_paths::create_private_dir(dir)?; } let tmp = path.with_extension("json.tmp"); - crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?; + pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?; std::fs::rename(&tmp, &path)?; Ok(()) } diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index 258f3b72..1368de0b 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -382,7 +382,7 @@ fn web_setup(args: &[String]) -> Result<()> { let app_dir = PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir required")?); let pw_file = flag_val(args, "--password-file"); - let data_dir = crate::gamestream::config_dir(); + let data_dir = pf_paths::config_dir(); std::fs::create_dir_all(&data_dir).ok(); let pw_path = data_dir.join("web-password"); let token_path = data_dir.join("mgmt-token"); diff --git a/crates/punktfunk-host/src/windows/monitor_devnode.rs b/crates/punktfunk-host/src/windows/monitor_devnode.rs index 7a3e6971..b647c6ca 100644 --- a/crates/punktfunk-host/src/windows/monitor_devnode.rs +++ b/crates/punktfunk-host/src/windows/monitor_devnode.rs @@ -36,7 +36,7 @@ use windows::Win32::Foundation::LUID; /// The crash-recovery journal: PnP instance ids we disabled and have not yet re-enabled. fn journal_path() -> std::path::PathBuf { - crate::gamestream::config_dir().join("pnp-disabled-monitors.json") + pf_paths::config_dir().join("pnp-disabled-monitors.json") } fn read_journal() -> Vec { @@ -55,7 +55,7 @@ fn write_journal(ids: &[String]) { return; } if let Some(dir) = path.parent() { - let _ = crate::gamestream::create_private_dir(dir); + let _ = pf_paths::create_private_dir(dir); } if let Err(e) = std::fs::write(&path, serde_json::to_vec_pretty(&ids).unwrap_or_default()) { tracing::warn!(error = %e, "PnP-disable: could not write the crash-recovery journal"); diff --git a/crates/punktfunk-host/src/windows/service.rs b/crates/punktfunk-host/src/windows/service.rs index 74fab89d..61bea868 100644 --- a/crates/punktfunk-host/src/windows/service.rs +++ b/crates/punktfunk-host/src/windows/service.rs @@ -117,16 +117,16 @@ pub fn main(args: &[String]) -> Result<()> { /// `%ProgramData%\punktfunk\logs\service.log` — the service's own (supervision) log. The host child's /// stdout/stderr are redirected to `host.log` in the same dir. pub fn service_log_path() -> PathBuf { - let dir = crate::gamestream::config_dir().join("logs"); + let dir = pf_paths::config_dir().join("logs"); // DACL-locked (Users read-only, no create) so a local user can't pre-plant SYSTEM log files as // reparse points / hardlinks to redirect the SYSTEM service's writes (security-review #11). - let _ = crate::gamestream::create_private_dir(&dir); + let _ = pf_paths::create_private_dir(&dir); dir.join("service.log") } fn host_log_path() -> PathBuf { - let dir = crate::gamestream::config_dir().join("logs"); - let _ = crate::gamestream::create_private_dir(&dir); + let dir = pf_paths::config_dir().join("logs"); + let _ = pf_paths::create_private_dir(&dir); dir.join("host.log") } @@ -191,7 +191,7 @@ pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) { // ── host.env config ───────────────────────────────────────────────────────────────────────────── fn host_env_path() -> PathBuf { - crate::gamestream::config_dir().join("host.env") + pf_paths::config_dir().join("host.env") } /// Load `%ProgramData%\punktfunk\host.env` (KEY=VALUE lines, `#` comments) into this process's @@ -728,7 +728,7 @@ fn install(args: &[String]) -> Result<()> { println!( "\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start", host_env_path().display(), - crate::gamestream::config_dir().join("logs").display() + pf_paths::config_dir().join("logs").display() ); Ok(()) } @@ -793,7 +793,7 @@ fn ensure_default_host_env() -> Result<()> { if let Some(dir) = path.parent() { // DACL-lock the config dir on creation so a local user can't pre-create it and plant a // host.env (which feeds the SYSTEM service's env + command line) — security-review #3. - crate::gamestream::create_private_dir(dir).ok(); + pf_paths::create_private_dir(dir).ok(); } let default = "# punktfunk host configuration (read by the Windows service).\n\ # KEY=VALUE per line; '#' comments. Restart the service after editing:\n\ @@ -818,7 +818,7 @@ fn ensure_default_host_env() -> Result<()> { // 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). - crate::gamestream::write_secret_file(&path, default.as_bytes()) + pf_paths::write_secret_file(&path, default.as_bytes()) .with_context(|| format!("write {}", path.display()))?; println!("Wrote default config: {}", path.display()); Ok(()) @@ -866,7 +866,7 @@ fn apply_gamestream_choice(enable: bool) { let mut out = lines.join("\n"); out.push('\n'); // Rewrite through write_secret_file so the SYSTEM/Administrators DACL is re-asserted. - if let Err(e) = crate::gamestream::write_secret_file(&path, out.as_bytes()) { + if let Err(e) = pf_paths::write_secret_file(&path, out.as_bytes()) { eprintln!("warning: could not write {}: {e}", path.display()); return; } @@ -962,7 +962,7 @@ fn remove_firewall_rules() { /// (`--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") + pf_paths::config_dir().join("fw-allow-public") } /// Record (or clear) the Public-firewall opt-in marker to match this install's choice.