feat(host): unified host + native pairing over the management API
`serve --native` now runs the GameStream host AND the native punktfunk/1 (QUIC) host in ONE process, sharing a single NativePairing handle with the management API — so native pairing is operable from the web console instead of journalctl. - gamestream::serve gains a native_port: spawns crate::m3::serve in the same runtime and passes the shared NativePairing to mgmt::run. Validated live: one process binds both RTSP 48010 and QUIC 9777. - mgmt API: new `native` endpoints — GET /native/pair (status), POST /native/pair/arm (mint a fresh, time-limited PIN to DISPLAY), DELETE /native/pair (disarm), GET/DELETE /native/clients (list/unpair). GameStream-only hosts report enabled:false. OpenAPI regenerated (checked-in doc + drift test). - main.rs: serve --native / --native-port flags. The native host arms pairing on demand (the operator reads the PIN from the console; the SPAKE2 ceremony is host-shows-PIN). New mgmt + native_pairing tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,17 +144,30 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the GameStream control plane (blocks): mDNS advertisement, the nvhttp servers, and
|
||||
/// the management REST API.
|
||||
pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> {
|
||||
/// Run the host (blocks): mDNS, the nvhttp servers, and the management REST API.
|
||||
/// `native_port = Some(p)` makes this the **unified** host — it also runs the native punktfunk/1
|
||||
/// QUIC server on `p` in the same process, sharing one [`crate::native_pairing`] handle with the
|
||||
/// management API so the web console can arm pairing and show the PIN. `None` = GameStream only
|
||||
/// (the mgmt API's native endpoints report `enabled: false`).
|
||||
pub fn serve(mgmt: crate::mgmt::Options, native_port: Option<u16>) -> Result<()> {
|
||||
let host = Host::detect()?;
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
let state = Arc::new(AppState::new(host, identity));
|
||||
// The shared native-pairing handle exists only when we run the native host; it links the QUIC
|
||||
// ceremony and the management API.
|
||||
let native = match native_port {
|
||||
Some(_) => Some(Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
ip = %state.host.local_ip,
|
||||
"punktfunk GameStream host (P1.1: serverinfo + pairing + mDNS)"
|
||||
native = native_port.is_some(),
|
||||
"punktfunk host (GameStream P1.1: serverinfo + pairing + mDNS)"
|
||||
);
|
||||
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
||||
rt.block_on(async move {
|
||||
@@ -163,7 +176,22 @@ pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> {
|
||||
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
|
||||
rtsp::spawn(state.clone()).context("start RTSP server")?;
|
||||
control::spawn(state.clone()).context("start ENet control server")?;
|
||||
tokio::try_join!(nvhttp::run(state.clone()), crate::mgmt::run(state, mgmt))?;
|
||||
match (native_port, native) {
|
||||
(Some(port), Some(np)) => {
|
||||
tracing::info!(port, "unified host: also serving native punktfunk/1 (QUIC)");
|
||||
tokio::try_join!(
|
||||
nvhttp::run(state.clone()),
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())),
|
||||
crate::m3::serve(crate::m3::native_serve_opts(port), np),
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
tokio::try_join!(
|
||||
nvhttp::run(state.clone()),
|
||||
crate::mgmt::run(state, mgmt, None)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,24 @@ fn fingerprint_hex(fp: &[u8; 32]) -> String {
|
||||
/// served one at a time (the virtual output + NVENC are single-tenant); a client that
|
||||
/// connects mid-session waits in the accept queue. A failed session logs and the loop
|
||||
/// keeps serving — only endpoint-level failures are fatal.
|
||||
async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
|
||||
/// Default options for the native host when the unified `serve --native` runs it in-process:
|
||||
/// real virtual capture, persistent (no session/duration cut), pairing armed on demand via the
|
||||
/// management API (the shared [`NativePairing`] starts disarmed).
|
||||
pub(crate) fn native_serve_opts(port: u16) -> M3Options {
|
||||
M3Options {
|
||||
port,
|
||||
source: M3Source::Virtual,
|
||||
seconds: 7 * 24 * 3600, // per-session cap; large enough not to cut a live stream
|
||||
frames: 0,
|
||||
max_sessions: 0,
|
||||
require_pairing: false,
|
||||
allow_pairing: false,
|
||||
pairing_pin: None,
|
||||
paired_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
|
||||
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
|
||||
.context("load host identity (~/.config/punktfunk)")?;
|
||||
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
||||
|
||||
@@ -56,8 +56,12 @@ fn real_main() -> Result<()> {
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
// M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API.
|
||||
Some("serve") => gamestream::serve(parse_serve(&args[1..])?),
|
||||
// GameStream host control plane (P1.1: mDNS + serverinfo) + management API, and (with
|
||||
// --native) the native punktfunk/1 host in the same process — the unified host.
|
||||
Some("serve") => {
|
||||
let (mgmt_opts, native_port) = parse_serve(&args[1..])?;
|
||||
gamestream::serve(mgmt_opts, native_port)
|
||||
}
|
||||
// Print the management API's OpenAPI document (for client codegen).
|
||||
Some("openapi") => {
|
||||
print!("{}", mgmt::openapi_json());
|
||||
@@ -220,9 +224,12 @@ fn input_test() -> Result<()> {
|
||||
bail!("input-test requires Linux")
|
||||
}
|
||||
|
||||
/// `serve` options — all about the management API; the GameStream ports are protocol-fixed.
|
||||
fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
||||
/// `serve` options: the management API (GameStream ports are protocol-fixed) + whether to also run
|
||||
/// the native punktfunk/1 host in-process (`--native`, the unified host). Returns the mgmt options
|
||||
/// and the native QUIC port (`None` = GameStream only).
|
||||
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<u16>)> {
|
||||
let mut opts = mgmt::Options::default();
|
||||
let mut native_port: Option<u16> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
@@ -248,6 +255,16 @@ fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
||||
}
|
||||
opts.token = Some(token);
|
||||
}
|
||||
// Also run the native punktfunk/1 (QUIC) host in this process — the unified host.
|
||||
// Pairing is then armed on demand from the management API / web console.
|
||||
"--native" => native_port = Some(native_port.unwrap_or(9777)),
|
||||
"--native-port" => {
|
||||
native_port = Some(
|
||||
next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?,
|
||||
)
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
@@ -262,7 +279,7 @@ fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty());
|
||||
}
|
||||
Ok(opts)
|
||||
Ok((opts, native_port))
|
||||
}
|
||||
|
||||
fn parse_m0(args: &[String]) -> Result<Options> {
|
||||
@@ -378,6 +395,9 @@ 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
|
||||
--native also run the native punktfunk/1 (QUIC) host in this process —
|
||||
the unified host; pairing is armed from the management API/console
|
||||
--native-port <PORT> native QUIC port (default 9777; implies --native)
|
||||
|
||||
M3-HOST OPTIONS:
|
||||
--port <N> QUIC listen port (default: 9777)
|
||||
|
||||
@@ -61,13 +61,21 @@ impl Default for Options {
|
||||
/// Axum state for the management routes: the shared control-plane state + auth config.
|
||||
struct MgmtState {
|
||||
app: Arc<AppState>,
|
||||
/// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native`
|
||||
/// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`).
|
||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||
token: Option<String>,
|
||||
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
||||
port: u16,
|
||||
}
|
||||
|
||||
/// Run the management API server (control plane; spawned alongside the nvhttp servers).
|
||||
pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
|
||||
/// Run the management API server (control plane; spawned alongside the nvhttp servers). `native`
|
||||
/// is the shared punktfunk/1 pairing handle when the unified host runs the native QUIC server.
|
||||
pub async fn run(
|
||||
state: Arc<AppState>,
|
||||
opts: Options,
|
||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||
) -> Result<()> {
|
||||
// A blank token is no token: it must neither satisfy the non-loopback guard below nor
|
||||
// become a credential an empty `Authorization: Bearer ` header would match.
|
||||
let token = opts.token.filter(|t| !t.trim().is_empty());
|
||||
@@ -83,7 +91,7 @@ pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
|
||||
auth = if token.is_some() { "bearer" } else { "none (loopback)" },
|
||||
"management API listening (docs at /api/docs, spec at /api/v1/openapi.json)"
|
||||
);
|
||||
let app = app(state, token, opts.bind.port());
|
||||
let app = app(state, token, opts.bind.port(), native);
|
||||
axum_server::bind(opts.bind)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
@@ -91,9 +99,15 @@ pub async fn run(state: Arc<AppState>, opts: Options) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Compose the full management router (also used directly by the handler tests).
|
||||
fn app(state: Arc<AppState>, token: Option<String>, port: u16) -> Router {
|
||||
fn app(
|
||||
state: Arc<AppState>,
|
||||
token: Option<String>,
|
||||
port: u16,
|
||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||
) -> Router {
|
||||
let shared = Arc::new(MgmtState {
|
||||
app: state,
|
||||
native,
|
||||
token,
|
||||
port,
|
||||
});
|
||||
@@ -126,6 +140,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(unpair_client))
|
||||
.routes(routes!(get_pairing_status))
|
||||
.routes(routes!(submit_pairing_pin))
|
||||
.routes(routes!(get_native_pairing))
|
||||
.routes(routes!(arm_native_pairing))
|
||||
.routes(routes!(disarm_native_pairing))
|
||||
.routes(routes!(list_native_clients))
|
||||
.routes(routes!(unpair_native_client))
|
||||
.routes(routes!(stop_session))
|
||||
.routes(routes!(request_idr)),
|
||||
)
|
||||
@@ -156,6 +175,7 @@ pub fn openapi_json() -> String {
|
||||
(name = "host", description = "Host identity, capabilities, and liveness"),
|
||||
(name = "clients", description = "Paired Moonlight client management"),
|
||||
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
|
||||
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
||||
(name = "session", description = "Active streaming session control"),
|
||||
)
|
||||
)]
|
||||
@@ -323,6 +343,42 @@ struct SubmitPin {
|
||||
pin: String,
|
||||
}
|
||||
|
||||
/// Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2
|
||||
/// ceremony needs it client-side first), so the console **displays** `pin` for the user to enter on
|
||||
/// their device — armed on demand for a short window.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct NativePairStatus {
|
||||
/// Whether the native host is running (the unified host started with `--native`).
|
||||
enabled: bool,
|
||||
/// True while a pairing window is open.
|
||||
armed: bool,
|
||||
/// The PIN to display while armed (null when disarmed).
|
||||
#[schema(example = "1234")]
|
||||
pin: Option<String>,
|
||||
/// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).
|
||||
expires_in_secs: Option<u64>,
|
||||
/// Number of paired native clients.
|
||||
paired_clients: u32,
|
||||
}
|
||||
|
||||
/// Arm-native-pairing request body.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
struct ArmNativePairing {
|
||||
/// Window length in seconds (default 120; clamped to 15–600).
|
||||
#[schema(example = 120)]
|
||||
ttl_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// A paired native (punktfunk/1) client.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct NativeClient {
|
||||
/// The name the client supplied when pairing.
|
||||
#[schema(example = "Living Room iPad")]
|
||||
name: String,
|
||||
/// Hex SHA-256 of the client certificate — its stable id here.
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// Error envelope for every non-2xx response.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
struct ApiError {
|
||||
@@ -667,6 +723,168 @@ async fn submit_pairing_pin(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
fn native_status(st: &MgmtState) -> NativePairStatus {
|
||||
match &st.native {
|
||||
Some(np) => {
|
||||
let s = np.status();
|
||||
NativePairStatus {
|
||||
enabled: true,
|
||||
armed: s.armed,
|
||||
pin: s.pin,
|
||||
expires_in_secs: s.expires_in_secs,
|
||||
paired_clients: s.paired_clients,
|
||||
}
|
||||
}
|
||||
None => NativePairStatus {
|
||||
enabled: false,
|
||||
armed: false,
|
||||
pin: None,
|
||||
expires_in_secs: None,
|
||||
paired_clients: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Native pairing status
|
||||
///
|
||||
/// The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.
|
||||
/// `enabled: false` means this host runs GameStream only (no `--native`).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/native/pair",
|
||||
tag = "native",
|
||||
operation_id = "getNativePairing",
|
||||
responses(
|
||||
(status = OK, description = "Native pairing status", body = NativePairStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn get_native_pairing(State(st): State<Arc<MgmtState>>) -> Json<NativePairStatus> {
|
||||
Json(native_status(&st))
|
||||
}
|
||||
|
||||
/// Arm native pairing
|
||||
///
|
||||
/// Opens a pairing window and mints a fresh PIN to display. The user enters it on their device
|
||||
/// within `ttl_secs`; the device then appears in the native client list.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/native/pair/arm",
|
||||
tag = "native",
|
||||
operation_id = "armNativePairing",
|
||||
request_body = ArmNativePairing,
|
||||
responses(
|
||||
(status = OK, description = "Pairing armed; the response carries the PIN to display", body = NativePairStatus),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled (run `serve --native`)", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn arm_native_pairing(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
ApiJson(req): ApiJson<ArmNativePairing>,
|
||||
) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"native host not enabled (run `serve --native`)",
|
||||
);
|
||||
};
|
||||
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
|
||||
let _pin = np.arm(std::time::Duration::from_secs(ttl as u64));
|
||||
tracing::info!(ttl_secs = ttl, "management API: native pairing armed");
|
||||
Json(native_status(&st)).into_response()
|
||||
}
|
||||
|
||||
/// Disarm native pairing
|
||||
///
|
||||
/// Closes the pairing window immediately (no new ceremonies accepted).
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/native/pair",
|
||||
tag = "native",
|
||||
operation_id = "disarmNativePairing",
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Pairing disarmed"),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn disarm_native_pairing(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
np.disarm();
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// List native paired clients
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/native/clients",
|
||||
tag = "native",
|
||||
operation_id = "listNativeClients",
|
||||
responses(
|
||||
(status = OK, description = "Paired native clients", body = [NativeClient]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn list_native_clients(State(st): State<Arc<MgmtState>>) -> Json<Vec<NativeClient>> {
|
||||
let clients = match &st.native {
|
||||
Some(np) => np
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|c| NativeClient {
|
||||
name: c.name,
|
||||
fingerprint: c.fingerprint,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Json(clients)
|
||||
}
|
||||
|
||||
/// Unpair a native client
|
||||
///
|
||||
/// Removes a punktfunk/1 client from the native trust store by fingerprint.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/native/clients/{fingerprint}",
|
||||
tag = "native",
|
||||
operation_id = "unpairNativeClient",
|
||||
params(
|
||||
("fingerprint" = String, Path,
|
||||
description = "Hex SHA-256 of the client certificate (case-insensitive)")
|
||||
),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Client unpaired"),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No paired native client with that fingerprint", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn unpair_native_client(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
match np.remove(&fingerprint) {
|
||||
Ok(true) => {
|
||||
tracing::info!(fingerprint, "management API: native client unpaired");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Ok(false) => api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"no paired native client with that fingerprint",
|
||||
),
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not persist trust store: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the active session
|
||||
///
|
||||
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
||||
@@ -739,7 +957,14 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
||||
app(state, token.map(String::from), DEFAULT_PORT)
|
||||
app(state, token.map(String::from), DEFAULT_PORT, None)
|
||||
}
|
||||
|
||||
fn test_app_native(
|
||||
state: Arc<AppState>,
|
||||
np: Arc<crate::native_pairing::NativePairing>,
|
||||
) -> Router {
|
||||
app(state, None, DEFAULT_PORT, Some(np))
|
||||
}
|
||||
|
||||
async fn send(app: &Router, req: axum::http::Request<Body>) -> (StatusCode, serde_json::Value) {
|
||||
@@ -957,7 +1182,7 @@ mod tests {
|
||||
bind: "0.0.0.0:0".parse().unwrap(),
|
||||
token: Some(" ".into()),
|
||||
};
|
||||
let err = run(test_state(), opts).await.unwrap_err();
|
||||
let err = run(test_state(), opts, None).await.unwrap_err();
|
||||
assert!(err.to_string().contains("not loopback"), "{err}");
|
||||
}
|
||||
|
||||
@@ -1048,4 +1273,89 @@ mod tests {
|
||||
cargo run -p punktfunk-host -- openapi > docs/api/openapi.json"
|
||||
);
|
||||
}
|
||||
|
||||
fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
|
||||
axum::http::Request::post(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_pairing_arm_show_and_unpair() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-np-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = test_app_native(test_state(), np.clone());
|
||||
|
||||
// Disarmed: enabled, not armed, no PIN.
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["enabled"], true);
|
||||
assert_eq!(b["armed"], false);
|
||||
assert!(b["pin"].is_null());
|
||||
|
||||
// Arm → a PIN appears and is readable via status.
|
||||
let (s, b) = send(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/native/pair/arm",
|
||||
serde_json::json!({"ttl_secs": 60}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["armed"], true);
|
||||
let pin = b["pin"].as_str().unwrap().to_string();
|
||||
assert_eq!(pin.len(), 4);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(b["pin"], pin);
|
||||
assert!(b["expires_in_secs"].as_u64().unwrap() <= 60);
|
||||
|
||||
// The QUIC side would read the same live PIN.
|
||||
assert_eq!(np.current_pin().as_deref(), Some(pin.as_str()));
|
||||
|
||||
// Pair a client out-of-band, then it shows in the list + can be unpaired.
|
||||
np.add("Test Device", "abc123").unwrap();
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/clients")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b[0]["name"], "Test Device");
|
||||
assert_eq!(b[0]["fingerprint"], "abc123");
|
||||
let del = axum::http::Request::delete("/api/v1/native/clients/ABC123")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
let missing = axum::http::Request::delete("/api/v1/native/clients/abc123")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, missing).await.0, StatusCode::NOT_FOUND);
|
||||
|
||||
// Disarm clears the window.
|
||||
let del = axum::http::Request::delete("/api/v1/native/pair")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(b["armed"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_endpoints_report_disabled_without_native_host() {
|
||||
let app = test_app(test_state(), None);
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["enabled"], false);
|
||||
// Arming a host that isn't running the native server is a 503.
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/native/pair/arm", serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user