feat(web): consolidate paired devices, self-contained sections, docs + lint
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s
Web console - Pairing/Library/Stats refactored into self-contained subsections that each own their own queries + mutations; a shared slot-based layout (view.tsx) is filled by the live page (containers) and Storybook (pure cards + fixtures) so the layout can't drift. - All paired devices in one list on Pairing with a protocol column (punktfunk/1 + Moonlight), routing each unpair to the right endpoint; the redundant Clients page is removed. - Library: overview grid split from the add/edit form into separate files. - Login screen links out to the docs. Docs - "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows) plus a new "Forgot your Password?" troubleshooting page, linked from the login screen. - Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the Bun entry, launchers, systemd units, and packaging. Tooling - Biome now respects .gitignore (stops linting generated code), config migrated to 2.5.1; all lint issues fixed cleanly. Also includes this branch's in-progress host, Apple client, packaging, and CI changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,10 @@
|
||||
//! lets a picker show the fingerprint and pre-pin a chosen host;
|
||||
//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN
|
||||
//! pairing ceremony before it can stream;
|
||||
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises).
|
||||
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises);
|
||||
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
||||
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
|
||||
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
@@ -30,7 +33,9 @@ pub struct Advert {
|
||||
}
|
||||
|
||||
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
|
||||
/// `require_pairing` tells a discovering client whether it must pair before it can stream.
|
||||
/// `require_pairing` tells a discovering client whether it must pair before it can stream;
|
||||
/// `mgmt_port` is the management API's port (`Some` when this host serves one — the client browses
|
||||
/// the library there over mTLS on the advertised IP), `None` for a host with no mgmt API.
|
||||
pub fn advertise_native(
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
@@ -38,6 +43,7 @@ pub fn advertise_native(
|
||||
fingerprint: &str,
|
||||
require_pairing: bool,
|
||||
uniqueid: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let host_name = format!("{hostname}.local.");
|
||||
@@ -54,6 +60,9 @@ pub fn advertise_native(
|
||||
.into(),
|
||||
);
|
||||
props.insert("id".into(), uniqueid.to_string());
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
|
||||
@@ -238,7 +238,7 @@ pub fn serve(
|
||||
tokio::try_join!(
|
||||
nvhttp::run(state.clone()),
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
} else {
|
||||
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
||||
@@ -249,7 +249,7 @@ pub fn serve(
|
||||
);
|
||||
tokio::try_join!(
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -350,7 +350,17 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
let fps = parse_u("x-nv-video[0].maxFPS")
|
||||
.filter(|&f| f > 0)
|
||||
.unwrap_or(60);
|
||||
let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000);
|
||||
// Bitrate: Moonlight caps the legacy `x-nv-vqos[0].bw.*` fields at 100 Mbps for old-GFE
|
||||
// compatibility and carries the user's REAL (uncapped) configured bitrate in the moonlight-specific
|
||||
// `x-ml-video.configuredBitrateKbps`. Read that first — exactly like Sunshine — so a 500 Mbps client
|
||||
// setting isn't silently floored to 100. Fall back to the legacy max for clients that don't send it,
|
||||
// then a conservative default; clamp to a sane ceiling (the RTSP ANNOUNCE is attacker-controlled).
|
||||
const MAX_BITRATE_KBPS: u32 = 1_000_000; // 1 Gbps — well above Moonlight's 500 Mbps slider
|
||||
let bitrate_kbps = parse_u("x-ml-video.configuredBitrateKbps")
|
||||
.filter(|&b| b > 0)
|
||||
.or_else(|| parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").filter(|&b| b > 0))
|
||||
.unwrap_or(20_000)
|
||||
.min(MAX_BITRATE_KBPS);
|
||||
// Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1.
|
||||
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
|
||||
Some("1") => Codec::H265,
|
||||
@@ -496,6 +506,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitrate precedence: the moonlight-specific `x-ml-video.configuredBitrateKbps` (the user's real,
|
||||
/// uncapped setting) wins over the legacy `x-nv-vqos[0].bw.maximumBitrateKbps` (which Moonlight floors
|
||||
/// at 100 Mbps for old-GFE compat). Without this a 500 Mbps client streamed at 100.
|
||||
#[test]
|
||||
fn announce_prefers_configured_bitrate() {
|
||||
// Real Moonlight shape: legacy max floored at 100 Mbps, configured carrying the true 500 Mbps.
|
||||
let map = announce(&[
|
||||
("x-nv-vqos[0].bw.maximumBitrateKbps", "100000"),
|
||||
("x-ml-video.configuredBitrateKbps", "500000"),
|
||||
]);
|
||||
assert_eq!(stream_config(&map).unwrap().bitrate_kbps, 500_000);
|
||||
// No configured field (older client) → fall back to the legacy max (the base announce's 40 Mbps).
|
||||
assert_eq!(stream_config(&announce(&[])).unwrap().bitrate_kbps, 40_000);
|
||||
// A zero configured value is ignored (falls back), and an absurd value is clamped to the ceiling.
|
||||
let zero = announce(&[("x-ml-video.configuredBitrateKbps", "0")]);
|
||||
assert_eq!(stream_config(&zero).unwrap().bitrate_kbps, 40_000);
|
||||
let huge = announce(&[("x-ml-video.configuredBitrateKbps", "9000000")]);
|
||||
assert_eq!(stream_config(&huge).unwrap().bitrate_kbps, 1_000_000);
|
||||
}
|
||||
|
||||
/// Missing required video keys → no config (the PLAY handler then refuses to stream).
|
||||
#[test]
|
||||
fn announce_missing_required_keys() {
|
||||
|
||||
@@ -472,6 +472,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
||||
let mut native_port: u16 = 9777; // the native plane always runs now
|
||||
let mut open = false;
|
||||
let mut gamestream = false;
|
||||
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
|
||||
// paired clients can browse the game library out of the box (the bearer admin surface stays
|
||||
// loopback-gated in `mgmt::require_auth` regardless of the bind).
|
||||
let mut mgmt_bind_explicit = false;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
@@ -485,7 +489,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
||||
"--mgmt-bind" => {
|
||||
opts.bind = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?
|
||||
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?;
|
||||
mgmt_bind_explicit = true;
|
||||
}
|
||||
"--mgmt-token" => {
|
||||
let token = next()?;
|
||||
@@ -526,9 +531,20 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
||||
if opts.token.is_none() {
|
||||
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
||||
}
|
||||
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
|
||||
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
|
||||
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
|
||||
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
|
||||
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
|
||||
if !mgmt_bind_explicit {
|
||||
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
|
||||
}
|
||||
let native = punktfunk1::NativeServe {
|
||||
port: native_port,
|
||||
require_pairing: !open,
|
||||
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
|
||||
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
|
||||
mgmt_port: opts.bind.port(),
|
||||
};
|
||||
Ok((opts, native, gamestream))
|
||||
}
|
||||
@@ -643,9 +659,13 @@ USAGE:
|
||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||
|
||||
SERVE OPTIONS:
|
||||
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
|
||||
required when --mgmt-bind is not loopback
|
||||
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
|
||||
reach the read-only surface, incl. the game library, over mTLS;
|
||||
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
|
||||
bind loopback only)
|
||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
|
||||
admin endpoints it guards are honored only from a loopback peer
|
||||
(the co-located web console), never over the LAN
|
||||
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
|
||||
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
|
||||
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
|
||||
|
||||
@@ -9,15 +9,20 @@
|
||||
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
|
||||
//! cbindgen header).
|
||||
//!
|
||||
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
|
||||
//! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired
|
||||
//! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` /
|
||||
//! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The
|
||||
//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
|
||||
//! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route
|
||||
//! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by
|
||||
//! default** so a paired native client can reach the read-only surface (host/status/clients and the
|
||||
//! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the
|
||||
//! `cert_may_access` allowlist). The **bearer-token admin surface** (pairing, unpair, session
|
||||
//! control, library mutation, stats) is honored **only from a loopback peer**, so it is never
|
||||
//! LAN-exposed: the web console BFF — the sole token holder (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`,
|
||||
//! else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`) — always connects over
|
||||
//! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI
|
||||
//! document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
|
||||
|
||||
use crate::encode::Codec;
|
||||
use crate::gamestream::{
|
||||
tls::{serve_https, PeerCertFingerprint},
|
||||
tls::{serve_https, PeerAddr, PeerCertFingerprint},
|
||||
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
||||
};
|
||||
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
||||
@@ -474,8 +479,11 @@ where
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required
|
||||
/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes.
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
|
||||
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist
|
||||
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
|
||||
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
|
||||
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
||||
if req.uri().path() == "/api/v1/health" {
|
||||
return next.run(req).await; // liveness probe is always open
|
||||
@@ -493,8 +501,25 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
// Otherwise require the bearer token (the web console / admin). `run` always passes a token, so
|
||||
// no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny.
|
||||
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
|
||||
// The token authorizes the full admin surface, so confining it to loopback keeps that surface off
|
||||
// the LAN even though the listener now binds all interfaces by default (so paired clients can
|
||||
// browse the library). The web console BFF — the sole token holder — always connects over
|
||||
// loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is
|
||||
// limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit
|
||||
// test → treat as loopback so handler tests still authenticate by token.)
|
||||
let from_loopback = req
|
||||
.extensions()
|
||||
.get::<PeerAddr>()
|
||||
.is_none_or(|a| a.0.ip().is_loopback());
|
||||
if !from_loopback {
|
||||
return api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the admin API is loopback-only — a LAN client must present a paired client certificate",
|
||||
);
|
||||
}
|
||||
// `run` always passes a token, so no-token means a misconfigured caller (e.g. a test constructing
|
||||
// `app` directly) — deny.
|
||||
let Some(expected) = st.token.as_deref() else {
|
||||
return api_error(StatusCode::UNAUTHORIZED, "authentication required");
|
||||
};
|
||||
@@ -1605,6 +1630,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The bearer-token (admin) path is honored only from a LOOPBACK peer: the same token from a LAN
|
||||
/// peer is rejected, so binding the listener to all interfaces (so paired clients can browse the
|
||||
/// library by default) never LAN-exposes the admin surface. A paired *cert*, by contrast, reaches
|
||||
/// the read-only allowlist from anywhere.
|
||||
#[tokio::test]
|
||||
async fn bearer_admin_is_loopback_only() {
|
||||
let lan: SocketAddr = "192.168.1.50:54321".parse().unwrap();
|
||||
let loopback: SocketAddr = "127.0.0.1:33333".parse().unwrap();
|
||||
let bearer = |peer: SocketAddr| {
|
||||
let mut req = get_req("/api/v1/stats/recordings"); // a bearer-only (admin) route
|
||||
req.extensions_mut().insert(PeerAddr(peer));
|
||||
req.headers_mut().insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::HeaderValue::from_static("Bearer test-secret"),
|
||||
);
|
||||
req
|
||||
};
|
||||
|
||||
let app = test_app(test_state(), None);
|
||||
// A valid bearer from a LAN peer → rejected on the admin API.
|
||||
assert_eq!(
|
||||
app.clone()
|
||||
.oneshot(bearer(lan))
|
||||
.await
|
||||
.expect("infallible")
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a bearer token from a LAN peer must be rejected on the admin API"
|
||||
);
|
||||
// The SAME token from a loopback peer (the web console BFF) → accepted.
|
||||
assert_ne!(
|
||||
app.clone()
|
||||
.oneshot(bearer(loopback))
|
||||
.await
|
||||
.expect("infallible")
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the bearer token must be accepted from a loopback peer"
|
||||
);
|
||||
|
||||
// A paired cert from a LAN peer still reaches the read-only library (the feature this enables).
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(
|
||||
std::env::temp_dir()
|
||||
.join(format!("pf-mgmt-lanlib-{}.json", std::process::id())),
|
||||
),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let fp = "deadbeefcafe";
|
||||
np.add("lan-client", fp).unwrap();
|
||||
let app = test_app_native(test_state(), np);
|
||||
let mut req = get_req("/api/v1/library");
|
||||
req.extensions_mut().insert(PeerAddr(lan));
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
assert_ne!(
|
||||
app.clone().oneshot(req).await.expect("infallible").status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a paired cert must reach the library from a LAN peer"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_open_and_versioned() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
@@ -121,7 +121,8 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> {
|
||||
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
|
||||
// recorder across mgmt + both streaming paths instead.
|
||||
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
rt.block_on(serve(opts, np, stats))
|
||||
// Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0).
|
||||
rt.block_on(serve(opts, 0, np, stats))
|
||||
}
|
||||
|
||||
fn fingerprint_hex(fp: &[u8; 32]) -> String {
|
||||
@@ -139,6 +140,9 @@ pub(crate) struct NativeServe {
|
||||
/// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on
|
||||
/// demand from the web console (arm → PIN); paired devices persist.
|
||||
pub require_pairing: bool,
|
||||
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
|
||||
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
|
||||
pub mgmt_port: u16,
|
||||
}
|
||||
|
||||
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
|
||||
@@ -166,6 +170,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
||||
|
||||
pub(crate) async fn serve(
|
||||
opts: Punktfunk1Options,
|
||||
mgmt_port: u16,
|
||||
np: Arc<NativePairing>,
|
||||
stats: Arc<StatsRecorder>,
|
||||
) -> Result<()> {
|
||||
@@ -198,6 +203,8 @@ pub(crate) async fn serve(
|
||||
&fingerprint_hex(&fingerprint),
|
||||
opts.require_pairing,
|
||||
&h.uniqueid,
|
||||
// 0 = standalone `punktfunk1-host` (no mgmt API) → don't advertise an `mgmt` port.
|
||||
(mgmt_port != 0).then_some(mgmt_port),
|
||||
)
|
||||
.map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)"))
|
||||
.ok(),
|
||||
@@ -3864,6 +3871,7 @@ mod tests {
|
||||
pairing_pin: None,
|
||||
paired_store: None, // unused: the shared `np` IS the store handle
|
||||
},
|
||||
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
|
||||
np_host,
|
||||
StatsRecorder::new(
|
||||
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
|
||||
|
||||
@@ -226,7 +226,8 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
bail!("web launcher missing: {}", cmd.display());
|
||||
}
|
||||
register_web_task(&cmd)?;
|
||||
// 4. firewall: inbound TCP 3000
|
||||
// 4. firewall: inbound TCP 3000. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
|
||||
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
|
||||
if !run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
@@ -251,7 +252,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
println!("web console set up + started (http://<host-ip>:3000)");
|
||||
println!("web console set up + started (https://<host-ip>:3000)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user