refactor(host/W6.1): extract secret/config-dir helpers into the pf-paths leaf crate

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 01:07:26 +02:00
parent 2e3208f75e
commit c42ce88921
19 changed files with 248 additions and 218 deletions
Generated
+8
View File
@@ -2808,6 +2808,13 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "pf-paths"
version = "0.12.0"
dependencies = [
"tracing",
]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.12.0" version = "0.12.0"
@@ -3122,6 +3129,7 @@ dependencies = [
"opus", "opus",
"parking_lot", "parking_lot",
"pf-driver-proto", "pf-driver-proto",
"pf-paths",
"pipewire", "pipewire",
"punktfunk-core", "punktfunk-core",
"pyrowave-sys", "pyrowave-sys",
+1
View File
@@ -10,6 +10,7 @@ members = [
"crates/pf-console-ui", "crates/pf-console-ui",
"crates/pf-ffvk", "crates/pf-ffvk",
"crates/pf-driver-proto", "crates/pf-driver-proto",
"crates/pf-paths",
"crates/pyrowave-sys", "crates/pyrowave-sys",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
+13
View File
@@ -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"
+172
View File
@@ -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"
),
}
}
+2
View File
@@ -10,6 +10,8 @@ repository.workspace = true
[dependencies] [dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } 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). # M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP).
quinn = "0.11" quinn = "0.11"
anyhow = "1" anyhow = "1"
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct AppEntry {
fn config_path() -> Option<std::path::PathBuf> { fn config_path() -> Option<std::path::PathBuf> {
// `config_dir()` resolves XDG/HOME on Linux and %APPDATA% on Windows (no HOME needed). // `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<crate::vdisplay::Compositor> { fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
+4 -4
View File
@@ -2,8 +2,8 @@
//! during pairing AND presented as the TLS server cert on 47984 (Moonlight pins it). The //! 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. //! 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 anyhow::{anyhow, Context, Result};
use pf_paths::config_dir;
use rsa::pkcs1v15::SigningKey; use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey; use rsa::pkcs8::DecodePrivateKey;
use rsa::RsaPrivateKey; use rsa::RsaPrivateKey;
@@ -36,11 +36,11 @@ impl ServerIdentity {
// The private key is the trust root for EVERY surface (TLS server cert, pairing // 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 // 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. // DACL) so a local user can't read it and impersonate the host. The dir is 0700.
super::create_private_dir(&dir).ok(); pf_paths::create_private_dir(&dir).ok();
super::write_secret_file(&key_path, k.as_bytes()) pf_paths::write_secret_file(&key_path, k.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?; .with_context(|| format!("write {}", key_path.display()))?;
// The cert is public (handed to clients), but write it owner-only too for consistency. // 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()))?; .with_context(|| format!("write {}", cert_path.display()))?;
tracing::info!(path = %cert_path.display(), "generated punktfunk host certificate (RSA-2048, key 0600)"); tracing::info!(path = %cert_path.display(), "generated punktfunk host certificate (RSA-2048, key 0600)");
(c, k) (c, k)
+9 -172
View File
@@ -26,7 +26,6 @@ mod video;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv4Addr, UdpSocket}; use std::net::{IpAddr, Ipv4Addr, UdpSocket};
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
/// nvhttp ports (Moonlight derives all stream ports by offset from the HTTP base 47989). /// 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 { fn hostname_string() -> String {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if let Some(n) = std::env::var_os("COMPUTERNAME") { 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. /// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it.
fn load_or_create_uniqueid() -> Result<String> { fn load_or_create_uniqueid() -> Result<String> {
let path = config_dir().join("uniqueid"); let path = pf_paths::config_dir().join("uniqueid");
if let Ok(s) = std::fs::read_to_string(&path) { if let Ok(s) = std::fs::read_to_string(&path) {
let t = s.trim(); let t = s.trim();
if !t.is_empty() { if !t.is_empty() {
@@ -496,7 +333,7 @@ fn load_or_create_uniqueid() -> Result<String> {
let id = std::fs::read_to_string("/proc/sys/kernel/random/uuid") let id = std::fs::read_to_string("/proc/sys/kernel/random/uuid")
.map(|u| u.trim().replace('-', "")) .map(|u| u.trim().replace('-', ""))
.unwrap_or_else(|_| format!("{:016x}{:016x}", std::process::id(), HTTP_PORT)); .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()))?; std::fs::write(&path, &id).with_context(|| format!("write {}", path.display()))?;
Ok(id) Ok(id)
} }
@@ -512,7 +349,7 @@ fn primary_local_ip() -> Option<IpAddr> {
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine). /// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
fn paired_path() -> Option<std::path::PathBuf> { fn paired_path() -> Option<std::path::PathBuf> {
// Same dir as the host identity (HOME/.config/punktfunk on Linux, %APPDATA%\punktfunk on Windows). // 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). /// Load the persisted paired-client certificate DERs (empty on first run / parse failure).
@@ -541,7 +378,7 @@ fn load_paired() -> Vec<Vec<u8>> {
pub(crate) fn save_paired(paired: &[Vec<u8>]) { pub(crate) fn save_paired(paired: &[Vec<u8>]) {
let Some(path) = paired_path() else { return }; let Some(path) = paired_path() else { return };
if let Some(dir) = path.parent() { 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) { let bytes = match serde_json::to_vec(paired) {
Ok(b) => b, Ok(b) => b,
@@ -553,7 +390,7 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
// Write to a sibling temp file (owner-only, so a local user can't tamper the allow-list), then // 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. // rename over the target (atomic replace on Unix and Windows). Never write `path` in place.
let tmp = path.with_extension("json.tmp"); 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)"); tracing::warn!(error = %e, "persisting pairings failed (temp write)");
return; return;
} }
@@ -565,24 +402,24 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
#[cfg(all(test, unix))] #[cfg(all(test, unix))]
mod tests { mod tests {
use super::{create_private_dir, write_secret_file};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
#[test] #[test]
fn secrets_are_written_owner_only() { fn secrets_are_written_owner_only() {
let dir = std::env::temp_dir().join(format!("pf-secret-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("pf-secret-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); 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; let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(dmode, 0o700, "config dir must be owner-only (0700)"); assert_eq!(dmode, 0o700, "config dir must be owner-only (0700)");
let key = dir.join("key.pem"); 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; let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
assert_eq!(fmode, 0o600, "private key must be owner-only (0600)"); assert_eq!(fmode, 0o600, "private key must be owner-only (0600)");
// Overwriting an existing secret keeps it 0600 (the truncate+reopen path). // 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; let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
assert_eq!(fmode, 0o600); assert_eq!(fmode, 0o600);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
+3 -5
View File
@@ -409,10 +409,10 @@ impl GpuPrefStore {
/// succeeds, so a full disk can't leave memory and file disagreeing. /// succeeds, so a full disk can't leave memory and file disagreeing.
pub fn set(&self, pref: GpuPreference) -> Result<()> { pub fn set(&self, pref: GpuPreference) -> Result<()> {
if let Some(dir) = self.path.parent() { 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"); 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)?; std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = pref; *self.cur.lock().unwrap() = pref;
Ok(()) Ok(())
@@ -424,9 +424,7 @@ impl GpuPrefStore {
/// capture/encode setup where no app state is threaded. /// capture/encode setup where no app state is threaded.
pub(crate) fn prefs() -> &'static GpuPrefStore { pub(crate) fn prefs() -> &'static GpuPrefStore {
static STORE: OnceLock<GpuPrefStore> = OnceLock::new(); static STORE: OnceLock<GpuPrefStore> = OnceLock::new();
STORE.get_or_init(|| { STORE.get_or_init(|| GpuPrefStore::load_from(pf_paths::config_dir().join("gpu-settings.json")))
GpuPrefStore::load_from(crate::gamestream::config_dir().join("gpu-settings.json"))
})
} }
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
+3 -3
View File
@@ -206,10 +206,10 @@ impl HooksStore {
/// changes only if the disk write succeeds. /// changes only if the disk write succeeds.
pub fn set(&self, cfg: HooksConfig) -> Result<()> { pub fn set(&self, cfg: HooksConfig) -> Result<()> {
if let Some(dir) = self.path.parent() { 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"); 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)?; std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(cfg); *self.cur.lock().unwrap() = Some(cfg);
Ok(()) Ok(())
@@ -219,7 +219,7 @@ impl HooksStore {
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access. /// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access.
pub fn store() -> &'static HooksStore { pub fn store() -> &'static HooksStore {
static STORE: OnceLock<HooksStore> = OnceLock::new(); static STORE: OnceLock<HooksStore> = 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 // ------------------------------------------------------------------------- runner
+2 -2
View File
@@ -19,10 +19,10 @@ fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Ar
} }
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows / /// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
/// `~/.config/punktfunk` on Linux — gamestream::config_dir, NOT the legacy XDG/HOME `config_dir` /// `~/.config/punktfunk` on Linux — `pf_paths::config_dir`, NOT the legacy XDG/HOME `config_dir`
/// below that the custom store still uses). /// below that the custom store still uses).
fn art_cache_path() -> PathBuf { fn art_cache_path() -> 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 /// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet
+4 -5
View File
@@ -25,7 +25,7 @@ pub fn load_or_generate() -> Result<String> {
return Ok(v.to_string()); 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 Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents) { if let Some(tok) = parse_token(&contents) {
return Ok(tok); return Ok(tok);
@@ -34,10 +34,9 @@ pub fn load_or_generate() -> Result<String> {
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf); rand::thread_rng().fill_bytes(&mut buf);
let token = hex::encode(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. // Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
crate::gamestream::create_private_dir(&dir) pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
.with_context(|| format!("create {}", dir.display()))?;
write_token(&path, &token)?; write_token(&path, &token)?;
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)"); tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
Ok(token) Ok(token)
@@ -61,7 +60,7 @@ fn parse_token(contents: &str) -> Option<String> {
/// 2026-06-28 #2). /// 2026-06-28 #2).
fn write_token(path: &Path, token: &str) -> Result<()> { fn write_token(path: &Path, token: &str) -> Result<()> {
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n"); 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())) .with_context(|| format!("write {}", path.display()))
} }
@@ -36,7 +36,7 @@ struct PairedState {
fn default_path() -> Result<PathBuf> { fn default_path() -> Result<PathBuf> {
// `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the // `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). // 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 { fn load(path: &Path) -> PairedClients {
@@ -48,13 +48,13 @@ fn load(path: &Path) -> PairedClients {
fn save(state: &PairedState) -> Result<()> { fn save(state: &PairedState) -> Result<()> {
if let Some(dir) = state.path.parent() { 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 // 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 // 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. // written owner-only so a local user can't inject a fingerprint to pair themselves.
let tmp = state.path.with_extension("json.tmp"); 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)?; std::fs::rename(&tmp, &state.path)?;
Ok(()) Ok(())
} }
+2 -2
View File
@@ -144,7 +144,7 @@ pub struct StatsRecorder {
/// The default captures directory: `~/.config/punktfunk/captures/` (next to `cert.pem`), /// The default captures directory: `~/.config/punktfunk/captures/` (next to `cert.pem`),
/// resolved via the same config-dir helper the rest of the host uses. /// resolved via the same config-dir helper the rest of the host uses.
pub fn default_dir() -> PathBuf { 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 /// `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 { impl StatsRecorder {
/// Create the recorder, creating `dir` (owner-private, best-effort) if missing. /// Create the recorder, creating `dir` (owner-private, best-effort) if missing.
pub fn new(dir: PathBuf) -> Arc<Self> { pub fn new(dir: PathBuf) -> Arc<Self> {
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"); tracing::warn!(dir = %dir.display(), error = %e, "could not create stats captures dir");
} }
Arc::new(StatsRecorder { Arc::new(StatsRecorder {
@@ -76,7 +76,7 @@ impl DisplayIdentityMap {
/// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy /// 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. /// Windows `pf-vdisplay-identity.json` if the new file is absent.
pub(crate) fn load() -> Self { pub(crate) fn load() -> Self {
let dir = crate::gamestream::config_dir(); let dir = pf_paths::config_dir();
let path = dir.join(FILE); let path = dir.join(FILE);
let bytes = std::fs::read(&path) let bytes = std::fs::read(&path)
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE))) .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 /// 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. /// scaling once). Drops non-finite / out-of-range entries from a hand-edited file.
fn load() -> Self { 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<String, f64> = std::fs::read(&path) let mut map: std::collections::BTreeMap<String, f64> = std::fs::read(&path)
.ok() .ok()
.and_then(|b| serde_json::from_slice(&b).ok()) .and_then(|b| serde_json::from_slice(&b).ok())
+6 -6
View File
@@ -481,10 +481,10 @@ impl DisplayPolicyStore {
pub fn set(&self, policy: DisplayPolicy) -> Result<()> { pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
let policy = policy.sanitized(); let policy = policy.sanitized();
if let Some(dir) = self.path.parent() { 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"); 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)?; std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(policy); *self.cur.lock().unwrap() = Some(policy);
Ok(()) Ok(())
@@ -497,7 +497,7 @@ impl DisplayPolicyStore {
pub fn prefs() -> &'static DisplayPolicyStore { pub fn prefs() -> &'static DisplayPolicyStore {
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new(); static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
STORE.get_or_init(|| { 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 { 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`] /// 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<CustomPreset> {
fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> { fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> {
let path = custom_presets_path(); let path = custom_presets_path();
if let Some(dir) = path.parent() { 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"); 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)?; std::fs::rename(&tmp, &path)?;
Ok(()) Ok(())
} }
+1 -1
View File
@@ -382,7 +382,7 @@ fn web_setup(args: &[String]) -> Result<()> {
let app_dir = let app_dir =
PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> required")?); PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> required")?);
let pw_file = flag_val(args, "--password-file"); 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(); std::fs::create_dir_all(&data_dir).ok();
let pw_path = data_dir.join("web-password"); let pw_path = data_dir.join("web-password");
let token_path = data_dir.join("mgmt-token"); let token_path = data_dir.join("mgmt-token");
@@ -36,7 +36,7 @@ use windows::Win32::Foundation::LUID;
/// The crash-recovery journal: PnP instance ids we disabled and have not yet re-enabled. /// The crash-recovery journal: PnP instance ids we disabled and have not yet re-enabled.
fn journal_path() -> std::path::PathBuf { 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<String> { fn read_journal() -> Vec<String> {
@@ -55,7 +55,7 @@ fn write_journal(ids: &[String]) {
return; return;
} }
if let Some(dir) = path.parent() { 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()) { 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"); tracing::warn!(error = %e, "PnP-disable: could not write the crash-recovery journal");
+10 -10
View File
@@ -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 /// `%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. /// stdout/stderr are redirected to `host.log` in the same dir.
pub fn service_log_path() -> PathBuf { 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 // 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). // 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") dir.join("service.log")
} }
fn host_log_path() -> PathBuf { fn host_log_path() -> PathBuf {
let dir = crate::gamestream::config_dir().join("logs"); let dir = pf_paths::config_dir().join("logs");
let _ = crate::gamestream::create_private_dir(&dir); let _ = pf_paths::create_private_dir(&dir);
dir.join("host.log") dir.join("host.log")
} }
@@ -191,7 +191,7 @@ pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
// ── host.env config ───────────────────────────────────────────────────────────────────────────── // ── host.env config ─────────────────────────────────────────────────────────────────────────────
fn host_env_path() -> PathBuf { 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 /// Load `%ProgramData%\punktfunk\host.env` (KEY=VALUE lines, `#` comments) into this process's
@@ -728,7 +728,7 @@ fn install(args: &[String]) -> Result<()> {
println!( println!(
"\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start", "\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start",
host_env_path().display(), host_env_path().display(),
crate::gamestream::config_dir().join("logs").display() pf_paths::config_dir().join("logs").display()
); );
Ok(()) Ok(())
} }
@@ -793,7 +793,7 @@ fn ensure_default_host_env() -> Result<()> {
if let Some(dir) = path.parent() { 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 // 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. // 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\ let default = "# punktfunk host configuration (read by the Windows service).\n\
# KEY=VALUE per line; '#' comments. Restart the service after editing:\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 // 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 // environment + launched command line, so a local user must not be able to read or tamper with
// it (security-review 2026-06-28 #3). // 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()))?; .with_context(|| format!("write {}", path.display()))?;
println!("Wrote default config: {}", path.display()); println!("Wrote default config: {}", path.display());
Ok(()) Ok(())
@@ -866,7 +866,7 @@ fn apply_gamestream_choice(enable: bool) {
let mut out = lines.join("\n"); let mut out = lines.join("\n");
out.push('\n'); out.push('\n');
// Rewrite through write_secret_file so the SYSTEM/Administrators DACL is re-asserted. // 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()); eprintln!("warning: could not write {}: {e}", path.display());
return; return;
} }
@@ -962,7 +962,7 @@ fn remove_firewall_rules() {
/// (`--allow-public-network`). Its presence suppresses the startup Public-network warning (they made /// (`--allow-public-network`). Its presence suppresses the startup Public-network warning (they made
/// an informed choice); absence = the secure default. /// an informed choice); absence = the secure default.
fn fw_public_marker() -> std::path::PathBuf { 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. /// Record (or clear) the Public-firewall opt-in marker to match this install's choice.