From 6bdb6a121678f3216cb74a0b607e2391fd82a225 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Wed, 28 Oct 2026 09:06:35 -0500 Subject: [PATCH] Add dashboard control of the host Apollo's USB forwarding (phase 5c) The host card gets a USB forwarding panel: switch the host Apollo's tunnel on or off, see what is forwarded, block a client or allow it again. The service talks to Apollo's local control pipe and never edits Apollo's config. A switch lasts until that Apollo restarts. Because the API has no key on a host that chose that, and every seat user can reach it on loopback, the whole /api/host/usb group, reading included (the status names paired clients and their devices), is served only to the person at the console. The check: - finds the owner of the request's loopback connection in the system TCP table and holds a handle to that process for the whole check; - refuses a process created after the connection (a reused process id), and a zero timestamp; - reads the table twice and requires the same owner and console session; - requires the console session and the user logged on there, and refuses service accounts and MultiSeat's seat accounts, so a seat user who moves their session to the console is still refused. Every doubt refuses. Refusals are logged once a minute per outcome. The pipe client follows Apollo's pipe contract: identification-level open, owner SYSTEM or Administrators, server process = the listener of Apollo's main port, bounded I/O. Tests: 1927 passed, 0 failed, 18 skipped; dashboard 82/82. Each protection was removed on purpose and the matching tests failed. Not yet tried against a real browser on the console versus a seat, and not deployed. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 + README.md | 7 + docs/security-posture.md | 43 ++ src/MultiSeat.Dashboard/src/api/client.ts | 29 ++ src/MultiSeat.Dashboard/src/api/types.ts | 51 +++ .../src/components/HostCard.tsx | 5 + .../src/components/HostUsbPanel.tsx | 235 +++++++++++ .../src/hostUsbPanel.test.tsx | 193 +++++++++ src/MultiSeat.Service/Api/ApiServer.cs | 3 + .../Api/ConsoleCallerCheck.cs | 147 +++++++ src/MultiSeat.Service/Api/HostUsbEndpoints.cs | 126 ++++++ .../HostUsb/ApolloUsbControlClient.cs | 267 ++++++++++++ .../HostUsb/HostUsbControl.cs | 265 ++++++++++++ src/MultiSeat.Service/Interop/OwnerProcess.cs | 88 ++++ src/MultiSeat.Service/Interop/PipeApi.cs | 43 ++ src/MultiSeat.Service/Interop/TcpTable.cs | 254 +++++++++++ .../Monitoring/HostApolloMonitor.cs | 11 + src/MultiSeat.Service/Program.cs | 5 + .../HostUsb/ApolloUsbControlClientTests.cs | 315 ++++++++++++++ .../HostUsb/ConsoleCallerCheckTests.cs | 252 +++++++++++ .../HostUsb/HostUsbControlTests.cs | 231 ++++++++++ .../HostUsb/HostUsbEndpointsTests.cs | 397 ++++++++++++++++++ src/MultiSeat.Tests/HostUsb/TcpTableTests.cs | 247 +++++++++++ 23 files changed, 3216 insertions(+) create mode 100644 src/MultiSeat.Dashboard/src/components/HostUsbPanel.tsx create mode 100644 src/MultiSeat.Dashboard/src/hostUsbPanel.test.tsx create mode 100644 src/MultiSeat.Service/Api/ConsoleCallerCheck.cs create mode 100644 src/MultiSeat.Service/Api/HostUsbEndpoints.cs create mode 100644 src/MultiSeat.Service/HostUsb/ApolloUsbControlClient.cs create mode 100644 src/MultiSeat.Service/HostUsb/HostUsbControl.cs create mode 100644 src/MultiSeat.Service/Interop/OwnerProcess.cs create mode 100644 src/MultiSeat.Service/Interop/PipeApi.cs create mode 100644 src/MultiSeat.Service/Interop/TcpTable.cs create mode 100644 src/MultiSeat.Tests/HostUsb/ApolloUsbControlClientTests.cs create mode 100644 src/MultiSeat.Tests/HostUsb/ConsoleCallerCheckTests.cs create mode 100644 src/MultiSeat.Tests/HostUsb/HostUsbControlTests.cs create mode 100644 src/MultiSeat.Tests/HostUsb/HostUsbEndpointsTests.cs create mode 100644 src/MultiSeat.Tests/HostUsb/TcpTableTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index e94ac32..5ba6d10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,8 @@ MultiSeat runs multiple simultaneous Moonlight game-streaming sessions on one Wi | `GitHubReleaseClient` | The only code that would talk to the internet: unauthenticated GET of three release lists, every failure returned not thrown | | `UpdateStateStore` | `C:\ProgramData\MultiSeat\update-check.json` cache (ETags, candidates, baseline, backoff, hash cache) | | `UpdateEndpoints` | `GET /api/system/updates`, `POST .../updates/check`, `POST .../updates/settings` | +| `HostUsbControl` / `ApolloUsbControlClient` | Talk to the **host** Apollo's USB control pipe (opt-in in Apollo). The client follows Apollo's pipe contract: identification-level open, owner SYSTEM or Administrators, server PID = the process listening on Apollo's main port, bounded I/O. Never edits Apollo's config | +| `HostUsbEndpoints` / `ConsoleCallerCheck` | `GET /api/host/usb` and `POST /api/host/usb/{enabled,revoke,clear-revoke}`. A filter on the whole route group, reading included, serves only a process of the user logged on at the active console session, never a seat account (loopback source port to owner process and connection time, owner handle held, table read twice); everything doubtful is refused. See `docs/security-posture.md` | ## Port Layout diff --git a/README.md b/README.md index cac3c08..f62e4e1 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,13 @@ default or unattended run. You ask for it: - **Seats keep USB forwarding off.** MultiSeat writes `usb_forwarding_enabled = disabled` into every seat's Apollo config, because a forwarded device is machine-wide, not per session. Only the console ApolloVibe can use it, and only when you enable it there. +- **Dashboard control of the console Apollo.** The host card has a "USB forwarding" panel: switch the tunnel on or off, + see what is forwarded, block a client or allow it again. It talks to a Windows named pipe that ApolloVibe creates + only when you tick "USB Forwarding Control Pipe" in its own Network tab and restart it, so the console Apollo must run + as SYSTEM (the normal case) for the pipe to exist. MultiSeat never edits that Apollo's config, a switch made here lasts + until that Apollo restarts, and the panel is served only to the host's own desktop (reading too, because the status + names paired clients and their devices). See + [security-posture.md](docs/security-posture.md#host-usb-forwarding-control-dashboard). - **Port reservation.** ApolloVibe's USB tunnel listens on its main port + 7 (TCP). That offset is reserved in every seat's port block and checked against all other ports, but MultiSeat does not open it in the firewall. That check assumes the default console Apollo port (47989) and `PortBase` (48100). If you override either in diff --git a/docs/security-posture.md b/docs/security-posture.md index 5772d24..fea8626 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -116,6 +116,49 @@ provisioning that nobody is there to dismiss — which usually shows up as a sea toggle before it holds a key. `POST` on that path — the call that turns authentication off — is gated, and a test holds that distinction in place. +## Host USB forwarding control (dashboard) + +The dashboard's host card can switch the **host's own Apollo** USB tunnel on or off and block a client's USB +forwarding. It does this through a Windows named pipe that Apollo creates only when its owner ticks +"USB Forwarding Control Pipe" in Apollo's own settings. MultiSeat never edits that Apollo's config, and a +change made here lasts only until that Apollo restarts. + +- **Reading and changing are both for the console only.** This API has no key on a host that chose that, and + every seat user can reach it on loopback. A seat user could otherwise switch the host's USB tunnel on through + this SYSTEM service (the pipe's access list cannot help: the service is the legitimate client), and could read + the status, which names paired clients and the devices they forward. A filter on the whole `/api/host/usb` + route group, so every route now or later, `GET` included, checks where the request came from. A refusal is + 403 `console_session_required`, and the request never reaches the pipe. + - The source port of the loopback connection names the client process in the system TCP table, with the time + the connection was created. The service opens that process and **keeps the handle open for the whole + check**, so its id cannot be given to another process meanwhile. The table names the process that + *created* the socket even after it has exited, so a process **created after the connection** cannot be the + one that made it. A reused process id is refused, and so is a zero timestamp. The comparison is strict. + - The table is read again once the handle is held and must name the same owner and connection, and the + console session must not have moved. + - The process must run in the **active console session**, and its token user must be **the user logged on + there**, not SYSTEM or another service account, and **not one of MultiSeat's seat accounts**. A seat user who + moves their own session to the console with `tscon` is the console user but a seat account, so is still + refused. A seat account MultiSeat cannot list is not excluded by that rule; it is still refused by the + session and user comparisons unless it is the console user. + - A request that is not loopback, whose caller cannot be found, or when nobody is at the console, is refused. + Another program of the console user can still ask; that is the trust the console user already has over the + host. A console-user program that hands a connected socket to a program in another session is not + detected, because the table names the creator. + - The check is by session and user, so it needs the dashboard opened on the host's own desktop (for example + over a remote-desktop tool). A dashboard on another PC, or in a seat, cannot see or change USB forwarding; + no key is offered for that. Its USB card says so. + - Refusals are logged at most once a minute per outcome, with a count of the rest, because the dashboard polls. +- **The service does not trust the pipe's name.** Before sending anything it opens the pipe with + `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION` (so a fake server can never act as SYSTEM), requires the + pipe's **owner** to be SYSTEM or Administrators, and requires the pipe's **server process** to be the one + that listens on the host Apollo's main port. A standard user can create a pipe with the same name while + Apollo is stopped; with these checks the service refuses it (`untrusted_server`) and sends nothing. Every + read and write is bounded, one conversation runs at a time, and a close with no reply is reported as busy. +- **Not in scope:** other administrators and SYSTEM-level processes (an administrator can already stop Apollo + and take the name). The blocklist is Apollo's, in memory, and does not unpair a client or change its stored + permission. + ## Outbound connections MultiSeat makes **no** connection to the internet unless you turn on update checks. With `MultiSeat:UpdateCheckEnabled` left at its default of `false`, the service only talks to loopback addresses (Apollo on this host). diff --git a/src/MultiSeat.Dashboard/src/api/client.ts b/src/MultiSeat.Dashboard/src/api/client.ts index 729f833..21682e6 100644 --- a/src/MultiSeat.Dashboard/src/api/client.ts +++ b/src/MultiSeat.Dashboard/src/api/client.ts @@ -18,6 +18,10 @@ import type { HostApolloInfo, ScaleChange, UpdatesState, + UsbState, + UsbSetEnabledResult, + UsbRevokeResult, + UsbClearResult, } from "./types"; const BASE = "/api"; @@ -225,6 +229,31 @@ export const host = { get: () => request("/host"), }; +// ── Host USB forwarding (the host Apollo's control pipe) ───────── + +export const hostUsb = { + get: () => request("/host/usb"), + + /** Lasts until the host's Apollo restarts. Refused unless the request comes from the host's own desktop. */ + setEnabled: (enabled: boolean) => + request("/host/usb/enabled", { + method: "POST", + body: JSON.stringify({ enabled }), + }), + + revoke: (clientUuid: string) => + request("/host/usb/revoke", { + method: "POST", + body: JSON.stringify({ clientUuid }), + }), + + clearRevoke: (clientUuid: string) => + request("/host/usb/clear-revoke", { + method: "POST", + body: JSON.stringify({ clientUuid }), + }), +}; + // ── Input ───────────────────────────────────────────────────────── export const input = { diff --git a/src/MultiSeat.Dashboard/src/api/types.ts b/src/MultiSeat.Dashboard/src/api/types.ts index 2c51674..f7da19e 100644 --- a/src/MultiSeat.Dashboard/src/api/types.ts +++ b/src/MultiSeat.Dashboard/src/api/types.ts @@ -338,3 +338,54 @@ export interface UpdatesState { error: string | null; components: UpdateComponent[]; } + +// ── Host Apollo: USB forwarding ─────────────────────────────────── + +/** One USB device a paired client is forwarding to the host right now. */ +export interface UsbForward { + clientUuid: string; + clientName: string; + busid: string; + sinceUnix: number; +} + +/** A client paired with the host's Apollo, and whether it may forward USB devices. */ +export interface UsbClient { + uuid: string; + name: string; + usbPermission: boolean; +} + +export interface UsbStatus { + enabled: boolean; + listening: boolean; + port: number; + requireStream: boolean; + forwards: UsbForward[]; + clients: UsbClient[]; + /** Client uuids blocked from USB forwarding until Apollo restarts. */ + denied: string[]; +} + +/** GET /api/host/usb: "not available" is a state to show, not an error. */ +export type UsbState = + | { available: true; status: UsbStatus } + | { available: false; reason: string }; + +export interface UsbSetEnabledResult { + enabled: boolean; + listening: boolean; + port: number; + persisted: boolean; +} + +export interface UsbRevokeResult { + forwardsCut: number; + denied: boolean; + known: boolean; +} + +export interface UsbClearResult { + denied: boolean; + known: boolean; +} diff --git a/src/MultiSeat.Dashboard/src/components/HostCard.tsx b/src/MultiSeat.Dashboard/src/components/HostCard.tsx index b26a15a..2c8ac86 100644 --- a/src/MultiSeat.Dashboard/src/components/HostCard.tsx +++ b/src/MultiSeat.Dashboard/src/components/HostCard.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import type { HostApolloInfo } from "../api/types"; import { host as hostApi } from "../api/client"; +import { HostUsbPanel } from "./HostUsbPanel"; /** * The console's own Apollo, shown the way a seat is. @@ -10,6 +11,8 @@ import { host as hostApi } from "../api/client"; * the operator actually uses the only one they could not see here. * * Read-only on purpose: this process is not ours to start, stop or reconfigure. + * The one exception is the USB forwarding panel, which switches a tunnel the host owner already allowed + * in Apollo itself; see HostUsbPanel. */ export function HostCard() { const [info, setInfo] = useState(null); @@ -151,6 +154,8 @@ export function HostCard() { {info.note} )} + + {info.detected && } ); diff --git a/src/MultiSeat.Dashboard/src/components/HostUsbPanel.tsx b/src/MultiSeat.Dashboard/src/components/HostUsbPanel.tsx new file mode 100644 index 0000000..1031bae --- /dev/null +++ b/src/MultiSeat.Dashboard/src/components/HostUsbPanel.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useState } from "react"; +import type { UsbClient, UsbStatus } from "../api/types"; +import { ApiError, hostUsb } from "../api/client"; + +/** + * USB forwarding of the host's own Apollo: what is forwarded now, who may do it, and the switches. + * + * The service serves this panel, reading and changing alike, only when the request comes from the + * host's own desktop: the status names paired clients and the devices they forward. From anywhere else + * it answers 403 and this panel says so in plain words instead of showing a code. + * + * Nothing here edits Apollo's settings. A switch made here lasts until that Apollo restarts; what it + * starts as is the "USB Forwarding" setting in Apollo's own Network tab. + */ + +/** What a "not available" reason from the service means, for the person looking at the card. */ +export function describeUnavailable(reason: string): string { + switch (reason) { + case "console_session_required": + return "USB forwarding can only be seen and changed from the host's own desktop. Open this dashboard there."; + case "pipe_unavailable": + return "Not available yet. In Apollo's web UI, open the Network tab, tick “USB Forwarding Control Pipe”, save, and restart Apollo."; + case "no_host_apollo": + return "Apollo is not running on this host, so there is nothing to control."; + case "untrusted_server": + return "Refused. The control pipe is not served by the Apollo that streams on this host, so nothing was sent to it."; + case "busy": + case "timeout": + return "Apollo did not answer in time. This panel will try again in a few seconds."; + case "bad_reply": + return "Apollo sent an answer this version of MultiSeat does not understand. Update MultiSeat and Apollo together."; + default: + return "USB forwarding cannot be controlled right now."; + } +} + +/** What a failed action means. 403 is the one people will meet: the request was not from the host's desktop. */ +export function describeActionError(e: unknown): string { + if (e instanceof ApiError) { + if (e.status === 403) { + return "USB forwarding can only be changed from the host's own desktop. Open this dashboard there and try again."; + } + switch (e.message) { + case "port_refused": + return "Apollo refused to start it: the USB forwarding port is out of range or one that Apollo already uses."; + case "start_failed": + return "Apollo could not start the USB tunnel. Its log says why."; + case "deny_list_full": + return "The list of blocked clients is full, so this client could not be added. Its running devices were still cut."; + case "shutting_down": + return "Apollo is shutting down."; + default: + return describeUnavailable(e.message); + } + } + return e instanceof Error ? e.message : "The request failed."; +} + +export function HostUsbPanel() { + const [state, setState] = useState<{ available: true; status: UsbStatus } | { available: false; reason: string } | null>(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + const load = useCallback(async () => { + try { + setState(await hostUsb.get()); + } catch (e) { + setState({ available: false, reason: e instanceof ApiError ? e.message : "bad_reply" }); + } + }, []); + + useEffect(() => { + load(); + const t = setInterval(load, 10000); + return () => clearInterval(t); + }, [load]); + + const act = async (action: () => Promise) => { + setPending(true); + setError(null); + try { + await action(); + } catch (e) { + setError(describeActionError(e)); + } finally { + setPending(false); + await load(); + } + }; + + if (!state) return null; + + return ( +
+
+ USB forwarding + {state.available && ( + + )} +
+ + {!state.available && ( +
+ {describeUnavailable(state.reason)} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {state.available &&
} +
+ ); +} + +function Details({ + status, + pending, + act, +}: { + status: UsbStatus; + pending: boolean; + act: (action: () => Promise) => Promise; +}) { + const deniedSet = new Set(status.denied.map((u) => u.toUpperCase())); + const pairedSet = new Set(status.clients.map((c) => c.uuid.toUpperCase())); + const strangers = status.denied.filter((u) => !pairedSet.has(u.toUpperCase())); + + return ( + <> +
+ {status.enabled + ? status.listening + ? `On, listening on port ${status.port}.` + : "On, but not listening. Apollo's log says why." + : "Off."} + {status.requireStream ? " A client needs an active stream to forward a device." : ""} +
+
+ A switch made here lasts until Apollo restarts. What it starts as is the “USB Forwarding” setting in + Apollo's Network tab. Each client also needs the USB permission, which you give in Apollo's client list. +
+ +

Devices forwarded now

+ {status.forwards.length === 0 ? ( +
None.
+ ) : ( +
    + {status.forwards.map((f) => ( +
  • + {f.clientName || f.clientUuid} · device {f.busid} · since{" "} + {new Date(f.sinceUnix * 1000).toLocaleTimeString()} +
  • + ))} +
+ )} + +

Paired clients

+ {status.clients.length === 0 ? ( +
No paired clients.
+ ) : ( +
+ + + {status.clients.map((c) => ( + + ))} + +
+
+ )} +
+ Blocking cuts the client's devices now and refuses new ones until Apollo restarts or you allow it again. + The client stays paired and keeps its other permissions. +
+ + {strangers.length > 0 && ( + <> +

Blocked, but not paired

+
    + {strangers.map((u) => ( +
  • + {u}{" "} + +
  • + ))} +
+ + )} + + ); +} + +function ClientRow({ + client, + blocked, + pending, + act, +}: { + client: UsbClient; + blocked: boolean; + pending: boolean; + act: (action: () => Promise) => Promise; +}) { + return ( + + {client.name || client.uuid} + + {blocked ? "Blocked" : client.usbPermission ? "May forward USB" : "No USB permission"} + + + {blocked ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/MultiSeat.Dashboard/src/hostUsbPanel.test.tsx b/src/MultiSeat.Dashboard/src/hostUsbPanel.test.tsx new file mode 100644 index 0000000..a3ad1e7 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/hostUsbPanel.test.tsx @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { UsbState, UsbStatus } from "./api/types"; +import { HostUsbPanel } from "./components/HostUsbPanel"; + +function status(over: Partial = {}): UsbStatus { + return { + enabled: true, + listening: true, + port: 47996, + requireStream: true, + forwards: [], + clients: [ + { uuid: "AB-1", name: "Player One", usbPermission: true }, + { uuid: "CD-2", name: "Player Two", usbPermission: false }, + ], + denied: [], + ...over, + }; +} + +let state: UsbState; +let calls: { key: string; body: unknown }[]; +let postReply: () => Response; +let getRefused: boolean; + +beforeEach(() => { + vi.useFakeTimers(); + calls = []; + localStorage.clear(); + state = { available: true, status: status() }; + postReply = () => new Response(JSON.stringify({}), { status: 200 }); + getRefused = false; + vi.stubGlobal( + "fetch", + vi.fn((url: string, init?: RequestInit) => { + const key = `${init?.method ?? "GET"} ${url}`; + calls.push({ key, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (key === "GET /api/host/usb") { + if (getRefused) { + return Promise.resolve( + new Response(JSON.stringify({ error: "console_session_required", reason: "NotConsoleSession" }), { status: 403 }) + ); + } + return Promise.resolve(new Response(JSON.stringify(state), { status: 200 })); + } + return Promise.resolve(postReply()); + }) + ); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const flush = () => + act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + +async function show() { + render(); + await flush(); +} + +const posts = () => calls.filter((c) => c.key.startsWith("POST")); + +describe("HostUsbPanel", () => { + it("says how to switch the control pipe on when Apollo has none", async () => { + state = { available: false, reason: "pipe_unavailable" }; + await show(); + + expect(screen.getByText(/USB Forwarding Control Pipe/)).toBeTruthy(); + expect(screen.queryByText("Turn on")).toBeNull(); + expect(screen.queryByText("Turn off")).toBeNull(); + }); + + it("names the failure instead of showing a code", async () => { + state = { available: false, reason: "untrusted_server" }; + await show(); + expect(screen.getByText(/not served by the Apollo that streams on this host/)).toBeTruthy(); + }); + + it("shows the tunnel, the forwarded devices and the paired clients", async () => { + state = { + available: true, + status: status({ + forwards: [{ clientUuid: "AB-1", clientName: "Player One", busid: "1-2", sinceUnix: 1700000000 }], + }), + }; + await show(); + + expect(screen.getByText(/On, listening on port 47996/)).toBeTruthy(); + expect(screen.getByText(/device 1-2/)).toBeTruthy(); + expect(screen.getByText("Player One")).toBeTruthy(); + expect(screen.getByText("May forward USB")).toBeTruthy(); + expect(screen.getByText("No USB permission")).toBeTruthy(); + }); + + it("is honest that a switch made here is temporary", async () => { + await show(); + expect(screen.getByText(/lasts until Apollo restarts/)).toBeTruthy(); + }); + + it("turns the tunnel off with one request", async () => { + await show(); + fireEvent.click(screen.getByText("Turn off")); + await flush(); + + expect(posts()).toEqual([{ key: "POST /api/host/usb/enabled", body: { enabled: false } }]); + }); + + it("turns it on when it is off", async () => { + state = { available: true, status: status({ enabled: false, listening: false, port: 0 }) }; + await show(); + fireEvent.click(screen.getByText("Turn on")); + await flush(); + + expect(posts()).toEqual([{ key: "POST /api/host/usb/enabled", body: { enabled: true } }]); + }); + + it("blocks a client by its uuid", async () => { + await show(); + fireEvent.click(screen.getAllByText("Block")[0]); + await flush(); + + expect(posts()).toEqual([{ key: "POST /api/host/usb/revoke", body: { clientUuid: "AB-1" } }]); + }); + + it("shows a blocked client as blocked and lets it be allowed again", async () => { + state = { available: true, status: status({ denied: ["ab-1"] }) }; // spelling may differ + await show(); + + expect(screen.getByText("Blocked")).toBeTruthy(); + fireEvent.click(screen.getByText("Allow again")); + await flush(); + + expect(posts()).toEqual([{ key: "POST /api/host/usb/clear-revoke", body: { clientUuid: "AB-1" } }]); + }); + + it("lists a blocked uuid that belongs to nobody paired, so a typo can be undone", async () => { + state = { available: true, status: status({ denied: ["EF-3"] }) }; + await show(); + + expect(screen.getByText(/Blocked, but not paired/)).toBeTruthy(); + expect(screen.getByText(/EF-3/)).toBeTruthy(); + fireEvent.click(screen.getByText("Allow again")); + await flush(); + expect(posts()).toEqual([{ key: "POST /api/host/usb/clear-revoke", body: { clientUuid: "EF-3" } }]); + }); + + it("tells the person to use the host's desktop when the service refuses (403)", async () => { + postReply = () => new Response(JSON.stringify({ error: "console_session_required" }), { status: 403 }); + await show(); + fireEvent.click(screen.getByText("Turn off")); + await flush(); + + expect(screen.getByRole("alert").textContent).toMatch(/host's own desktop/); + }); + + it("says the card is for the host's own desktop when even reading is refused (403)", async () => { + getRefused = true; + await show(); + + expect(screen.getByText(/only be seen and changed from the host's own desktop/)).toBeTruthy(); + expect(screen.queryByText(/console_session_required/)).toBeNull(); + expect(screen.queryByText("Turn on")).toBeNull(); + expect(screen.queryByText("Turn off")).toBeNull(); + expect(screen.queryByText("Player One")).toBeNull(); + }); + + it("explains a refused port in words", async () => { + postReply = () => new Response(JSON.stringify({ error: "port_refused" }), { status: 409 }); + state = { available: true, status: status({ enabled: false, listening: false }) }; + await show(); + fireEvent.click(screen.getByText("Turn on")); + await flush(); + + expect(screen.getByRole("alert").textContent).toMatch(/port/); + }); + + it("refreshes by itself", async () => { + await show(); + const before = calls.filter((c) => c.key === "GET /api/host/usb").length; + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(calls.filter((c) => c.key === "GET /api/host/usb").length).toBeGreaterThan(before); + }); +}); diff --git a/src/MultiSeat.Service/Api/ApiServer.cs b/src/MultiSeat.Service/Api/ApiServer.cs index 8fb131e..0659b15 100644 --- a/src/MultiSeat.Service/Api/ApiServer.cs +++ b/src/MultiSeat.Service/Api/ApiServer.cs @@ -37,6 +37,8 @@ public static WebApplication Build(IServiceProvider hostServices, MultiSeatOptio builder.Services.AddSingleton(hostServices.GetRequiredService()); builder.Services.AddSingleton(hostServices.GetRequiredService()); builder.Services.AddSingleton(hostServices.GetRequiredService()); + builder.Services.AddSingleton(hostServices.GetRequiredService()); + builder.Services.AddSingleton(hostServices.GetRequiredService()); builder.Services.AddSingleton(hostServices.GetRequiredService()); builder.Services.AddSingleton(hostServices.GetRequiredService()); // The SAME instance the seat manager reads, so a change made through the API is what the @@ -168,6 +170,7 @@ public static WebApplication Build(IServiceProvider hostServices, MultiSeatOptio SystemEndpoints.Map(app); UpdateEndpoints.Map(app); HostEndpoints.Map(app); + HostUsbEndpoints.Map(app); InputEndpoints.Map(app); WebSocketHub.Map(app); diff --git a/src/MultiSeat.Service/Api/ConsoleCallerCheck.cs b/src/MultiSeat.Service/Api/ConsoleCallerCheck.cs new file mode 100644 index 0000000..5955ab3 --- /dev/null +++ b/src/MultiSeat.Service/Api/ConsoleCallerCheck.cs @@ -0,0 +1,147 @@ +using System.Net; +using MultiSeat.Service.Accounts; +using MultiSeat.Service.Interop; + +namespace MultiSeat.Service.Api; + +public enum CallerVerdict +{ + /// The request came over loopback from the user logged on at the active console session, in a process of that session. + Allowed, + /// Not a loopback connection (the dashboard was reached over the network). + NotLoopback, + /// The calling process, its session or its user could not be determined. Refused: unknown is not "yes". + UnknownCaller, + /// The caller runs in another session, for example a seat's. + NotConsoleSession, + /// The caller is in the console session but is not the person logged on there: a service account, another user, or a seat account. + NotConsoleUser, +} + +/// +/// Decides whether a request came from the person sitting at the host: a process of the user logged on at +/// the active CONSOLE session. The dashboard API has no key on this host by the owner's choice and every +/// seat user can reach it on loopback, so without this a seat user could read the host's USB status or +/// switch its USB tunnel on through MultiSeat's SYSTEM service. +/// +/// How: the request's source port identifies the client end of the loopback connection; the system's TCP +/// table names the process that owns that end and when the connection was created. +/// - The owner process is opened and the handle is KEPT OPEN for the whole check, so its id cannot be +/// given to another process meanwhile. The table keeps naming the process that CREATED a socket after +/// that process has exited; if its id was then reused, the process that now has it is younger than the +/// connection. So the check refuses when the owner was created AFTER the connection, and when either +/// timestamp is zero. The comparison is strict: a tolerance could only let a reused id through. +/// - The table is read again once the handle is held and must name the same owner and connection, and +/// the console session must not have moved. +/// - The owner's session must be the active console session. Its token user must be the user logged on +/// at that session, must not be SYSTEM or another service account, and must not be a MultiSeat seat +/// account. A seat user who moves their own session to the console with tscon is the console user, +/// but is a seat account, so is still refused. +/// Every doubt refuses. +/// +/// What it does not stop: another program of the console user, which is the same trust the console user +/// already has over the host. A console-user program that hands a connected socket to a program in another +/// session is not detected, because the table names the creator. A dashboard reached over the network +/// fails (not loopback); that case would need a key and is not offered. +/// +public sealed class ConsoleCallerCheck +{ + private const uint NoSession = 0xFFFFFFFF; + + private readonly Func _clientOwner; + private readonly Func _openOwner; + private readonly Func _consoleSession; + private readonly Func _consoleUserSid; + private readonly Func> _seatSids; + + public ConsoleCallerCheck(AccountManager accounts) + : this( + (clientPort, serverPort) => TcpTable.ClientOwner(TcpTable.ReadConnectionsWithTimes(), clientPort, serverPort), + OwnerProcess.Open, + Kernel32.WTSGetActiveConsoleSessionId, + ConsoleUser.SidOf, + () => SeatSidsOf(accounts)) + { + } + + internal ConsoleCallerCheck( + Func clientOwner, Func openOwner, + Func consoleSession, Func consoleUserSid, Func> seatSids) + { + _clientOwner = clientOwner; + _openOwner = openOwner; + _consoleSession = consoleSession; + _consoleUserSid = consoleUserSid; + _seatSids = seatSids; + } + + /// The SIDs of the seat accounts MultiSeat manages. A name that cannot be resolved is skipped. + private static IReadOnlyCollection SeatSidsOf(AccountManager accounts) + { + var sids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var account in accounts.ListManagedAccounts()) + { + var sid = account.Sid ?? AccountManager.TryResolveSid(account.Username, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + if (!string.IsNullOrEmpty(sid)) sids.Add(sid); + } + return sids; + } + + public CallerVerdict Check(IPAddress? remote, int remotePort, int localPort) + { + if (remote is null || !IPAddress.IsLoopback(remote)) + return CallerVerdict.NotLoopback; + + if (remotePort is < 1 or > 65535 || localPort is < 1 or > 65535) + return CallerVerdict.UnknownCaller; + + var first = _clientOwner((ushort)remotePort, (ushort)localPort); + if (first is not { Pid: not 0 } owner) + return CallerVerdict.UnknownCaller; + + // Held to the end of the check: the id cannot be reused while this handle is open. + using var process = _openOwner(owner.Pid); + if (process is null) + return CallerVerdict.UnknownCaller; + + // A reused id belongs to a process younger than the connection. Strict, no tolerance. + if (process.CreationTime <= 0 || owner.Created <= 0 || process.CreationTime > owner.Created) + return CallerVerdict.UnknownCaller; + + var session = process.SessionId(); + var console = _consoleSession(); + var consoleUser = console == 0 || console == NoSession ? null : _consoleUserSid(console); + var user = process.UserSid(); + + // Ask again now that the handle is held: the table must name the same owner and connection, and + // the answers about the session must not have moved. + if (_clientOwner((ushort)remotePort, (ushort)localPort) != first) + return CallerVerdict.UnknownCaller; + if (process.SessionId() != session || _consoleSession() != console) + return CallerVerdict.UnknownCaller; + + // Session 0 is the services session and 0xFFFFFFFF means nobody is at the console: neither is a + // person at the host, so with either as the console nothing can match. + if (session is null || console == 0 || console == NoSession || consoleUser is null || user is null) + return CallerVerdict.UnknownCaller; + + if (session != console) + return CallerVerdict.NotConsoleSession; + + if (!string.Equals(user, consoleUser, StringComparison.OrdinalIgnoreCase) + || IsServiceAccount(user) || IsServiceAccount(consoleUser) || IsSeatAccount(user)) + return CallerVerdict.NotConsoleUser; + + return CallerVerdict.Allowed; + } + + /// SYSTEM, LocalService, NetworkService and per-service SIDs are not "the person at the console". + private static bool IsServiceAccount(string sid) => + sid is "S-1-5-18" or "S-1-5-19" or "S-1-5-20" || sid.StartsWith("S-1-5-80-", StringComparison.Ordinal); + + private bool IsSeatAccount(string sid) + { + try { return _seatSids().Contains(sid, StringComparer.OrdinalIgnoreCase); } + catch (Exception) { return false; } // the set could not be obtained: nothing extra is refused + } +} diff --git a/src/MultiSeat.Service/Api/HostUsbEndpoints.cs b/src/MultiSeat.Service/Api/HostUsbEndpoints.cs new file mode 100644 index 0000000..5f0a019 --- /dev/null +++ b/src/MultiSeat.Service/Api/HostUsbEndpoints.cs @@ -0,0 +1,126 @@ +using System.Text.RegularExpressions; +using MultiSeat.Service.HostUsb; + +namespace MultiSeat.Service.Api; + +/// +/// USB forwarding of the host's own Apollo, through its control pipe. +/// +/// The API has no key on this host by the owner's choice and every seat user can reach it on loopback, so +/// the whole group, reading included, first asks whether the request came +/// from the person at the console. Reading is covered because the status names paired clients and the +/// devices they forward. Reasons and codes are fixed words; nothing Apollo or the caller sent is echoed back. +/// +public static partial class HostUsbEndpoints +{ + [GeneratedRegex("^[A-Za-z0-9-]{1,64}$")] + private static partial Regex UuidShape(); + + public sealed record SetEnabledRequest(bool? Enabled); + + public sealed record ClientRequest(string? ClientUuid); + + public static void Map(WebApplication app) + { + var group = app.MapGroup("/api/host/usb").WithTags("Host"); + + // On the GROUP, so every route mapped under it, now or later, is refused before its handler runs. + var refusals = new RefusalLog(TimeProvider.System); + group.AddEndpointFilter(async (context, next) => + { + var http = context.HttpContext; + var refused = Guard( + http, http.RequestServices.GetRequiredService(), + http.RequestServices.GetRequiredService(), refusals); + return refused ?? await next(context); + }); + + // Always 200: "not available" is a state the card shows, not a failure of this request. + group.MapGet("/", async (IHostUsbControl usb, CancellationToken ct) => + { + var result = await usb.StatusAsync(ct); + return result.Ok + ? Results.Ok(new { available = true, status = result.Value }) + : Results.Ok(new { available = false, reason = result.Reason }); + }); + + group.MapPost("/enabled", async ( + SetEnabledRequest? body, IHostUsbControl usb, CancellationToken ct) => + { + if (body?.Enabled is not bool enabled) return Results.Json(new { error = "bad_request" }, statusCode: 400); + return Respond(await usb.SetEnabledAsync(enabled, ct)); + }); + + group.MapPost("/revoke", async ( + ClientRequest? body, IHostUsbControl usb, CancellationToken ct) => + { + if (body?.ClientUuid is not { } uuid || !UuidShape().IsMatch(uuid)) + return Results.Json(new { error = "bad_request" }, statusCode: 400); + return Respond(await usb.RevokeAsync(uuid, ct)); + }); + + group.MapPost("/clear-revoke", async ( + ClientRequest? body, IHostUsbControl usb, CancellationToken ct) => + { + if (body?.ClientUuid is not { } uuid || !UuidShape().IsMatch(uuid)) + return Results.Json(new { error = "bad_request" }, statusCode: 400); + return Respond(await usb.ClearRevokeAsync(uuid, ct)); + }); + } + + /// + /// At most one line per outcome per interval. The dashboard polls the status, so a browser outside the + /// console would otherwise write a warning every few seconds for as long as it stays open. + /// + internal sealed class RefusalLog(TimeProvider time) + { + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1); + private readonly object _gate = new(); + private readonly Dictionary _last = new(); + + /// True when a line should be written now; counts the ones left out since the last. + public bool ShouldLog(CallerVerdict verdict, out long skipped) + { + lock (_gate) + { + var now = time.GetUtcNow(); + if (_last.TryGetValue(verdict, out var last) && now - last.At < Interval) + { + _last[verdict] = (last.At, last.Skipped + 1); + skipped = 0; + return false; + } + skipped = _last.TryGetValue(verdict, out last) ? last.Skipped : 0; + _last[verdict] = (now, 0); + return true; + } + } + } + + /// Null when the caller may use the host's USB forwarding; otherwise the refusal to send. + internal static IResult? Guard(HttpContext http, ConsoleCallerCheck check, ILoggerFactory logs, RefusalLog refusals) + { + var verdict = check.Check( + http.Connection.RemoteIpAddress, http.Connection.RemotePort, http.Connection.LocalPort); + if (verdict == CallerVerdict.Allowed) + return null; + + if (refusals.ShouldLog(verdict, out var skipped)) + logs.CreateLogger("MultiSeat.Service.Api.HostUsbEndpoints").LogWarning( + "Refused a host USB forwarding request: {Verdict} ({Skipped} like it since the last line). Only the person at the console is served.", + verdict, skipped); + return Results.Json(new { error = "console_session_required", reason = verdict.ToString() }, statusCode: 403); + } + + private static IResult Respond(UsbOutcome result) => + result.Ok ? Results.Ok(result.Value) : Results.Json(new { error = result.Reason }, statusCode: StatusFor(result.Reason!)); + + private static int StatusFor(string reason) => reason switch + { + UsbReasons.NoHostApollo or UsbReasons.PipeUnavailable or UsbReasons.Busy or "shutting_down" => 503, + UsbReasons.Timeout => 504, + "port_refused" or "deny_list_full" => 409, + "bad_request" or "bad_field" or "too_large" => 400, + _ => 502, + }; +} diff --git a/src/MultiSeat.Service/HostUsb/ApolloUsbControlClient.cs b/src/MultiSeat.Service/HostUsb/ApolloUsbControlClient.cs new file mode 100644 index 0000000..654f31e --- /dev/null +++ b/src/MultiSeat.Service/HostUsb/ApolloUsbControlClient.cs @@ -0,0 +1,267 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using Microsoft.Win32.SafeHandles; +using MultiSeat.Service.Interop; + +namespace MultiSeat.Service.HostUsb; + +public enum UsbPipeOutcome +{ + /// A reply that passed every check. + Ok, + /// No pipe by that name (control is off in Apollo, or Apollo is old, or not up). + Unavailable, + /// All instances busy, or the server closed the connection with no reply (its connection cap). + Busy, + /// The server did not answer in time. + Timeout, + /// The pipe is not owned by SYSTEM or Administrators, is not served by the Apollo we expect, or refused us. + UntrustedServer, + /// The reply was not a valid version 1 reply. + BadReply, + /// Apollo answered with ok:false and one of its fixed error codes. + ApolloError, +} + +public sealed record UsbPipeReply(UsbPipeOutcome Outcome, JsonElement? Body = null, string? ApolloError = null); + +/// +/// The client side of the host Apollo's USB control pipe, written to Apollo's CLIENT CONTRACT +/// (usb_control_pipe.h in ApolloVibe, "Control pipe" in its docs/configuration.md). +/// +/// The pipe's access list keeps ordinary users out of Apollo. It does nothing for US: while Apollo is +/// stopped, any local user can create a pipe with the same name, and this service runs as SYSTEM, so +/// without the checks below it would send a fake server a request and believe the answer. Before a +/// single byte is sent, the client therefore: +/// 1. opens the pipe with SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION (flags of the open call), +/// so whoever serves it can never act as SYSTEM; +/// 2. requires the pipe's owner to be SYSTEM or Administrators; +/// 3. requires the serving process to be the one the caller names (the process that listens on +/// Apollo's main port). +/// Every wait is bounded. A server that closes without answering means "busy" (Apollo serves at most +/// four connections), not an answer. +/// +public sealed class ApolloUsbControlClient +{ + public sealed class Options + { + /// Total time spent getting past ERROR_PIPE_BUSY. + public TimeSpan ConnectBudget { get; init; } = TimeSpan.FromMilliseconds(2500); + + /// One WaitNamedPipe slice inside the budget. + public TimeSpan WaitSlice { get; init; } = TimeSpan.FromMilliseconds(500); + + /// Writing the request, and separately reading the reply. + public TimeSpan IoTimeout { get; init; } = TimeSpan.FromSeconds(5); + + public int MaxReplyBytes { get; init; } = 256 * 1024; + + /// Production accepts SYSTEM and Administrators only. Tests widen it. + public Func OwnerAcceptable { get; init; } = IsTrustedOwner; + } + + private static readonly HashSet ApolloErrorCodes = new(StringComparer.Ordinal) + { + "bad_request", "too_large", "bad_version", "unknown_op", "bad_field", "timeout", + "port_refused", "start_failed", "shutting_down", "deny_list_full", "internal", + }; + + private readonly Options _options; + + public ApolloUsbControlClient() : this(new Options()) { } + + public ApolloUsbControlClient(Options options) => _options = options; + + /// S-1-5-18 (SYSTEM) or S-1-5-32-544 (BUILTIN\Administrators). + public static bool IsTrustedOwner(SecurityIdentifier sid) => + sid.Value is "S-1-5-18" or "S-1-5-32-544"; + + /// The pipe name after \\.\pipe\. + /// The process that must be serving it. + /// One JSON object, without the newline. + public async Task CallAsync( + string pipeLeaf, uint expectedServerPid, string requestLine, CancellationToken ct) + { + var path = @"\\.\pipe\" + pipeLeaf; + + var opened = Open(path, out var handle); + if (opened != UsbPipeOutcome.Ok) + return new UsbPipeReply(opened); + + using var pipe = new NamedPipeClientStream(PipeDirection.InOut, isAsync: true, isConnected: true, handle!); + + // Who is on the other end? Both answers before anything is sent. + if (!ServerIsTrusted(pipe, handle!, expectedServerPid)) + return new UsbPipeReply(UsbPipeOutcome.UntrustedServer); + + try + { + var request = Encoding.UTF8.GetBytes(requestLine + "\n"); + + using (var writeCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + writeCts.CancelAfter(_options.IoTimeout); + try + { + await pipe.WriteAsync(request, writeCts.Token); + await pipe.FlushAsync(writeCts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new UsbPipeReply(UsbPipeOutcome.Timeout); + } + catch (IOException) + { + // The server went away mid-request: its cap, or it is stopping. + return new UsbPipeReply(UsbPipeOutcome.Busy); + } + } + + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + readCts.CancelAfter(_options.IoTimeout); + return await ReadReplyAsync(pipe, readCts.Token, ct); + } + catch (ObjectDisposedException) + { + return new UsbPipeReply(UsbPipeOutcome.Busy); + } + } + + /// + /// Opens the pipe with the identification flags. ERROR_PIPE_BUSY gets bounded WaitNamedPipe slices + /// inside ; anything else decides the outcome at once. + /// + private UsbPipeOutcome Open(string path, out SafePipeHandle? handle) + { + handle = null; + var started = System.Diagnostics.Stopwatch.StartNew(); + + while (true) + { + var raw = PipeApi.CreateFile( + path, + PipeApi.GENERIC_READ | PipeApi.GENERIC_WRITE, + 0, + IntPtr.Zero, + PipeApi.OPEN_EXISTING, + PipeApi.FILE_FLAG_OVERLAPPED | PipeApi.SECURITY_SQOS_PRESENT | PipeApi.SECURITY_IDENTIFICATION, + IntPtr.Zero); + + if (raw != new IntPtr(-1)) + { + handle = new SafePipeHandle(raw, ownsHandle: true); + return UsbPipeOutcome.Ok; + } + + var error = System.Runtime.InteropServices.Marshal.GetLastWin32Error(); + if (error == PipeApi.ERROR_PIPE_BUSY) + { + var left = _options.ConnectBudget - started.Elapsed; + if (left <= TimeSpan.Zero) + return UsbPipeOutcome.Busy; + var slice = left < _options.WaitSlice ? left : _options.WaitSlice; + PipeApi.WaitNamedPipe(path, (uint)Math.Max(1, (int)slice.TotalMilliseconds)); + continue; + } + + // The access list lets SYSTEM in. Being refused means the pipe is not the one Apollo makes. + return error == PipeApi.ERROR_ACCESS_DENIED ? UsbPipeOutcome.UntrustedServer : UsbPipeOutcome.Unavailable; + } + } + + private bool ServerIsTrusted(NamedPipeClientStream pipe, SafePipeHandle handle, uint expectedServerPid) + { + try + { + var owner = pipe.GetAccessControl().GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + if (owner is null || !_options.OwnerAcceptable(owner)) + return false; + + if (!PipeApi.GetNamedPipeServerProcessId(handle, out var serverPid)) + return false; + + return expectedServerPid != 0 && serverPid == expectedServerPid; + } + catch (Exception) + { + return false; // any doubt is a refusal + } + } + + private async Task ReadReplyAsync( + NamedPipeClientStream pipe, CancellationToken token, CancellationToken callerToken) + { + var received = new List(1024); + var buffer = new byte[4096]; + + try + { + while (true) + { + var got = await pipe.ReadAsync(buffer, token); + if (got == 0) + { + // Closed. With nothing read that is the connection cap (or a stopping server). + return received.Count == 0 + ? new UsbPipeReply(UsbPipeOutcome.Busy) + : new UsbPipeReply(UsbPipeOutcome.BadReply); + } + + var newline = Array.IndexOf(buffer, (byte)'\n', 0, got); + var take = newline >= 0 ? newline : got; + if (received.Count + take > _options.MaxReplyBytes) + return new UsbPipeReply(UsbPipeOutcome.BadReply); + received.AddRange(new ArraySegment(buffer, 0, take)); + + if (newline >= 0) + return ParseReply(received.ToArray()); + } + } + catch (OperationCanceledException) when (!callerToken.IsCancellationRequested) + { + return new UsbPipeReply(UsbPipeOutcome.Timeout); + } + catch (IOException) + { + return new UsbPipeReply(UsbPipeOutcome.Busy); + } + } + + /// A version 1 reply: an object with v = 1 and a boolean ok; ok:false carries one of Apollo's fixed codes. + internal static UsbPipeReply ParseReply(byte[] line) + { + try + { + var text = new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(line); + using var document = JsonDocument.Parse(text, new JsonDocumentOptions { MaxDepth = 16 }); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("v", out var version) || version.ValueKind != JsonValueKind.Number + || !version.TryGetInt32(out var v) || v != 1 + || !root.TryGetProperty("ok", out var ok) + || (ok.ValueKind != JsonValueKind.True && ok.ValueKind != JsonValueKind.False)) + { + return new UsbPipeReply(UsbPipeOutcome.BadReply); + } + + if (ok.ValueKind == JsonValueKind.True) + return new UsbPipeReply(UsbPipeOutcome.Ok, root.Clone()); + + if (root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String + && error.GetString() is { } code + && ApolloErrorCodes.Contains(code)) + { + return new UsbPipeReply(UsbPipeOutcome.ApolloError, null, code); + } + return new UsbPipeReply(UsbPipeOutcome.BadReply); + } + catch (Exception) + { + return new UsbPipeReply(UsbPipeOutcome.BadReply); + } + } +} diff --git a/src/MultiSeat.Service/HostUsb/HostUsbControl.cs b/src/MultiSeat.Service/HostUsb/HostUsbControl.cs new file mode 100644 index 0000000..fe445ab --- /dev/null +++ b/src/MultiSeat.Service/HostUsb/HostUsbControl.cs @@ -0,0 +1,265 @@ +using System.Text.Json; +using MultiSeat.Service.Interop; +using MultiSeat.Service.Monitoring; + +namespace MultiSeat.Service.HostUsb; + +public sealed record UsbForward(string ClientUuid, string ClientName, string Busid, long SinceUnix); + +public sealed record UsbClient(string Uuid, string Name, bool UsbPermission); + +public sealed record UsbStatus( + bool Enabled, + bool Listening, + int Port, + bool RequireStream, + IReadOnlyList Forwards, + IReadOnlyList Clients, + IReadOnlyList Denied); + +public sealed record UsbSetEnabledResult(bool Enabled, bool Listening, int Port, bool Persisted); + +public sealed record UsbRevokeResult(int ForwardsCut, bool Denied, bool Known); + +public sealed record UsbClearResult(bool Denied, bool Known); + +/// The outcome of one control call: a value, or a fixed reason code (never text from Apollo). +public sealed record UsbOutcome(T? Value, string? Reason) +{ + public bool Ok => Reason is null; + public static UsbOutcome Success(T value) => new(value, null); + public static UsbOutcome Failure(string reason) => new(default, reason); +} + +/// What the dashboard may ask of the host Apollo's USB forwarding. +public interface IHostUsbControl +{ + Task> StatusAsync(CancellationToken ct); + Task> SetEnabledAsync(bool enabled, CancellationToken ct); + Task> RevokeAsync(string clientUuid, CancellationToken ct); + Task> ClearRevokeAsync(string clientUuid, CancellationToken ct); +} + +/// +/// Talks to the host's own Apollo through its control pipe. MultiSeat still never edits that Apollo's +/// config: this is a live switch on a tunnel the host owner already allowed in Apollo's own settings, +/// and a change made here lasts only until that Apollo restarts. +/// +/// Which Apollo is "the" one is decided here, not by the pipe: the pipe belongs to the process that +/// LISTENS on the host Apollo's main port. If no single process listens there, or the pipe's server is +/// another process, nothing is sent (see ). +/// +public sealed class HostUsbControl : IHostUsbControl +{ + /// The leaf of \\.\pipe\ApolloVibe-usb-control-<main port>. + public static string PipeLeafFor(int mainPort) => $"ApolloVibe-usb-control-{mainPort}"; + + private readonly Func _mainPort; + private readonly Func _listenerPid; + private readonly ApolloUsbControlClient _client; + private readonly ILogger _logger; + private readonly SemaphoreSlim _one = new(1, 1); + + public HostUsbControl(HostApolloMonitor monitor, ApolloUsbControlClient client, ILogger logger) + : this(monitor.TryResolveMainPort, port => TcpTable.ListenerPid(port), client, logger) + { + } + + internal HostUsbControl( + Func mainPort, Func listenerPid, ApolloUsbControlClient client, ILogger logger) + { + _mainPort = mainPort; + _listenerPid = listenerPid; + _client = client; + _logger = logger; + } + + public async Task> StatusAsync(CancellationToken ct) => + Interpret(await CallAsync("status", null, ct), ParseStatus); + + public async Task> SetEnabledAsync(bool enabled, CancellationToken ct) + { + var result = Interpret(await CallAsync("set_enabled", new Dictionary { ["enabled"] = enabled }, ct), ParseSetEnabled); + _logger.LogInformation("Host USB forwarding: set_enabled({Enabled}) -> {Outcome}", enabled, result.Reason ?? "ok"); + return result; + } + + public async Task> RevokeAsync(string clientUuid, CancellationToken ct) + { + var result = Interpret(await CallAsync("revoke_client", new Dictionary { ["client_uuid"] = clientUuid }, ct), ParseRevoke); + _logger.LogInformation("Host USB forwarding: revoke_client -> {Outcome}", result.Reason ?? "ok"); + return result; + } + + public async Task> ClearRevokeAsync(string clientUuid, CancellationToken ct) + { + var result = Interpret(await CallAsync("clear_revoke", new Dictionary { ["client_uuid"] = clientUuid }, ct), ParseClear); + _logger.LogInformation("Host USB forwarding: clear_revoke -> {Outcome}", result.Reason ?? "ok"); + return result; + } + + // ---- one call -------------------------------------------------------------------------------- + + private async Task> CallAsync(string op, Dictionary? fields, CancellationToken ct) + { + // Apollo serves four connections at once; this service never uses more than one. + if (!await _one.WaitAsync(TimeSpan.FromSeconds(3), ct)) + return UsbOutcome.Failure(UsbReasons.Busy); + + try + { + var port = _mainPort(); + if (port is null or < 1024 or > 65535) + return UsbOutcome.Failure(UsbReasons.NoHostApollo); + + var pid = _listenerPid((ushort)port.Value); + if (pid is null) + return UsbOutcome.Failure(UsbReasons.NoHostApollo); + + var request = new Dictionary { ["v"] = 1, ["op"] = op }; + if (fields is not null) + { + foreach (var pair in fields) + request[pair.Key] = pair.Value; + } + + var reply = await _client.CallAsync(PipeLeafFor(port.Value), pid.Value, JsonSerializer.Serialize(request), ct); + return reply.Outcome switch + { + UsbPipeOutcome.Ok => UsbOutcome.Success(reply.Body!.Value), + UsbPipeOutcome.Unavailable => UsbOutcome.Failure(UsbReasons.PipeUnavailable), + UsbPipeOutcome.Busy => UsbOutcome.Failure(UsbReasons.Busy), + UsbPipeOutcome.Timeout => UsbOutcome.Failure(UsbReasons.Timeout), + UsbPipeOutcome.UntrustedServer => UsbOutcome.Failure(UsbReasons.UntrustedServer), + UsbPipeOutcome.ApolloError => UsbOutcome.Failure(reply.ApolloError!), + _ => UsbOutcome.Failure(UsbReasons.BadReply), + }; + } + finally + { + _one.Release(); + } + } + + private static UsbOutcome Interpret(UsbOutcome raw, Func parse) where T : class + { + if (!raw.Ok) + return UsbOutcome.Failure(raw.Reason!); + var value = parse(raw.Value); + return value is null ? UsbOutcome.Failure(UsbReasons.BadReply) : UsbOutcome.Success(value); + } + + // ---- strict readers: a wrong type or a missing field is a bad reply, never a guess -------------- + + private const int MaxListed = 512; + private const int MaxText = 256; + + private static bool TryBool(JsonElement o, string name, out bool value) + { + value = false; + if (!o.TryGetProperty(name, out var p) || (p.ValueKind != JsonValueKind.True && p.ValueKind != JsonValueKind.False)) + return false; + value = p.GetBoolean(); + return true; + } + + private static bool TryInt(JsonElement o, string name, out int value) + { + value = 0; + return o.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out value); + } + + private static bool TryLong(JsonElement o, string name, out long value) + { + value = 0; + return o.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out value); + } + + private static bool TryText(JsonElement o, string name, out string value) + { + value = ""; + if (!o.TryGetProperty(name, out var p) || p.ValueKind != JsonValueKind.String) + return false; + value = p.GetString() ?? ""; + return value.Length <= MaxText; + } + + private static bool TryArray(JsonElement o, string name, out JsonElement array) + { + array = default; + if (!o.TryGetProperty(name, out array) || array.ValueKind != JsonValueKind.Array) + return false; + return array.GetArrayLength() <= MaxListed; + } + + internal static UsbStatus? ParseStatus(JsonElement r) + { + if (!TryBool(r, "enabled", out var enabled) || !TryBool(r, "listening", out var listening) + || !TryInt(r, "port", out var port) || !TryBool(r, "require_stream", out var requireStream) + || !TryArray(r, "forwards", out var forwardsJson) || !TryArray(r, "clients", out var clientsJson) + || !TryArray(r, "denied", out var deniedJson)) + { + return null; + } + + var forwards = new List(); + foreach (var f in forwardsJson.EnumerateArray()) + { + if (f.ValueKind != JsonValueKind.Object || !TryText(f, "client_uuid", out var uuid) + || !TryText(f, "client_name", out var name) || !TryText(f, "busid", out var busid) + || !TryLong(f, "since_unix", out var since)) + { + return null; + } + forwards.Add(new UsbForward(uuid, name, busid, since)); + } + + var clients = new List(); + foreach (var c in clientsJson.EnumerateArray()) + { + if (c.ValueKind != JsonValueKind.Object || !TryText(c, "uuid", out var uuid) + || !TryText(c, "name", out var name) || !TryBool(c, "usb_permission", out var permission)) + { + return null; + } + clients.Add(new UsbClient(uuid, name, permission)); + } + + var denied = new List(); + foreach (var d in deniedJson.EnumerateArray()) + { + if (d.ValueKind != JsonValueKind.String || d.GetString() is not { Length: <= MaxText } text) + return null; + denied.Add(text); + } + + return new UsbStatus(enabled, listening, port, requireStream, forwards, clients, denied); + } + + internal static UsbSetEnabledResult? ParseSetEnabled(JsonElement r) => + TryBool(r, "enabled", out var enabled) && TryBool(r, "listening", out var listening) + && TryInt(r, "port", out var port) && TryBool(r, "persisted", out var persisted) + ? new UsbSetEnabledResult(enabled, listening, port, persisted) + : null; + + internal static UsbRevokeResult? ParseRevoke(JsonElement r) => + TryInt(r, "forwards_cut", out var cut) && cut >= 0 && TryBool(r, "denied", out var denied) && TryBool(r, "known", out var known) + ? new UsbRevokeResult(cut, denied, known) + : null; + + internal static UsbClearResult? ParseClear(JsonElement r) => + TryBool(r, "denied", out var denied) && TryBool(r, "known", out var known) + ? new UsbClearResult(denied, known) + : null; +} + +/// The reason codes this API gives: fixed words, so a client can switch on them. Apollo's own codes pass through. +public static class UsbReasons +{ + public const string NoHostApollo = "no_host_apollo"; + public const string PipeUnavailable = "pipe_unavailable"; + public const string Busy = "busy"; + public const string Timeout = "timeout"; + public const string UntrustedServer = "untrusted_server"; + public const string BadReply = "bad_reply"; +} diff --git a/src/MultiSeat.Service/Interop/OwnerProcess.cs b/src/MultiSeat.Service/Interop/OwnerProcess.cs new file mode 100644 index 0000000..3833537 --- /dev/null +++ b/src/MultiSeat.Service/Interop/OwnerProcess.cs @@ -0,0 +1,88 @@ +using System.Runtime.InteropServices; + +namespace MultiSeat.Service.Interop; + +/// +/// An open handle to the process that owns the client end of a connection. While this object lives the +/// process id cannot be given to another process, so everything read from it is about one process. +/// +internal interface IOwnerProcess : IDisposable +{ + /// When the process was created, as 100 ns ticks since 1601 (the FILETIME / LARGE_INTEGER epoch). + long CreationTime { get; } + + /// The process's session id, or null if it cannot be read. + uint? SessionId(); + + /// The string SID of the user in the process's token, or null if it cannot be read. + string? UserSid(); +} + +/// The production . +internal sealed partial class OwnerProcess : IOwnerProcess +{ + private const uint ProcessQueryInformation = 0x0400; + private const uint ProcessQueryLimitedInformation = 0x1000; + private const uint TokenQuery = 0x0008; + + private readonly uint _pid; + private IntPtr _handle; + + public long CreationTime { get; } + + private OwnerProcess(uint pid, IntPtr handle, long creation) + { + _pid = pid; + _handle = handle; + CreationTime = creation; + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetProcessTimes(IntPtr process, out long creation, out long exit, out long kernel, out long user); + + /// + /// Opens the process and keeps it open. Null when it cannot be opened (it has exited, or it is not + /// ours to look at) and when it has already exited but something still holds a handle to it. + /// + public static IOwnerProcess? Open(uint pid) + { + var handle = Kernel32.OpenProcess(ProcessQueryInformation | ProcessQueryLimitedInformation, false, pid); + if (handle == IntPtr.Zero) return null; + if (!GetProcessTimes(handle, out var creation, out var exit, out _, out _) || exit != 0 || creation <= 0) + { + Kernel32.CloseHandle(handle); + return null; + } + return new OwnerProcess(pid, handle, creation); + } + + public uint? SessionId() => Kernel32.ProcessIdToSessionId(_pid, out var s) ? s : null; + + public string? UserSid() + { + if (!AdvApi.OpenProcessToken(_handle, TokenQuery, out var token)) return null; + using var safe = new SafeTokenHandle(token); + return AdvApi.TryGetTokenUserSid(safe.DangerousGetHandle())?.Value; + } + + public void Dispose() + { + var h = Interlocked.Exchange(ref _handle, IntPtr.Zero); + if (h != IntPtr.Zero) Kernel32.CloseHandle(h); + } +} + +internal static class ConsoleUser +{ + /// + /// The SID of the user logged on at the given console session, or null (nobody is logged on, or the + /// caller is not allowed to ask). Needs the SYSTEM account, which the service is. + /// + public static string? SidOf(uint session) + { + if (!WtsApi.WTSQueryUserToken(session, out var token)) return null; + using var safe = new SafeTokenHandle(token); + return AdvApi.TryGetTokenUserSid(safe.DangerousGetHandle())?.Value; + } +} diff --git a/src/MultiSeat.Service/Interop/PipeApi.cs b/src/MultiSeat.Service/Interop/PipeApi.cs new file mode 100644 index 0000000..158ea2b --- /dev/null +++ b/src/MultiSeat.Service/Interop/PipeApi.cs @@ -0,0 +1,43 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace MultiSeat.Service.Interop; + +/// +/// P/Invoke for the few named-pipe calls the host Apollo control client needs. The pipe is opened +/// with CreateFile directly, not through NamedPipeClientStream's own connect, because the contract +/// with Apollo is stated in terms of the exact open flags and the exact Win32 error that came back +/// (file not found is "no pipe", pipe busy is "retry, bounded"), and the managed connect hides both. +/// +internal static partial class PipeApi +{ + public const uint GENERIC_READ = 0x80000000; + public const uint GENERIC_WRITE = 0x40000000; + public const uint OPEN_EXISTING = 3; + public const uint FILE_FLAG_OVERLAPPED = 0x40000000; + + /// + /// Flags of the OPEN call, not a step after it. Together they make the server's token for this + /// client an identification-level token: the server can learn who connected but can never act + /// as them. + /// + public const uint SECURITY_SQOS_PRESENT = 0x00100000; + public const uint SECURITY_IDENTIFICATION = 0x00010000; + + public const int ERROR_FILE_NOT_FOUND = 2; + public const int ERROR_ACCESS_DENIED = 5; + public const int ERROR_PIPE_BUSY = 231; + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + public static partial IntPtr CreateFile( + string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes, + uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile); + + [LibraryImport("kernel32.dll", EntryPoint = "WaitNamedPipeW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool WaitNamedPipe(string name, uint timeoutMilliseconds); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId); +} diff --git a/src/MultiSeat.Service/Interop/TcpTable.cs b/src/MultiSeat.Service/Interop/TcpTable.cs new file mode 100644 index 0000000..d9f7dcd --- /dev/null +++ b/src/MultiSeat.Service/Interop/TcpTable.cs @@ -0,0 +1,254 @@ +using System.Buffers.Binary; +using System.Net; +using System.Runtime.InteropServices; + +namespace MultiSeat.Service.Interop; + +/// +/// Who owns a TCP endpoint, from the system's extended TCP table (IPv4 and IPv6). +/// +/// Two questions are asked of it and both fail CLOSED, meaning "unknown" whenever the table does not +/// give exactly one answer: +/// - which single process listens on a port (so the host Apollo's control pipe can be tied to the +/// Apollo that really serves Moonlight, not to whoever created a pipe with the same name); +/// - which single process owns the client end of a loopback connection, and when that connection was +/// created (so the API can tell which session a request came from, and notice a process id that has +/// been given to a different process since). +/// +internal static partial class TcpTable +{ + private const uint AF_INET = 2; + private const uint AF_INET6 = 23; + private const int TCP_TABLE_OWNER_PID_LISTENER = 3; + private const int TCP_TABLE_OWNER_PID_CONNECTIONS = 4; + private const int TCP_TABLE_OWNER_MODULE_CONNECTIONS = 7; + private const uint NO_ERROR = 0; + private const uint ERROR_INSUFFICIENT_BUFFER = 122; + + public const uint StateListen = 2; + public const uint StateEstablished = 5; + + [LibraryImport("iphlpapi.dll")] + private static partial uint GetExtendedTcpTable( + IntPtr table, ref uint size, [MarshalAs(UnmanagedType.Bool)] bool order, + uint addressFamily, int tableClass, uint reserved); + + /// + /// One connection or listener. is when the connection was created, in + /// 100 ns ticks since 1601 (the epoch of a process's creation time); it is 0 for rows read from a + /// table that does not carry it. + /// + internal readonly record struct Row( + IPAddress LocalAddress, ushort LocalPort, + IPAddress RemoteAddress, ushort RemotePort, + uint State, uint Pid, long CreateTime = 0); + + /// The process that owns the client end of a connection, and when that connection was created. + internal readonly record struct Owner(uint Pid, long Created); + + /// The raw bytes of one of the system tables. Null when the call fails. + private static byte[]? ReadBytes(uint family, int tableClass) + { + uint size = 0; + // The table can grow between the size query and the read; try a few times. + for (var attempt = 0; attempt < 5; attempt++) + { + var buffer = Marshal.AllocHGlobal((int)Math.Max(size, 4)); + try + { + var result = GetExtendedTcpTable(buffer, ref size, false, family, tableClass, 0); + if (result == NO_ERROR) + { + var bytes = new byte[size]; + Marshal.Copy(buffer, bytes, 0, (int)size); + return bytes; + } + if (result != ERROR_INSUFFICIENT_BUFFER) + return null; + size += 4096; // headroom for rows added meanwhile + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + return null; + } + + /// Reads one of the system tables. An empty list when the call fails. + internal static IReadOnlyList Read(bool ipv6, bool listenersOnly) + { + var tableClass = listenersOnly ? TCP_TABLE_OWNER_PID_LISTENER : TCP_TABLE_OWNER_PID_CONNECTIONS; + var bytes = ReadBytes(ipv6 ? AF_INET6 : AF_INET, tableClass); + if (bytes is null) return Array.Empty(); + return ipv6 ? ParseV6(bytes) : ParseV4(bytes); + } + + /// + /// Every connection, both address families, each with the time it was created. Null when either table + /// cannot be read: a failed read is never reported as "no connections". + /// + internal static IReadOnlyList? ReadConnectionsWithTimes() + { + var v4 = ReadBytes(AF_INET, TCP_TABLE_OWNER_MODULE_CONNECTIONS); + var v6 = ReadBytes(AF_INET6, TCP_TABLE_OWNER_MODULE_CONNECTIONS); + if (v4 is null || v6 is null) return null; + return ParseOwnerModuleV4(v4).Concat(ParseOwnerModuleV6(v6)).ToArray(); + } + + /// A port as stored in the table: the low 16 bits of a DWORD, in network byte order. + private static ushort DecodePort(uint value) => + BinaryPrimitives.ReverseEndianness((ushort)(value & 0xFFFF)); + + /// MIB_TCPTABLE_OWNER_PID: a count, then 24-byte rows. + internal static IReadOnlyList ParseV4(ReadOnlySpan table) + { + const int rowSize = 24; + var rows = new List(); + if (table.Length < 4) return rows; + + var count = BinaryPrimitives.ReadUInt32LittleEndian(table); + for (var i = 0; i < count; i++) + { + var at = 4 + i * rowSize; + if (at + rowSize > table.Length) break; + var row = table.Slice(at, rowSize); + rows.Add(new Row( + new IPAddress(row.Slice(4, 4)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(8, 4))), + new IPAddress(row.Slice(12, 4)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(16, 4))), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(0, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(20, 4)))); + } + return rows; + } + + /// MIB_TCP6TABLE_OWNER_PID: a count, then 56-byte rows. + internal static IReadOnlyList ParseV6(ReadOnlySpan table) + { + const int rowSize = 56; + var rows = new List(); + if (table.Length < 4) return rows; + + var count = BinaryPrimitives.ReadUInt32LittleEndian(table); + for (var i = 0; i < count; i++) + { + var at = 4 + i * rowSize; + if (at + rowSize > table.Length) break; + var row = table.Slice(at, rowSize); + rows.Add(new Row( + new IPAddress(row.Slice(0, 16)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(20, 4))), + new IPAddress(row.Slice(24, 16)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(44, 4))), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(48, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(52, 4)))); + } + return rows; + } + + // The owner-module rows hold a LARGE_INTEGER, so the table struct is 8-byte aligned: the DWORD row + // count is followed by 4 bytes of padding and the rows start at offset 8. + private const int OwnerModuleHeader = 8; + + /// + /// MIB_TCPTABLE_OWNER_MODULE with 160-byte rows: state, laddr, lport, raddr, rport, owning pid (six + /// DWORDs), liCreateTimestamp (8 bytes), then OwningModuleInfo (16 ULONGLONGs) that is not read. + /// + internal static IReadOnlyList ParseOwnerModuleV4(ReadOnlySpan table) + { + const int rowSize = 160; + var rows = new List(); + if (table.Length < 4) return rows; + + var count = BinaryPrimitives.ReadUInt32LittleEndian(table); + for (var i = 0; i < count; i++) + { + var at = OwnerModuleHeader + i * rowSize; + if (at + rowSize > table.Length) break; + var row = table.Slice(at, rowSize); + rows.Add(new Row( + new IPAddress(row.Slice(4, 4)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(8, 4))), + new IPAddress(row.Slice(12, 4)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(16, 4))), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(0, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(20, 4)), + BinaryPrimitives.ReadInt64LittleEndian(row.Slice(24, 8)))); + } + return rows; + } + + /// + /// MIB_TCP6TABLE_OWNER_MODULE with 192-byte rows: laddr[16], lscope, lport, raddr[16], rscope, rport, + /// state, owning pid, liCreateTimestamp (8 bytes at offset 56), then OwningModuleInfo that is not read. + /// + internal static IReadOnlyList ParseOwnerModuleV6(ReadOnlySpan table) + { + const int rowSize = 192; + var rows = new List(); + if (table.Length < 4) return rows; + + var count = BinaryPrimitives.ReadUInt32LittleEndian(table); + for (var i = 0; i < count; i++) + { + var at = OwnerModuleHeader + i * rowSize; + if (at + rowSize > table.Length) break; + var row = table.Slice(at, rowSize); + rows.Add(new Row( + new IPAddress(row.Slice(0, 16)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(20, 4))), + new IPAddress(row.Slice(24, 16)), + DecodePort(BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(44, 4))), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(48, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(52, 4)), + BinaryPrimitives.ReadInt64LittleEndian(row.Slice(56, 8)))); + } + return rows; + } + + /// + /// The one process that listens on . Nothing when no process does, or when + /// more than one does (an Apollo on the wildcard address and a different process on one specific + /// address is a situation to refuse, not to guess about). + /// + internal static uint? ListenerPid(ushort port, Func>? readListeners = null) + { + readListeners ??= ipv6 => Read(ipv6, listenersOnly: true); + var pids = new HashSet(); + foreach (var row in readListeners(false).Concat(readListeners(true))) + { + if (row.State == StateListen && row.LocalPort == port) + pids.Add(row.Pid); + } + return pids.Count == 1 ? pids.First() : null; + } + + /// + /// The one process that owns the CLIENT end of a loopback connection, and when the connection was + /// created: the row whose local port is and whose remote port is + /// , with both addresses on loopback. Nothing when there is no such row, + /// the table could not be read ( is null), the owner is process 0, or the rows + /// disagree about the owner or the creation time. The same process on both address stacks is one owner. + /// + internal static Owner? ClientOwner(IReadOnlyList? rows, ushort clientPort, ushort serverPort) + { + if (rows is null) return null; + + var owners = new HashSet(); + foreach (var row in rows) + { + if (row.State == StateEstablished + && row.LocalPort == clientPort + && row.RemotePort == serverPort + && IPAddress.IsLoopback(row.LocalAddress) + && IPAddress.IsLoopback(row.RemoteAddress)) + { + if (row.Pid == 0) return null; + owners.Add(new Owner(row.Pid, row.CreateTime)); + } + } + return owners.Count == 1 ? owners.First() : null; + } +} diff --git a/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs b/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs index 7c6e4e2..6ea9e21 100644 --- a/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs +++ b/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs @@ -72,6 +72,17 @@ public async Task CollectAsync(CancellationToken ct = default) return info; } + /// + /// The standalone Apollo's main port, without the HTTPS probe or the pairing count that + /// also does. Null when no standalone Apollo is running. Cheap enough + /// to call for every USB control request. + /// + public int? TryResolveMainPort() + { + var host = FindStandaloneApollo(); + return host is null ? null : ResolvePort(host.Value.ExePath); + } + /// /// The first Apollo process that is not MultiSeat's. Mirrors the ownership test used by /// startup cleanup. Returns null when only MultiSeat's own instances are running. diff --git a/src/MultiSeat.Service/Program.cs b/src/MultiSeat.Service/Program.cs index fd67541..5fb074e 100644 --- a/src/MultiSeat.Service/Program.cs +++ b/src/MultiSeat.Service/Program.cs @@ -330,6 +330,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Host Apollo USB forwarding: the control-pipe client, what talks to it, and the console-session check +// that gates every change made through the API. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // The DWM interval in effect now. Seeded from configuration once; after that the settings API diff --git a/src/MultiSeat.Tests/HostUsb/ApolloUsbControlClientTests.cs b/src/MultiSeat.Tests/HostUsb/ApolloUsbControlClientTests.cs new file mode 100644 index 0000000..ced0b7a --- /dev/null +++ b/src/MultiSeat.Tests/HostUsb/ApolloUsbControlClientTests.cs @@ -0,0 +1,315 @@ +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using MultiSeat.Service.HostUsb; +using Xunit; + +namespace MultiSeat.Tests.HostUsb; + +/// +/// The pipe client against REAL named pipes served by the test. The server here owns the pipe as the +/// current user (set explicitly in its security descriptor), so the production rule "SYSTEM or +/// Administrators only" refuses it whether the tests run elevated or not; tests that need the client +/// to go on widen the owner rule to this user. +/// +public class ApolloUsbControlClientTests +{ + internal static SecurityIdentifier Me => WindowsIdentity.GetCurrent().User!; + + internal static string UniqueLeaf() => $"MultiSeatTest-{Environment.ProcessId}-{Guid.NewGuid():N}"; + + internal static ApolloUsbControlClient.Options Lenient( + TimeSpan? io = null, TimeSpan? connect = null, int? maxReply = null) => new() + { + OwnerAcceptable = sid => sid == Me, + IoTimeout = io ?? TimeSpan.FromSeconds(5), + ConnectBudget = connect ?? TimeSpan.FromMilliseconds(2500), + MaxReplyBytes = maxReply ?? 256 * 1024, + }; + + /// A pipe server owned by the current user, with one scripted conversation per connection. + internal sealed class FakeServer : IAsyncDisposable + { + public string Leaf { get; } + public List Received { get; } = new(); + private readonly NamedPipeServerStream _pipe; + private readonly Task _run; + + public FakeServer(string leaf, Func script, bool readRequest = true, int maxInstances = 1) + { + Leaf = leaf; + var security = new PipeSecurity(); + security.AddAccessRule(new PipeAccessRule(Me, PipeAccessRights.FullControl, AccessControlType.Allow)); + security.SetOwner(Me); + _pipe = NamedPipeServerStreamAcl.Create( + leaf, PipeDirection.InOut, maxInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 4096, 4096, security); + + _run = Task.Run(async () => + { + try + { + await _pipe.WaitForConnectionAsync(); + string? line = null; + if (readRequest) + { + line = await ReadLineAsync(_pipe, TimeSpan.FromSeconds(2)); + lock (Received) { if (line is not null) Received.Add(line); } + } + await script(_pipe, line); + } + catch (Exception) + { + // the client went away: the script is over + } + }); + } + + /// The token level the server sees when it acts as the client, after the request was read. + public static int? ReadTokenLevel(NamedPipeServerStream pipe) + { + int? level = null; + pipe.RunAsClient(() => + { + if (OpenThreadToken(GetCurrentThread(), 0x0008 /* TOKEN_QUERY */, true, out var token)) + { + if (GetTokenInformation(token, 9 /* TokenImpersonationLevel */, out int value, sizeof(int), out _)) + level = value; + CloseHandle(token); + } + }); + return level; + } + + public async ValueTask DisposeAsync() + { + _pipe.Dispose(); + try { await _run.WaitAsync(TimeSpan.FromSeconds(3)); } catch { /* ended */ } + } + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenThreadToken(IntPtr thread, uint access, bool openAsSelf, out IntPtr token); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation(IntPtr token, int infoClass, out int info, int length, out int returned); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentThread(); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + } + + internal static async Task ReadLineAsync(Stream stream, TimeSpan limit) + { + using var cts = new CancellationTokenSource(limit); + var bytes = new List(); + var one = new byte[1]; + try + { + while (await stream.ReadAsync(one, cts.Token) == 1) + { + if (one[0] == (byte)'\n') + return Encoding.UTF8.GetString(bytes.ToArray()); + bytes.Add(one[0]); + } + } + catch (OperationCanceledException) { } + return bytes.Count == 0 ? null : Encoding.UTF8.GetString(bytes.ToArray()); + } + + internal static Task Reply(Stream pipe, string line) => + pipe.WriteAsync(Encoding.UTF8.GetBytes(line + "\n")).AsTask(); + + private static readonly string OkReply = """{"v":1,"ok":true,"enabled":true}"""; + + private static ApolloUsbControlClient Client(ApolloUsbControlClient.Options options) => new(options); + + // ── the happy path ─────────────────────────────────────────────── + + [Fact(Timeout = 30_000)] + public async Task ATrustedServer_GetsTheRequestAndItsReplyComesBack() + { + await using var server = new FakeServer(UniqueLeaf(), async (pipe, _) => + { + await Reply(pipe, OkReply); + await ReadLineAsync(pipe, TimeSpan.FromSeconds(1)); // Apollo waits for the client to finish + }); + + var reply = await Client(Lenient()).CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.Ok, reply.Outcome); + Assert.True(reply.Body!.Value.GetProperty("enabled").GetBoolean()); + Assert.Equal("""{"v":1,"op":"status"}""", Assert.Single(server.Received)); + } + + // ── who is on the other end: nothing is sent unless both checks pass ─ + + [Fact(Timeout = 30_000)] + public async Task AServerOwnedByAnOrdinaryUser_IsRefusedBeforeAnythingIsSent() + { + if (Me.Value == "S-1-5-18") return; // SYSTEM cannot stand in for an ordinary user + await using var server = new FakeServer(UniqueLeaf(), (_, _) => Task.CompletedTask); + + // The production owner rule, not the lenient one. + var reply = await new ApolloUsbControlClient().CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.UntrustedServer, reply.Outcome); + Assert.Empty(server.Received); + } + + [Fact(Timeout = 30_000)] + public async Task AServerThatIsNotTheProcessWeExpect_IsRefusedBeforeAnythingIsSent() + { + // Each server serves one connection, so each expectation gets its own. + await using var wrongServer = new FakeServer(UniqueLeaf(), (_, _) => Task.CompletedTask); + await using var noneServer = new FakeServer(UniqueLeaf(), (_, _) => Task.CompletedTask); + + var wrong = await Client(Lenient()).CallAsync(wrongServer.Leaf, (uint)Environment.ProcessId + 4, """{"v":1,"op":"status"}""", CancellationToken.None); + var none = await Client(Lenient()).CallAsync(noneServer.Leaf, 0, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.UntrustedServer, wrong.Outcome); + Assert.Equal(UsbPipeOutcome.UntrustedServer, none.Outcome); + Assert.Empty(wrongServer.Received); + Assert.Empty(noneServer.Received); + } + + [Theory] + [InlineData("S-1-5-18", true)] + [InlineData("S-1-5-32-544", true)] + [InlineData("S-1-5-32-545", false)] // Users + [InlineData("S-1-1-0", false)] // Everyone + [InlineData("S-1-5-11", false)] // Authenticated Users + [InlineData("S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", false)] // TrustedInstaller + public void TheOwnerRule_IsSystemAndAdministratorsOnly(string sid, bool trusted) + { + Assert.Equal(trusted, ApolloUsbControlClient.IsTrustedOwner(new SecurityIdentifier(sid))); + } + + [Fact(Timeout = 30_000)] + public async Task ThePipeIsOpenedAtIdentificationLevel_SoTheServerCannotActAsUs() + { + // The server acts as the client after reading the request. The token it gets is identification-only: + // it can see who connected but could never do anything as them. + int? level = null; + await using var server = new FakeServer(UniqueLeaf(), async (pipe, _) => + { + level = FakeServer.ReadTokenLevel(pipe); + await Reply(pipe, OkReply); + }); + + var reply = await Client(Lenient()).CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.Ok, reply.Outcome); + Assert.Equal(1, level); // SecurityIdentification + } + + // ── what can go wrong with the connection ──────────────────────── + + [Fact(Timeout = 30_000)] + public async Task NoPipe_IsUnavailable_AndQuick() + { + var started = DateTime.UtcNow; + var reply = await Client(Lenient()).CallAsync(UniqueLeaf(), (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.Unavailable, reply.Outcome); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(2)); + } + + [Fact(Timeout = 30_000)] + public async Task AServerThatClosesWithoutAReply_MeansBusy() + { + await using var server = new FakeServer(UniqueLeaf(), (pipe, _) => { pipe.Dispose(); return Task.CompletedTask; }); + var reply = await Client(Lenient()).CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + Assert.Equal(UsbPipeOutcome.Busy, reply.Outcome); + } + + [Fact(Timeout = 30_000)] + public async Task AServerThatNeverAnswers_TimesOutAtTheDeadline() + { + await using var server = new FakeServer(UniqueLeaf(), async (_, _) => await Task.Delay(TimeSpan.FromSeconds(20))); + var started = DateTime.UtcNow; + + var reply = await Client(Lenient(io: TimeSpan.FromMilliseconds(400))).CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.Timeout, reply.Outcome); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(4)); + } + + [Fact(Timeout = 30_000)] + public async Task APipeWithAllInstancesBusy_GivesUpAfterTheBudget() + { + // One instance, already taken by somebody else. + var leaf = UniqueLeaf(); + await using var server = new FakeServer(leaf, async (_, _) => await Task.Delay(TimeSpan.FromSeconds(20)), readRequest: false); + using var occupant = new NamedPipeClientStream(".", leaf, PipeDirection.InOut, PipeOptions.Asynchronous); + await occupant.ConnectAsync(2000); + + var started = DateTime.UtcNow; + var reply = await Client(Lenient(connect: TimeSpan.FromMilliseconds(700))).CallAsync(leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + + Assert.Equal(UsbPipeOutcome.Busy, reply.Outcome); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(4)); + } + + [Fact(Timeout = 30_000)] + public async Task ACallerWhoCancels_GetsACancellation_NotATimeout() + { + await using var server = new FakeServer(UniqueLeaf(), async (_, _) => await Task.Delay(TimeSpan.FromSeconds(20))); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + await Assert.ThrowsAnyAsync(() => + Client(Lenient()).CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", cts.Token)); + } + + // ── what the reply may look like ───────────────────────────────── + + [Fact(Timeout = 30_000)] + public async Task AReplyBiggerThanTheCap_IsBad() + { + await using var server = new FakeServer(UniqueLeaf(), async (pipe, _) => + { + await pipe.WriteAsync(new byte[200_000].Select(_ => (byte)'x').ToArray()); + }); + var reply = await Client(Lenient(maxReply: 64 * 1024)).CallAsync(server.Leaf, (uint)Environment.ProcessId, """{"v":1,"op":"status"}""", CancellationToken.None); + Assert.Equal(UsbPipeOutcome.BadReply, reply.Outcome); + } + + [Theory] + [InlineData("not json")] + [InlineData("[1,2]")] + [InlineData("""{"ok":true}""")] // no version + [InlineData("""{"v":2,"ok":true}""")] // wrong version + [InlineData("""{"v":"1","ok":true}""")] // version of the wrong type + [InlineData("""{"v":1}""")] // no ok + [InlineData("""{"v":1,"ok":"yes"}""")] // ok of the wrong type + [InlineData("""{"v":1,"ok":false}""")] // failure with no code + [InlineData("""{"v":1,"ok":false,"error":"rm -rf"}""")] // a code Apollo does not have + [InlineData("""{"v":1,"ok":false,"error":5}""")] + public void AReplyThatIsNotAVersionOneAnswer_IsBad(string line) + { + Assert.Equal(UsbPipeOutcome.BadReply, ApolloUsbControlClient.ParseReply(Encoding.UTF8.GetBytes(line)).Outcome); + } + + [Fact] + public void AReplyThatIsNotUtf8_IsBad() + { + var bytes = Encoding.UTF8.GetBytes("{\"v\":1,\"ok\":true,\"x\":\"").Concat(new byte[] { 0xFF, 0xFE }).Concat(Encoding.UTF8.GetBytes("\"}")).ToArray(); + Assert.Equal(UsbPipeOutcome.BadReply, ApolloUsbControlClient.ParseReply(bytes).Outcome); + } + + [Theory] + [InlineData("bad_request")] + [InlineData("port_refused")] + [InlineData("start_failed")] + [InlineData("shutting_down")] + [InlineData("deny_list_full")] + [InlineData("internal")] + public void AnApolloErrorCode_PassesThrough(string code) + { + var reply = ApolloUsbControlClient.ParseReply(Encoding.UTF8.GetBytes($$"""{"v":1,"ok":false,"error":"{{code}}"}""")); + Assert.Equal(UsbPipeOutcome.ApolloError, reply.Outcome); + Assert.Equal(code, reply.ApolloError); + } +} diff --git a/src/MultiSeat.Tests/HostUsb/ConsoleCallerCheckTests.cs b/src/MultiSeat.Tests/HostUsb/ConsoleCallerCheckTests.cs new file mode 100644 index 0000000..0671db6 --- /dev/null +++ b/src/MultiSeat.Tests/HostUsb/ConsoleCallerCheckTests.cs @@ -0,0 +1,252 @@ +using System.Net; +using MultiSeat.Service.Api; +using MultiSeat.Service.Interop; +using Xunit; + +namespace MultiSeat.Tests.HostUsb; + +/// +/// The rule that lets only the person at the host use USB forwarding: the request must come over +/// loopback from a process of the user logged on at the active console session, and not from one of +/// MultiSeat's seat accounts. Every doubt must refuse. +/// +public class ConsoleCallerCheckTests +{ + private const uint ConsoleSession = 1; + private const string ConsoleUser = "S-1-5-21-1-1-1-1001"; + private const string SeatUser = "S-1-5-21-1-1-1-1002"; + private const long ConnectionCreated = 1_000_000; + + private sealed class FakeOwner : IOwnerProcess + { + public long CreationTime { get; init; } = ConnectionCreated - 10; + public uint? Session { get; init; } = ConsoleSession; + public string? User { get; init; } = ConsoleUser; + public bool Disposed { get; private set; } + public uint? SessionId() => Session; + public string? UserSid() => User; + public void Dispose() => Disposed = true; + } + + /// What the TCP table says about the client end of the connection: its owner and when it was created. + private static TcpTable.Owner? Found(uint pid = 4242, long created = ConnectionCreated) => new(pid, created); + + private sealed class Setup + { + public Func[] Reads { get; set; } = [(_, _) => Found()]; + public int ReadCount; + public FakeOwner? Owner { get; set; } = new(); + public uint Console { get; set; } = ConsoleSession; + public string? ConsoleUserSid { get; set; } = ConsoleUser; + public string[] SeatSids { get; set; } = [SeatUser]; + public Func? ConsoleSessionProvider { get; set; } + public uint? OpenedPid; + public int Opened; + + public ConsoleCallerCheck Build() => new( + (clientPort, serverPort) => Reads[Math.Min(ReadCount++, Reads.Length - 1)](clientPort, serverPort), + pid => { OpenedPid = pid; Opened++; return Owner; }, + () => ConsoleSessionProvider?.Invoke() ?? Console, + _ => ConsoleUserSid, + () => SeatSids); + } + + private static CallerVerdict Run(Setup s, IPAddress? remote = null, int remotePort = 50000, int localPort = 9550) => + s.Build().Check(remote ?? IPAddress.Loopback, remotePort, localPort); + + [Fact] + public void TheConsoleUserInTheConsoleSession_IsAllowed() + { + Assert.Equal(CallerVerdict.Allowed, Run(new Setup())); + Assert.Equal(CallerVerdict.Allowed, Run(new Setup(), IPAddress.IPv6Loopback)); + Assert.Equal(CallerVerdict.Allowed, Run(new Setup(), IPAddress.Loopback.MapToIPv6())); + } + + [Fact] + public void ARequestFromASeatsSession_IsRefused() + { + Assert.Equal(CallerVerdict.NotConsoleSession, Run(new Setup { Owner = new FakeOwner { Session = 3 } })); + } + + [Fact] + public void ARequestFromTheNetwork_IsRefusedWithoutLookingAnythingUp() + { + var s = new Setup(); + var check = s.Build(); + + Assert.Equal(CallerVerdict.NotLoopback, check.Check(IPAddress.Parse("192.168.1.20"), 50000, 9550)); + Assert.Equal(CallerVerdict.NotLoopback, check.Check(null, 50000, 9550)); + Assert.Equal(0, s.ReadCount); + } + + [Theory] + [InlineData(0, 9550)] + [InlineData(50000, 0)] + [InlineData(70000, 9550)] + [InlineData(-1, 9550)] + public void ImpossiblePorts_AreUnknown(int remotePort, int localPort) + { + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup(), remotePort: remotePort, localPort: localPort)); + } + + [Fact] + public void WhenTheCallerCannotBeFound_ItIsRefused() + { + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Reads = [(_, _) => null] })); + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Reads = [(_, _) => Found(pid: 0)] })); + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Owner = null })); + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Owner = new FakeOwner { Session = null } })); + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Owner = new FakeOwner { User = null } })); + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { ConsoleUserSid = null })); + } + + [Theory] + [InlineData(0u)] // the services session is not a person + [InlineData(0xFFFFFFFFu)] // nobody is at the console + public void WhenThereIsNoConsoleSession_NothingMatches(uint console) + { + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Console = console, Owner = new FakeOwner { Session = console } })); + } + + [Fact] + public void ACallerInTheServicesSession_IsNeverTheConsole() + { + Assert.Equal(CallerVerdict.NotConsoleSession, Run(new Setup { Owner = new FakeOwner { Session = 0 } })); + } + + [Fact] + public void TheLookupUsesTheClientsPortThenTheServersPort() + { + (ushort Client, ushort Server)? seen = null; + var s = new Setup { Reads = [(c, sv) => { seen = (c, sv); return Found(pid: 77); }] }; + Run(s, remotePort: 51234, localPort: 9550); + Assert.Equal(((ushort)51234, (ushort)9550), seen); + Assert.Equal(77u, s.OpenedPid); + } + + // ── who the caller is, not just where ───────────────────────────── + + [Fact] + public void ADifferentUserInTheConsoleSession_IsRefused() + { + var s = new Setup { Owner = new FakeOwner { User = "S-1-5-21-1-1-1-1500" } }; + Assert.Equal(CallerVerdict.NotConsoleUser, Run(s)); + } + + [Fact] + public void ASeatAccount_IsRefused_EvenWhenItsSessionIsTheConsole() + { + // A seat user who moves their own session to the console with tscon is the console user, but a seat account. + var s = new Setup { ConsoleUserSid = SeatUser, Owner = new FakeOwner { User = SeatUser } }; + Assert.Equal(CallerVerdict.NotConsoleUser, Run(s)); + } + + [Fact] + public void TheSeatAccountComparison_IgnoresCase() + { + var s = new Setup { ConsoleUserSid = SeatUser, Owner = new FakeOwner { User = SeatUser }, SeatSids = [SeatUser.ToLowerInvariant()] }; + Assert.Equal(CallerVerdict.NotConsoleUser, Run(s)); + } + + [Theory] + [InlineData("S-1-5-18")] // SYSTEM + [InlineData("S-1-5-19")] // LocalService + [InlineData("S-1-5-20")] // NetworkService + [InlineData("S-1-5-80-1-2-3-4-5")] // a per-service SID + public void AServiceAccount_IsNeverThePersonAtTheConsole(string sid) + { + var s = new Setup { ConsoleUserSid = sid, Owner = new FakeOwner { User = sid } }; + Assert.Equal(CallerVerdict.NotConsoleUser, Run(s)); + } + + [Fact] + public void IfTheSeatAccountsCannotBeListed_NothingExtraIsRefused() + { + var check = new ConsoleCallerCheck( + (_, _) => Found(), _ => new FakeOwner(), () => ConsoleSession, _ => ConsoleUser, + () => throw new InvalidOperationException("no list")); + Assert.Equal(CallerVerdict.Allowed, check.Check(IPAddress.Loopback, 50000, 9550)); + } + + // ── a process id can be given to another process ────────────────── + + [Fact] + public void AnOwnerCreatedAfterTheConnection_IsARecycledId_AndIsRefused() + { + var s = new Setup { Owner = new FakeOwner { CreationTime = ConnectionCreated + 1 } }; + Assert.Equal(CallerVerdict.UnknownCaller, Run(s)); + } + + [Fact] + public void AnOwnerCreatedAtTheSameTickOrBefore_IsAllowed() + { + Assert.Equal(CallerVerdict.Allowed, Run(new Setup { Owner = new FakeOwner { CreationTime = ConnectionCreated } })); + Assert.Equal(CallerVerdict.Allowed, Run(new Setup { Owner = new FakeOwner { CreationTime = 1 } })); + } + + [Fact] + public void ZeroTimestamps_AreRefused() + { + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Owner = new FakeOwner { CreationTime = 0 } })); + Assert.Equal(CallerVerdict.UnknownCaller, Run(new Setup { Reads = [(_, _) => Found(created: 0)] })); + } + + [Fact] + public void TheOwnerHandle_IsHeldAcrossTheSecondRead_AndReleasedAfterwards() + { + var s = new Setup(); + var disposedAtSecondRead = (bool?)null; + s.Reads = + [ + (_, _) => Found(), + (_, _) => { disposedAtSecondRead = s.Owner!.Disposed; return Found(); }, + ]; + + Assert.Equal(CallerVerdict.Allowed, Run(s)); + Assert.False(disposedAtSecondRead); + Assert.True(s.Owner!.Disposed); + } + + [Fact] + public void TheHandleIsReleased_AlsoWhenTheCallerIsRefused() + { + var s = new Setup { Owner = new FakeOwner { Session = 3 } }; + Run(s); + Assert.True(s.Owner!.Disposed); + } + + [Fact] + public void IfTheTableNamesAnotherOwnerTheSecondTime_ItIsRefused() + { + var s = new Setup + { + Reads = [(_, _) => Found(pid: 4242), (_, _) => Found(pid: 9999)], + }; + Assert.Equal(CallerVerdict.UnknownCaller, Run(s)); + } + + [Fact] + public void IfTheConnectionIsNewerTheSecondTime_ItIsRefused() + { + var s = new Setup + { + Reads = [(_, _) => Found(created: ConnectionCreated), (_, _) => Found(created: ConnectionCreated + 500)], + }; + Assert.Equal(CallerVerdict.UnknownCaller, Run(s)); + } + + [Fact] + public void IfTheSecondReadFails_ItIsRefused() + { + var s = new Setup { Reads = [(_, _) => Found(), (_, _) => null] }; + Assert.Equal(CallerVerdict.UnknownCaller, Run(s)); + } + + [Fact] + public void IfTheConsoleMovesDuringTheCheck_ItIsRefused() + { + var calls = 0; + var s = new Setup { ConsoleSessionProvider = () => calls++ < 1 ? ConsoleSession : 2u }; + Assert.Equal(CallerVerdict.UnknownCaller, Run(s)); + } +} diff --git a/src/MultiSeat.Tests/HostUsb/HostUsbControlTests.cs b/src/MultiSeat.Tests/HostUsb/HostUsbControlTests.cs new file mode 100644 index 0000000..0613ea7 --- /dev/null +++ b/src/MultiSeat.Tests/HostUsb/HostUsbControlTests.cs @@ -0,0 +1,231 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using MultiSeat.Service.HostUsb; +using Xunit; +using static MultiSeat.Tests.HostUsb.ApolloUsbControlClientTests; + +namespace MultiSeat.Tests.HostUsb; + +/// +/// What the dashboard's calls turn into: which Apollo is "the" Apollo, which process must be serving +/// its pipe, what is sent, and how each kind of answer or failure is reported. Real pipes again, so the +/// client's checks run for real. +/// +public class HostUsbControlTests +{ + private static readonly Random Dice = new(); + + /// A main port no real Apollo uses, so the pipe name cannot collide with an installed one. + private static int FreshPort() => 20000 + Dice.Next(0, 9000); + + private static HostUsbControl Control( + int? port, uint? listenerPid, ApolloUsbControlClient.Options? options = null) => + new(() => port, _ => listenerPid, new ApolloUsbControlClient(options ?? Lenient()), NullLogger.Instance); + + private const string FullStatus = """ + {"v":1,"ok":true,"enabled":true,"listening":true,"port":47996,"require_stream":false, + "forwards":[{"client_uuid":"AB-1","client_name":"Player One","busid":"1-2","since_unix":1700000000}], + "clients":[{"uuid":"AB-1","name":"Player One","usb_permission":true},{"uuid":"CD-2","name":"Player Two","usb_permission":false}], + "denied":["EF-3"]} + """; + + private static Task Answer(Stream pipe, string json) => Reply(pipe, json.ReplaceLineEndings("")); + + // ── which Apollo ───────────────────────────────────────────────── + + [Fact(Timeout = 30_000)] + public async Task WithoutAStandaloneApollo_NothingIsContacted() + { + var result = await Control(port: null, listenerPid: 1).StatusAsync(CancellationToken.None); + Assert.Equal(UsbReasons.NoHostApollo, result.Reason); + } + + [Fact(Timeout = 30_000)] + public async Task WhenNoSingleProcessListensOnTheMainPort_NothingIsContacted() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), (_, _) => Task.CompletedTask); + + var result = await Control(port, listenerPid: null).StatusAsync(CancellationToken.None); + + Assert.Equal(UsbReasons.NoHostApollo, result.Reason); + Assert.Empty(server.Received); + } + + [Fact(Timeout = 30_000)] + public async Task ThePipeIsNamedAfterTheMainPort_AndItsServerMustBeTheListener() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), (_, _) => Task.CompletedTask); + + // The pipe exists and is lenient about its owner, but its server is not the process listening on the port. + var result = await Control(port, listenerPid: (uint)Environment.ProcessId + 4).StatusAsync(CancellationToken.None); + + Assert.Equal(UsbReasons.UntrustedServer, result.Reason); + Assert.Empty(server.Received); + } + + [Fact(Timeout = 30_000)] + public async Task NoPipe_MeansControlIsNotSwitchedOnInApollo() + { + var result = await Control(FreshPort(), (uint)Environment.ProcessId).StatusAsync(CancellationToken.None); + Assert.Equal(UsbReasons.PipeUnavailable, result.Reason); + } + + [Fact(Timeout = 30_000)] + public async Task APipeOwnedByAnOrdinaryUser_IsReportedAsUntrusted() + { + if (Me.Value == "S-1-5-18") return; + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), (_, _) => Task.CompletedTask); + + var result = await Control(port, (uint)Environment.ProcessId, new ApolloUsbControlClient.Options()).StatusAsync(CancellationToken.None); + + Assert.Equal(UsbReasons.UntrustedServer, result.Reason); + Assert.Empty(server.Received); + } + + // ── what is sent, what comes back ──────────────────────────────── + + [Fact(Timeout = 30_000)] + public async Task Status_AsksForStatus_AndReadsEveryField() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), (pipe, _) => Answer(pipe, FullStatus)); + + var result = await Control(port, (uint)Environment.ProcessId).StatusAsync(CancellationToken.None); + + Assert.True(result.Ok, result.Reason); + Assert.Equal("""{"v":1,"op":"status"}""", Assert.Single(server.Received)); + var status = result.Value!; + Assert.True(status.Enabled); + Assert.True(status.Listening); + Assert.Equal(47996, status.Port); + Assert.False(status.RequireStream); + Assert.Equal(new UsbForward("AB-1", "Player One", "1-2", 1700000000), Assert.Single(status.Forwards)); + Assert.Equal(2, status.Clients.Count); + Assert.True(status.Clients[0].UsbPermission); + Assert.False(status.Clients[1].UsbPermission); + Assert.Equal(new[] { "EF-3" }, status.Denied); + } + + [Fact(Timeout = 30_000)] + public async Task SetEnabled_SendsTheBoolean_AndSaysItIsNotPersisted() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), + (pipe, _) => Answer(pipe, """{"v":1,"ok":true,"enabled":true,"listening":true,"port":47996,"persisted":false}""")); + + var result = await Control(port, (uint)Environment.ProcessId).SetEnabledAsync(true, CancellationToken.None); + + Assert.True(result.Ok, result.Reason); + Assert.Equal("""{"v":1,"op":"set_enabled","enabled":true}""", Assert.Single(server.Received)); + Assert.Equal(new UsbSetEnabledResult(true, true, 47996, false), result.Value); + } + + [Fact(Timeout = 30_000)] + public async Task Revoke_AndClear_SendTheClientUuid() + { + var port = FreshPort(); + await using var revokeServer = new FakeServer(HostUsbControl.PipeLeafFor(port), + (pipe, _) => Answer(pipe, """{"v":1,"ok":true,"forwards_cut":2,"denied":true,"known":true}""")); + var revoked = await Control(port, (uint)Environment.ProcessId).RevokeAsync("AB-1", CancellationToken.None); + Assert.Equal("""{"v":1,"op":"revoke_client","client_uuid":"AB-1"}""", Assert.Single(revokeServer.Received)); + Assert.Equal(new UsbRevokeResult(2, true, true), revoked.Value); + + var port2 = FreshPort(); + await using var clearServer = new FakeServer(HostUsbControl.PipeLeafFor(port2), + (pipe, _) => Answer(pipe, """{"v":1,"ok":true,"denied":false,"known":false}""")); + var cleared = await Control(port2, (uint)Environment.ProcessId).ClearRevokeAsync("ZZ-9", CancellationToken.None); + Assert.Equal("""{"v":1,"op":"clear_revoke","client_uuid":"ZZ-9"}""", Assert.Single(clearServer.Received)); + Assert.Equal(new UsbClearResult(false, false), cleared.Value); + } + + [Fact(Timeout = 30_000)] + public async Task AnApolloErrorCode_IsReportedAsItIs() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), + (pipe, _) => Answer(pipe, """{"v":1,"ok":false,"error":"start_failed"}""")); + + var result = await Control(port, (uint)Environment.ProcessId).SetEnabledAsync(true, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal("start_failed", result.Reason); + } + + [Fact(Timeout = 30_000)] + public async Task AnAnswerOfTheWrongShape_IsABadReply_NotAGuess() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), + (pipe, _) => Answer(pipe, """{"v":1,"ok":true,"enabled":"yes"}""")); + var result = await Control(port, (uint)Environment.ProcessId).StatusAsync(CancellationToken.None); + Assert.Equal(UsbReasons.BadReply, result.Reason); + } + + // ── the strict readers ─────────────────────────────────────────── + + private static JsonElement Json(string text) => JsonDocument.Parse(text).RootElement.Clone(); + + [Fact] + public void ParseStatus_ReadsAValidReply() + { + Assert.NotNull(HostUsbControl.ParseStatus(Json(FullStatus))); + } + + [Theory] + [InlineData("""{"v":1,"ok":true}""")] + [InlineData("""{"enabled":1,"listening":true,"port":1,"require_stream":true,"forwards":[],"clients":[],"denied":[]}""")] + [InlineData("""{"enabled":true,"listening":true,"port":"1","require_stream":true,"forwards":[],"clients":[],"denied":[]}""")] + [InlineData("""{"enabled":true,"listening":true,"port":1,"require_stream":true,"forwards":{},"clients":[],"denied":[]}""")] + [InlineData("""{"enabled":true,"listening":true,"port":1,"require_stream":true,"forwards":[1],"clients":[],"denied":[]}""")] + [InlineData("""{"enabled":true,"listening":true,"port":1,"require_stream":true,"forwards":[],"clients":[{"uuid":"a","name":"b"}],"denied":[]}""")] + [InlineData("""{"enabled":true,"listening":true,"port":1,"require_stream":true,"forwards":[],"clients":[],"denied":[5]}""")] + public void ParseStatus_RefusesAnythingElse(string json) + { + Assert.Null(HostUsbControl.ParseStatus(Json(json))); + } + + [Fact] + public void ParseStatus_RefusesHugeListsAndLongText() + { + var many = string.Join(",", Enumerable.Repeat("""{"uuid":"a","name":"b","usb_permission":true}""", 513)); + Assert.Null(HostUsbControl.ParseStatus(Json($$"""{"enabled":true,"listening":true,"port":1,"require_stream":true,"forwards":[],"clients":[{{many}}],"denied":[]}"""))); + + var longName = new string('x', 300); + Assert.Null(HostUsbControl.ParseStatus(Json($$"""{"enabled":true,"listening":true,"port":1,"require_stream":true,"forwards":[],"clients":[{"uuid":"a","name":"{{longName}}","usb_permission":true}],"denied":[]}"""))); + } + + [Fact] + public void TheOtherReaders_AreStrictToo() + { + Assert.Null(HostUsbControl.ParseSetEnabled(Json("""{"enabled":true,"listening":true,"port":1}"""))); + Assert.Null(HostUsbControl.ParseRevoke(Json("""{"forwards_cut":-1,"denied":true,"known":true}"""))); + Assert.Null(HostUsbControl.ParseRevoke(Json("""{"forwards_cut":1,"denied":true}"""))); + Assert.Null(HostUsbControl.ParseClear(Json("""{"denied":false}"""))); + } + + // ── one conversation at a time ─────────────────────────────────── + + [Fact(Timeout = 30_000)] + public async Task ASecondCallWhileOneIsInFlight_WaitsThenReportsBusy() + { + var port = FreshPort(); + await using var server = new FakeServer(HostUsbControl.PipeLeafFor(port), async (_, _) => await Task.Delay(TimeSpan.FromSeconds(20))); + var control = Control(port, (uint)Environment.ProcessId, Lenient(io: TimeSpan.FromSeconds(15))); + using var cancelFirst = new CancellationTokenSource(); + + var first = control.StatusAsync(cancelFirst.Token); + await Task.Delay(300); // the first is now holding the gate + + var started = DateTime.UtcNow; + var second = await control.StatusAsync(CancellationToken.None); + + Assert.Equal(UsbReasons.Busy, second.Reason); + Assert.InRange(DateTime.UtcNow - started, TimeSpan.FromSeconds(2.5), TimeSpan.FromSeconds(8)); + + cancelFirst.Cancel(); + await Assert.ThrowsAnyAsync(() => first); + } +} diff --git a/src/MultiSeat.Tests/HostUsb/HostUsbEndpointsTests.cs b/src/MultiSeat.Tests/HostUsb/HostUsbEndpointsTests.cs new file mode 100644 index 0000000..7207f0f --- /dev/null +++ b/src/MultiSeat.Tests/HostUsb/HostUsbEndpointsTests.cs @@ -0,0 +1,397 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MultiSeat.Service.Api; +using MultiSeat.Service.HostUsb; +using MultiSeat.Service.Interop; +using Xunit; + +namespace MultiSeat.Tests.HostUsb; + +/// +/// The /api/host/usb routes over real HTTP on a loopback port, through the real route mapping, the real +/// JSON options and the real API key middleware. The control behind them is a recording fake. The +/// console-session rule runs for real in some tests (this process is both client and server, so the +/// real TCP-table lookup finds it) and with scripted answers in the rest. It covers every route here, +/// reading included. +/// +public class HostUsbEndpointsTests +{ + private sealed class FakeControl : IHostUsbControl + { + public List Calls { get; } = new(); + public string? FailWith { get; set; } + + private UsbOutcome Result(string call, T value) + { + lock (Calls) Calls.Add(call); + return FailWith is null ? UsbOutcome.Success(value) : UsbOutcome.Failure(FailWith); + } + + public Task> StatusAsync(CancellationToken ct) => + Task.FromResult(Result("status", new UsbStatus(true, true, 47996, false, + new[] { new UsbForward("AB-1", "Player One", "1-2", 1700000000) }, + new[] { new UsbClient("AB-1", "Player One", true) }, new[] { "EF-3" }))); + + public Task> SetEnabledAsync(bool enabled, CancellationToken ct) => + Task.FromResult(Result($"set_enabled:{enabled}", new UsbSetEnabledResult(enabled, enabled, 47996, false))); + + public Task> RevokeAsync(string clientUuid, CancellationToken ct) => + Task.FromResult(Result($"revoke:{clientUuid}", new UsbRevokeResult(1, true, true))); + + public Task> ClearRevokeAsync(string clientUuid, CancellationToken ct) => + Task.FromResult(Result($"clear:{clientUuid}", new UsbClearResult(false, true))); + } + + private sealed class Harness : IAsyncDisposable + { + public FakeControl Control { get; } = new(); + public HttpClient Http { get; } + private readonly WebApplication _app; + + /// Every (method, path) mapped under /api/host/usb, read from the route table itself. + public IReadOnlyList<(string Method, string Path)> Routes() => + _app.Services.GetRequiredService().Endpoints + .OfType() + .Where(e => e.RoutePattern.RawText!.StartsWith("/api/host/usb", StringComparison.Ordinal)) + .SelectMany(e => (e.Metadata.GetMetadata()?.HttpMethods ?? Array.Empty()) + .Select(m => (m, e.RoutePattern.RawText!))) + .ToList(); + + private Harness(WebApplication app, HttpClient http, FakeControl control) + { _app = app; Http = http; Control = control; } + + public static async Task StartAsync(ConsoleCallerCheck check, string? apiKey = null) + { + var control = new FakeControl(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(o => ApiServer.ConfigureApiJson(o.SerializerOptions)); + builder.Services.AddSingleton(control); + builder.Services.AddSingleton(check); + var app = builder.Build(); + ApiServer.UseApiKeyAuth(app, new ApiAuthState(apiKey is not null, apiKey ?? "")); + HostUsbEndpoints.Map(app); + await app.StartAsync(); + + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + return new Harness(app, new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(30) }, control); + } + + public async ValueTask DisposeAsync() + { + Http.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private const string ConsoleUser = "S-1-5-21-1-1-1-1001"; + + private sealed class StubOwner(uint session, string user) : IOwnerProcess + { + public long CreationTime => 100; + public uint? SessionId() => session; + public string? UserSid() => user; + public void Dispose() { } + } + + /// The rule with a fixed answer for "which session is the caller in". + private static ConsoleCallerCheck Verdict(bool allowed) => + new((_, _) => new TcpTable.Owner(4242, 200), _ => new StubOwner(allowed ? 1u : 3u, ConsoleUser), () => 1u, _ => ConsoleUser, () => []); + + /// + /// The real rule, told that the console session is away from this + /// process's own, with this process's user as the console user. The real TCP table names this process + /// as the client, and the real process lookup reads its session, user and creation time. + /// + private static ConsoleCallerCheck RealRule(uint consoleOffset, bool meIsASeatAccount = false) + { + Kernel32.ProcessIdToSessionId((uint)Environment.ProcessId, out var mine); + string? me; + using (var self = OwnerProcess.Open((uint)Environment.ProcessId)) me = self?.UserSid(); + return new ConsoleCallerCheck( + (clientPort, serverPort) => TcpTable.ClientOwner(TcpTable.ReadConnectionsWithTimes(), clientPort, serverPort), + OwnerProcess.Open, + () => mine + consoleOffset, + _ => me, + () => meIsASeatAccount && me is not null ? [me] : []); + } + + private static HttpRequestMessage Post(string path, string body, string? key = null) + { + var r = new HttpRequestMessage(HttpMethod.Post, path) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + if (key is not null) r.Headers.Add("X-MultiSeat-Key", key); + return r; + } + + // ── reading is for the console too ─────────────────────────────── + + [Fact(Timeout = 60_000)] + public async Task Status_FromTheConsoleSession_ReturnsTheStatus() + { + await using var h = await Harness.StartAsync(Verdict(allowed: true)); + + var resp = await h.Http.GetAsync("/api/host/usb"); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.True(doc.RootElement.GetProperty("available").GetBoolean()); + var status = doc.RootElement.GetProperty("status"); + Assert.Equal(47996, status.GetProperty("port").GetInt32()); + Assert.Equal("Player One", status.GetProperty("clients")[0].GetProperty("name").GetString()); + Assert.Equal("EF-3", status.GetProperty("denied")[0].GetString()); + Assert.Equal(1700000000, status.GetProperty("forwards")[0].GetProperty("sinceUnix").GetInt64()); + } + + [Fact(Timeout = 60_000)] + public async Task Status_FromAnotherSession_IsRefused_AndNeverAsksTheControl() + { + await using var h = await Harness.StartAsync(Verdict(allowed: false)); + + var resp = await h.Http.GetAsync("/api/host/usb"); + var text = await resp.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Equal("""{"error":"console_session_required","reason":"NotConsoleSession"}""", text); + Assert.DoesNotContain("Player One", text); + Assert.Empty(h.Control.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task Status_WhenTheCallerIsUnknown_IsRefused() + { + var unknown = new ConsoleCallerCheck((_, _) => null, _ => new StubOwner(1, ConsoleUser), () => 1u, _ => ConsoleUser, () => []); + await using var h = await Harness.StartAsync(unknown); + + var resp = await h.Http.GetAsync("/api/host/usb"); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Empty(h.Control.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task EveryRouteUnderHostUsb_IsRefusedToAnOutsider() + { + // Read from the route table, so a route added later is covered without anyone remembering to test it. + await using var h = await Harness.StartAsync(Verdict(allowed: false)); + var routes = h.Routes(); + Assert.True(routes.Count >= 4, "the route table was not read"); + + foreach (var (method, path) in routes) + { + var req = new HttpRequestMessage(new HttpMethod(method), path); + if (method != "GET") req.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + var resp = await h.Http.SendAsync(req); + Assert.True(resp.StatusCode == HttpStatusCode.Forbidden, $"{method} {path} answered {(int)resp.StatusCode}"); + } + Assert.Empty(h.Control.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task Status_WhenControlIsUnavailable_IsStill200WithAReason() + { + await using var h = await Harness.StartAsync(Verdict(allowed: true)); + h.Control.FailWith = UsbReasons.PipeUnavailable; + + var resp = await h.Http.GetAsync("/api/host/usb"); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.False(doc.RootElement.GetProperty("available").GetBoolean()); + Assert.Equal("pipe_unavailable", doc.RootElement.GetProperty("reason").GetString()); + } + + // ── changing is for the console session only ───────────────────── + + public static IEnumerable Actions() => new[] + { + new object[] { "/api/host/usb/enabled", """{"enabled":true}""", "set_enabled:True" }, + new object[] { "/api/host/usb/revoke", """{"clientUuid":"AB-1"}""", "revoke:AB-1" }, + new object[] { "/api/host/usb/clear-revoke", """{"clientUuid":"AB-1"}""", "clear:AB-1" }, + }; + + [Theory(Timeout = 60_000)] + [MemberData(nameof(Actions))] + public async Task AnAction_FromTheConsoleSession_GoesThrough(string path, string body, string expectedCall) + { + await using var h = await Harness.StartAsync(Verdict(allowed: true)); + + var resp = await h.Http.SendAsync(Post(path, body)); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal(expectedCall, Assert.Single(h.Control.Calls)); + } + + [Theory(Timeout = 60_000)] + [MemberData(nameof(Actions))] + public async Task AnAction_FromAnotherSession_IsRefused_AndNothingIsDone(string path, string body, string _) + { + await using var h = await Harness.StartAsync(Verdict(allowed: false)); + + var resp = await h.Http.SendAsync(Post(path, body)); + var text = await resp.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Equal("""{"error":"console_session_required","reason":"NotConsoleSession"}""", text); + Assert.Empty(h.Control.Calls); + } + + [Theory(Timeout = 60_000)] + [MemberData(nameof(Actions))] + public async Task AnAction_WhenTheCallerIsUnknown_IsRefused(string path, string body, string _) + { + var unknown = new ConsoleCallerCheck((_, _) => null, _ => new StubOwner(1, ConsoleUser), () => 1u, _ => ConsoleUser, () => []); + await using var h = await Harness.StartAsync(unknown); + + var resp = await h.Http.SendAsync(Post(path, body)); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Empty(h.Control.Calls); + } + + // The real rule, end to end: this process is the client, so the real TCP table names it, and its + // real session is compared with the (told) console session. + + [Fact(Timeout = 60_000)] + public async Task TheRealRule_AllowsARequestFromThisProcess_WhenItIsInTheConsoleSession() + { + await using var h = await Harness.StartAsync(RealRule(consoleOffset: 0)); + var resp = await h.Http.SendAsync(Post("/api/host/usb/enabled", """{"enabled":false}""")); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal("set_enabled:False", Assert.Single(h.Control.Calls)); + } + + [Fact(Timeout = 60_000)] + public async Task TheRealRule_RefusesARequestFromThisProcess_WhenTheConsoleIsAnotherSession() + { + await using var h = await Harness.StartAsync(RealRule(consoleOffset: 1)); + var resp = await h.Http.SendAsync(Post("/api/host/usb/enabled", """{"enabled":true}""")); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Empty(h.Control.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task TheRealRule_RefusesThisProcess_WhenItsUserIsASeatAccount() + { + await using var h = await Harness.StartAsync(RealRule(consoleOffset: 0, meIsASeatAccount: true)); + + Assert.Equal(HttpStatusCode.Forbidden, (await h.Http.GetAsync("/api/host/usb")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await h.Http.SendAsync(Post("/api/host/usb/enabled", """{"enabled":true}"""))).StatusCode); + Assert.Empty(h.Control.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task TheRealRule_AllowsReading_FromThisProcess_WhenItIsTheConsoleUser() + { + await using var h = await Harness.StartAsync(RealRule(consoleOffset: 0)); + var resp = await h.Http.GetAsync("/api/host/usb"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal("status", Assert.Single(h.Control.Calls)); + } + + // ── a polling outsider must not flood the Event Log ────────────── + + private sealed class StepClock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 10, 28, 9, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; + } + + [Fact] + public void ARefusedCallerThatPolls_IsLoggedOncePerMinutePerOutcome_WithTheCountOfTheRest() + { + var clock = new StepClock(); + var log = new HostUsbEndpoints.RefusalLog(clock); + + Assert.True(log.ShouldLog(CallerVerdict.NotConsoleSession, out var skipped)); + Assert.Equal(0, skipped); + for (var i = 0; i < 99; i++) Assert.False(log.ShouldLog(CallerVerdict.NotConsoleSession, out _)); + + // another outcome has its own line + Assert.True(log.ShouldLog(CallerVerdict.UnknownCaller, out _)); + + clock.Now += TimeSpan.FromSeconds(59); + Assert.False(log.ShouldLog(CallerVerdict.NotConsoleSession, out _)); + + clock.Now += TimeSpan.FromSeconds(2); + Assert.True(log.ShouldLog(CallerVerdict.NotConsoleSession, out skipped)); + Assert.Equal(100, skipped); + } + + // ── bad requests do nothing ────────────────────────────────────── + + [Theory(Timeout = 60_000)] + [InlineData("/api/host/usb/enabled", "{}")] + [InlineData("/api/host/usb/enabled", """{"enabled":"yes"}""")] + [InlineData("/api/host/usb/enabled", """{"enabled":null}""")] + [InlineData("/api/host/usb/enabled", "not json")] + [InlineData("/api/host/usb/revoke", "{}")] + [InlineData("/api/host/usb/revoke", """{"clientUuid":""}""")] + [InlineData("/api/host/usb/revoke", """{"clientUuid":"a b"}""")] + [InlineData("/api/host/usb/revoke", """{"clientUuid":"x;y"}""")] + [InlineData("/api/host/usb/revoke", """{"clientUuid":5}""")] + [InlineData("/api/host/usb/clear-revoke", """{"clientUuid":"ÄÖ"}""")] + public async Task ABadRequest_IsRejected_AndNothingIsDone(string path, string body) + { + await using var h = await Harness.StartAsync(Verdict(allowed: true)); + var resp = await h.Http.SendAsync(Post(path, body)); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Empty(h.Control.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task ATooLongUuid_IsRejected() + { + await using var h = await Harness.StartAsync(Verdict(allowed: true)); + var resp = await h.Http.SendAsync(Post("/api/host/usb/revoke", $$"""{"clientUuid":"{{new string('A', 65)}}"}""")); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Empty(h.Control.Calls); + } + + // ── failures keep their fixed words ────────────────────────────── + + [Theory(Timeout = 60_000)] + [InlineData("no_host_apollo", 503)] + [InlineData("pipe_unavailable", 503)] + [InlineData("busy", 503)] + [InlineData("shutting_down", 503)] + [InlineData("timeout", 504)] + [InlineData("port_refused", 409)] + [InlineData("deny_list_full", 409)] + [InlineData("untrusted_server", 502)] + [InlineData("bad_reply", 502)] + [InlineData("start_failed", 502)] + public async Task AFailure_ComesBackAsItsCodeWithAMatchingStatus(string reason, int status) + { + await using var h = await Harness.StartAsync(Verdict(allowed: true)); + h.Control.FailWith = reason; + + var resp = await h.Http.SendAsync(Post("/api/host/usb/enabled", """{"enabled":true}""")); + + Assert.Equal(status, (int)resp.StatusCode); + Assert.Equal($$"""{"error":"{{reason}}"}""", await resp.Content.ReadAsStringAsync()); + } + + // ── the API key still applies ──────────────────────────────────── + + [Fact(Timeout = 60_000)] + public async Task WithAnApiKey_BothReadingAndChangingNeedIt() + { + await using var h = await Harness.StartAsync(Verdict(allowed: true), apiKey: "secret-key"); + + Assert.Equal(HttpStatusCode.Unauthorized, (await h.Http.GetAsync("/api/host/usb")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await h.Http.SendAsync(Post("/api/host/usb/enabled", """{"enabled":true}"""))).StatusCode); + Assert.Empty(h.Control.Calls); + + Assert.Equal(HttpStatusCode.OK, (await h.Http.SendAsync(Post("/api/host/usb/enabled", """{"enabled":true}""", key: "secret-key"))).StatusCode); + } +} diff --git a/src/MultiSeat.Tests/HostUsb/TcpTableTests.cs b/src/MultiSeat.Tests/HostUsb/TcpTableTests.cs new file mode 100644 index 0000000..3509272 --- /dev/null +++ b/src/MultiSeat.Tests/HostUsb/TcpTableTests.cs @@ -0,0 +1,247 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using MultiSeat.Service.Interop; +using Xunit; + +namespace MultiSeat.Tests.HostUsb; + +/// +/// The system TCP table is how the host Apollo's pipe is tied to the process that serves Moonlight, and +/// how the API learns which session a request came from. The parsing is pinned on hand-built tables, +/// the questions on injected rows (every doubtful case must come out as "unknown"), and the real call on +/// a real loopback connection made by this very process. +/// +public class TcpTableTests +{ + private static void PutPort(Span at, ushort port) + { + // Network byte order in the low 16 bits of a DWORD. + at[0] = (byte)(port >> 8); + at[1] = (byte)(port & 0xFF); + } + + private static byte[] V4Table(params (uint State, byte[] Local, ushort LocalPort, byte[] Remote, ushort RemotePort, uint Pid)[] rows) + { + var table = new byte[4 + rows.Length * 24]; + BinaryPrimitives.WriteUInt32LittleEndian(table, (uint)rows.Length); + for (var i = 0; i < rows.Length; i++) + { + var row = table.AsSpan(4 + i * 24, 24); + BinaryPrimitives.WriteUInt32LittleEndian(row, rows[i].State); + rows[i].Local.CopyTo(row.Slice(4, 4)); + PutPort(row.Slice(8, 4), rows[i].LocalPort); + rows[i].Remote.CopyTo(row.Slice(12, 4)); + PutPort(row.Slice(16, 4), rows[i].RemotePort); + BinaryPrimitives.WriteUInt32LittleEndian(row.Slice(20, 4), rows[i].Pid); + } + return table; + } + + [Fact] + public void ParseV4_ReadsStateAddressesPortsAndPid() + { + var table = V4Table((2, new byte[] { 0, 0, 0, 0 }, 47989, new byte[] { 0, 0, 0, 0 }, 0, 1234)); + var rows = TcpTable.ParseV4(table); + + var row = Assert.Single(rows); + Assert.Equal(2u, row.State); + Assert.Equal(IPAddress.Any, row.LocalAddress); + Assert.Equal((ushort)47989, row.LocalPort); + Assert.Equal((ushort)0, row.RemotePort); + Assert.Equal(1234u, row.Pid); + } + + [Fact] + public void ParseV4_ATruncatedTableGivesWhatFitsAndNeverThrows() + { + var table = V4Table( + (2, new byte[] { 127, 0, 0, 1 }, 1000, new byte[4], 0, 1), + (2, new byte[] { 127, 0, 0, 1 }, 1001, new byte[4], 0, 2)); + Assert.Single(TcpTable.ParseV4(table.AsSpan(0, 4 + 24 + 10))); + Assert.Empty(TcpTable.ParseV4(new byte[2])); + Assert.Empty(TcpTable.ParseV4(ReadOnlySpan.Empty)); + } + + [Fact] + public void ParseV6_ReadsTheSameFieldsFromTheWiderRow() + { + var table = new byte[4 + 56]; + BinaryPrimitives.WriteUInt32LittleEndian(table, 1); + var row = table.AsSpan(4, 56); + IPAddress.IPv6Loopback.GetAddressBytes().CopyTo(row.Slice(0, 16)); + PutPort(row.Slice(20, 4), 50000); + IPAddress.IPv6Loopback.GetAddressBytes().CopyTo(row.Slice(24, 16)); + PutPort(row.Slice(44, 4), 47989); + BinaryPrimitives.WriteUInt32LittleEndian(row.Slice(48, 4), 5); + BinaryPrimitives.WriteUInt32LittleEndian(row.Slice(52, 4), 4321); + + var parsed = Assert.Single(TcpTable.ParseV6(table)); + Assert.Equal(IPAddress.IPv6Loopback, parsed.LocalAddress); + Assert.Equal((ushort)50000, parsed.LocalPort); + Assert.Equal((ushort)47989, parsed.RemotePort); + Assert.Equal(5u, parsed.State); + Assert.Equal(4321u, parsed.Pid); + } + + private static TcpTable.Row Row(uint state, ushort localPort, ushort remotePort, uint pid, string local = "127.0.0.1", string remote = "127.0.0.1", long created = 0) => + new(IPAddress.Parse(local), localPort, IPAddress.Parse(remote), remotePort, state, pid, created); + + [Fact] + public void ListenerPid_IsTheOneProcessListeningOnThePort() + { + var pid = TcpTable.ListenerPid(47989, v6 => v6 + ? Array.Empty() + : new[] { Row(TcpTable.StateListen, 47989, 0, 100, "0.0.0.0", "0.0.0.0"), Row(TcpTable.StateListen, 47990, 0, 200) }); + Assert.Equal(100u, pid); + } + + [Fact] + public void ListenerPid_AnApolloOnBothStacksIsStillOneProcess() + { + var pid = TcpTable.ListenerPid(47989, v6 => new[] { Row(TcpTable.StateListen, 47989, 0, 100, v6 ? "::" : "0.0.0.0", v6 ? "::" : "0.0.0.0") }); + Assert.Equal(100u, pid); + } + + [Fact] + public void ListenerPid_TwoDifferentProcessesOnThePortIsUnknown() + { + var pid = TcpTable.ListenerPid(47989, v6 => v6 + ? Array.Empty() + : new[] { Row(TcpTable.StateListen, 47989, 0, 100, "0.0.0.0", "0.0.0.0"), Row(TcpTable.StateListen, 47989, 0, 666) }); + Assert.Null(pid); + } + + [Fact] + public void ListenerPid_NobodyListening_OrOnlyAConnection_IsUnknown() + { + Assert.Null(TcpTable.ListenerPid(47989, _ => Array.Empty())); + Assert.Null(TcpTable.ListenerPid(47989, _ => new[] { Row(TcpTable.StateEstablished, 47989, 50000, 100) })); + } + + [Fact] + public void ClientOwner_IsTheOwnerAndCreationTimeOfTheClientEndOfTheLoopbackConnection() + { + var rows = new[] + { + Row(TcpTable.StateEstablished, 50000, 9550, 321, created: 5000), // the client end + Row(TcpTable.StateEstablished, 9550, 50000, 77, created: 5000), // the server end + Row(TcpTable.StateEstablished, 50001, 9550, 999, created: 6000), // another client + }; + Assert.Equal(new TcpTable.Owner(321, 5000), TcpTable.ClientOwner(rows, 50000, 9550)); + } + + [Fact] + public void ClientOwner_DoubtfulRowsAreUnknown() + { + // not loopback, not established, nothing matching, no read at all, pid 0, or two owners for one connection + Assert.Null(TcpTable.ClientOwner(new[] { Row(TcpTable.StateEstablished, 50000, 9550, 5, "192.168.1.5", "192.168.1.9") }, 50000, 9550)); + Assert.Null(TcpTable.ClientOwner(new[] { Row(TcpTable.StateListen, 50000, 9550, 5) }, 50000, 9550)); + Assert.Null(TcpTable.ClientOwner(Array.Empty(), 50000, 9550)); + Assert.Null(TcpTable.ClientOwner(null, 50000, 9550)); + Assert.Null(TcpTable.ClientOwner(new[] { Row(TcpTable.StateEstablished, 50000, 9550, 0) }, 50000, 9550)); + Assert.Null(TcpTable.ClientOwner(new[] { Row(TcpTable.StateEstablished, 50000, 9550, 5), Row(TcpTable.StateEstablished, 50000, 9550, 6, "::1", "::1") }, 50000, 9550)); + } + + [Fact] + public void ClientOwner_TheSameProcessOnBothStacks_IsOneOwner_ButTwoCreationTimesAreNot() + { + var same = new[] { Row(TcpTable.StateEstablished, 50000, 9550, 5, created: 100), Row(TcpTable.StateEstablished, 50000, 9550, 5, created: 100) }; + Assert.Equal(new TcpTable.Owner(5, 100), TcpTable.ClientOwner(same, 50000, 9550)); + + var differ = new[] { Row(TcpTable.StateEstablished, 50000, 9550, 5, created: 100), Row(TcpTable.StateEstablished, 50000, 9550, 5, created: 200) }; + Assert.Null(TcpTable.ClientOwner(differ, 50000, 9550)); + } + + // The owner-module tables carry each connection's creation time. The table struct holds a LARGE_INTEGER, + // so the row count is followed by 4 bytes of padding and the rows start at offset 8. + + [Fact] + public void ParseOwnerModuleV4_ReadsTheCreationTimeFromTheWideRow() + { + var table = new byte[8 + 160]; + BinaryPrimitives.WriteUInt32LittleEndian(table, 1); + var row = table.AsSpan(8, 160); + BinaryPrimitives.WriteUInt32LittleEndian(row, 5); + new byte[] { 127, 0, 0, 1 }.CopyTo(row.Slice(4, 4)); + PutPort(row.Slice(8, 4), 50000); + new byte[] { 127, 0, 0, 1 }.CopyTo(row.Slice(12, 4)); + PutPort(row.Slice(16, 4), 9550); + BinaryPrimitives.WriteUInt32LittleEndian(row.Slice(20, 4), 321); + BinaryPrimitives.WriteInt64LittleEndian(row.Slice(24, 8), 133_800_000_000_000_000); + + var parsed = Assert.Single(TcpTable.ParseOwnerModuleV4(table)); + Assert.Equal(5u, parsed.State); + Assert.Equal((ushort)50000, parsed.LocalPort); + Assert.Equal((ushort)9550, parsed.RemotePort); + Assert.Equal(321u, parsed.Pid); + Assert.Equal(133_800_000_000_000_000, parsed.CreateTime); + } + + [Fact] + public void ParseOwnerModuleV6_ReadsTheCreationTimeFromTheWideRow() + { + var table = new byte[8 + 192]; + BinaryPrimitives.WriteUInt32LittleEndian(table, 1); + var row = table.AsSpan(8, 192); + IPAddress.IPv6Loopback.GetAddressBytes().CopyTo(row.Slice(0, 16)); + PutPort(row.Slice(20, 4), 50000); + IPAddress.IPv6Loopback.GetAddressBytes().CopyTo(row.Slice(24, 16)); + PutPort(row.Slice(44, 4), 9550); + BinaryPrimitives.WriteUInt32LittleEndian(row.Slice(48, 4), 5); + BinaryPrimitives.WriteUInt32LittleEndian(row.Slice(52, 4), 4321); + BinaryPrimitives.WriteInt64LittleEndian(row.Slice(56, 8), 133_800_000_000_000_001); + + var parsed = Assert.Single(TcpTable.ParseOwnerModuleV6(table)); + Assert.Equal(IPAddress.IPv6Loopback, parsed.LocalAddress); + Assert.Equal(4321u, parsed.Pid); + Assert.Equal(133_800_000_000_000_001, parsed.CreateTime); + } + + [Fact] + public void ParseOwnerModule_ATruncatedTableGivesWhatFitsAndNeverThrows() + { + var table = new byte[8 + 160 * 2]; + BinaryPrimitives.WriteUInt32LittleEndian(table, 2); + Assert.Single(TcpTable.ParseOwnerModuleV4(table.AsSpan(0, 8 + 160 + 10))); + Assert.Empty(TcpTable.ParseOwnerModuleV4(new byte[2])); + Assert.Empty(TcpTable.ParseOwnerModuleV6(ReadOnlySpan.Empty)); + } + + // ---- the real table ----------------------------------------------------------------------- + + [Fact] + public async Task Real_AListenerAndAConnectionMadeByThisProcessAreFoundAsThisProcess() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var serverPort = (ushort)((IPEndPoint)listener.LocalEndpoint).Port; + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, serverPort); + using var accepted = await listener.AcceptTcpClientAsync(); + var clientPort = (ushort)((IPEndPoint)client.Client.LocalEndPoint!).Port; + + var me = (uint)Environment.ProcessId; + Assert.Equal(me, TcpTable.ListenerPid(serverPort)); + + var rows = TcpTable.ReadConnectionsWithTimes(); + Assert.NotNull(rows); + var owner = TcpTable.ClientOwner(rows, clientPort, serverPort); + Assert.Equal(me, owner?.Pid); + + // The connection is newer than this process and not in the future. + var start = Process.GetCurrentProcess().StartTime.ToFileTime(); + Assert.InRange(owner!.Value.Created, start, DateTime.UtcNow.ToFileTime() + TimeSpan.TicksPerSecond); + + // A port nobody uses, and a pair that is not a connection, are unknown. + Assert.Null(TcpTable.ClientOwner(rows, clientPort, (ushort)(serverPort ^ 1))); + } + finally + { + listener.Stop(); + } + } +}