Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 29 additions & 0 deletions src/MultiSeat.Dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import type {
HostApolloInfo,
ScaleChange,
UpdatesState,
UsbState,
UsbSetEnabledResult,
UsbRevokeResult,
UsbClearResult,
} from "./types";

const BASE = "/api";
Expand Down Expand Up @@ -225,6 +229,31 @@ export const host = {
get: () => request<HostApolloInfo>("/host"),
};

// ── Host USB forwarding (the host Apollo's control pipe) ─────────

export const hostUsb = {
get: () => request<UsbState>("/host/usb"),

/** Lasts until the host's Apollo restarts. Refused unless the request comes from the host's own desktop. */
setEnabled: (enabled: boolean) =>
request<UsbSetEnabledResult>("/host/usb/enabled", {
method: "POST",
body: JSON.stringify({ enabled }),
}),

revoke: (clientUuid: string) =>
request<UsbRevokeResult>("/host/usb/revoke", {
method: "POST",
body: JSON.stringify({ clientUuid }),
}),

clearRevoke: (clientUuid: string) =>
request<UsbClearResult>("/host/usb/clear-revoke", {
method: "POST",
body: JSON.stringify({ clientUuid }),
}),
};

// ── Input ─────────────────────────────────────────────────────────

export const input = {
Expand Down
51 changes: 51 additions & 0 deletions src/MultiSeat.Dashboard/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions src/MultiSeat.Dashboard/src/components/HostCard.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<HostApolloInfo | null>(null);
Expand Down Expand Up @@ -151,6 +154,8 @@ export function HostCard() {
{info.note}
</div>
)}

{info.detected && <HostUsbPanel />}
</div>
</div>
);
Expand Down
Loading
Loading