@hediet/linkrpc-cli 0.0.3-next.20261025.3 → 0.0.3-next.20261026.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -794,7 +794,7 @@ initial document:
794
794
  | `connection destroy` | Stop the configured connection broker through its local overlay. |
795
795
  | `json-rpc-stdio <serviceId> [--params <json>]` | In the Hub profile, expose `<serviceId>::jsonRpcConnection::connectRaw` as newline-delimited JSON-RPC on stdin/stdout. Diagnostics remain on stderr. |
796
796
  | `json-rpc-export --target-endpoint <uri> --serviceId <id> --path <path> ...` | In the Hub profile, export multiple JSON-RPC factories through one authenticated raw WebSocket listener. |
797
- | `http-export [--port <n>] [--host <address>]... [--base-domain <domain>]` | In the Hub profile, serve loopback ports of Hub compute nodes at `<port>.<service>.localhost` over HTTP and WebSocket. |
797
+ | `http-export [--port <n>] [--host <address>]... [--base-domain <domain>] [--auth browser\|none]` | In the Hub profile, serve loopback ports of Hub compute nodes at `<port>.<service>.localhost` over HTTP and WebSocket; browsers must be approved once unless `--auth none`. |
798
798
 
799
799
  The inherited `--schema <path-or-url>` option also overlays static
800
800
  `hubrpc.defaults`, `hubrpc.directory`, and `hubrpc.schemas` reflection for
@@ -848,7 +848,7 @@ linkrpc hub http-export # listens on 127.0.0.1:80 and [::1]:80
848
848
  | URL | Shows |
849
849
  | --- | --- |
850
850
  | `http://localhost/` | Services that can forward TCP (`?format=json` for JSON). |
851
- | `http://hediet--tablet--compute.localhost/` | Listening ports of `hediet/tablet/compute` (needs `linkrpc.computeNode.ports`). |
851
+ | `http://hediet--tablet--compute.localhost/` | Listening ports of `hediet/tablet/compute` with each owning process's name, command line, and working directory (needs `linkrpc.computeNode.ports`). |
852
852
  | `http://3000.hediet--tablet--compute.localhost/` | Port 3000 of that compute node, including WebSocket upgrades. |
853
853
 
854
854
  A service is always exactly one label directly below `localhost`, its
@@ -880,6 +880,43 @@ bodies are not rewritten, so they fail rather than reach this machine. Bind
880
880
  loopback only: anyone who can reach the listener can use the export process's
881
881
  Hub identity to reach every forwarded port.
882
882
 
883
+ **Browser approval (prototype, on by default).** Browsers must be approved once
884
+ before they can use the gateway; `--auth none` restores the behavior above.
885
+
886
+ - A browser without approval is redirected to `http://localhost/.linkrpc/login`.
887
+ The gateway creates a signing key for that browser and asks the Hub's
888
+ approvers (`hubAccess::requestAccess`) to let it call `tcpForward` and
889
+ `ports::listListening` on the compute nodes. The page shows a code, the first
890
+ eight characters of the browser key (`id:key:AbCdEfGh…` → `AbCd-EfGh`),
891
+ and continues by itself once approved. Approve only requests whose principal
892
+ starts with the code your browser shows.
893
+ - The gateway then signs that browser's calls with the browser's key and
894
+ granted capabilities, so compute nodes authorize the browser, not the
895
+ gateway. A browser approved for some machines only reaches those.
896
+ - `localhost` keeps the browser's session cookie. Each service site
897
+ (`<service>.localhost`) gets its own cookie on first visit through a
898
+ single-use, 30-second ticket from `localhost`, bound to that site and to a
899
+ state cookie set by the site. Cookies are `HttpOnly`, `Secure`
900
+ (Chromium treats `http://*.localhost` as secure), and never reach forwarded
901
+ apps; apps cannot set them either.
902
+ - Requests without `Sec-Fetch-*` and `Origin` headers (curl, scripts, agents)
903
+ are not affected and keep using the gateway's identity. Browser requests
904
+ other than page navigations get `401` until the browser is approved.
905
+ - Approved sessions are kept in `--session-file` (default
906
+ `%LOCALAPPDATA%\linkrpc\http-export\browser-sessions-<port>.json` or
907
+ `$XDG_STATE_HOME/linkrpc/http-export/…`); it contains the browser keys.
908
+
909
+ The gateway's own pages (`localhost` and `<service>.localhost`, HTML and JSON)
910
+ show command lines, which may contain secrets. They answer only top-level
911
+ navigations, same-origin requests, and clients that send no `Sec-Fetch-*` or
912
+ cross-origin `Origin` header (e.g. `curl`); other requests, including fetches,
913
+ scripts, and iframes from forwarded apps of the same machine, get `403`. They
914
+ send no CORS headers, forbid framing (`frame-ancestors 'none'`,
915
+ `X-Frame-Options: DENY`), and set `nosniff`, `no-store`, `no-referrer`, and
916
+ same-origin resource and opener policies. Command lines and working directories
917
+ are only shown for processes the compute node may inspect, e.g. not for
918
+ elevated or other users' processes on Windows.
919
+
883
920
  Each service and port shares one `tcpForward.multiplex` session (up to 10
884
921
  logical connections, 8 of them for pooled HTTP keep-alive), closed after five
885
922
  idle minutes.
@@ -6,12 +6,13 @@ import { SocketServer } from "@hediet/linkrpc-hub/hub/server/node";
6
6
  import { randomBytes, randomUUID } from "node:crypto";
7
7
  import * as fs from "node:fs";
8
8
  import { readFileSync } from "node:fs";
9
- import path, { dirname } from "node:path";
9
+ import { homedir, hostname } from "node:os";
10
+ import path, { dirname, join } from "node:path";
10
11
  import { spawnCommand } from "@hediet/linkrpc-hub/spawn";
11
12
  import { tapTransport } from "@hediet/linkrpc-hub/hub/server/transit";
12
13
  import * as net from "node:net";
13
14
  import { mkdir, open, readFile, writeFile } from "node:fs/promises";
14
- import { DEFAULT_WALK_DEPTH, HubDirectoryExplorer, fetchSchema, findCoveringCapabilities, hubGrantedServiceIdInterface } from "@hediet/linkrpc/hub/common";
15
+ import { DEFAULT_WALK_DEPTH, HubDirectoryExplorer, fetchSchema, findCoveringCapabilities, hubAccessInterface, hubGrantedServiceIdInterface } from "@hediet/linkrpc/hub/common";
15
16
  import { CONSOLE_INTERFACE_TAG, ConsoleTemplate, consoleAttachResultSchema, consoleServerMessageSchema, decodeConsoleData, encodeConsoleData } from "@hediet/linkrpc-infra/console";
16
17
  import { createInterface } from "node:readline/promises";
17
18
  import { Command, InvalidArgumentError, Option } from "commander";
@@ -3556,12 +3557,54 @@ async function httpExportCommand(options) {
3556
3557
  port: options.port
3557
3558
  })),
3558
3559
  baseDomain: options.baseDomain,
3559
- getTcpForward: (serviceId) => connection.service(serviceId).get(computeNodeTcpForwardInterface),
3560
+ getTcpForward: (serviceId, caller) => connection.service(serviceId).get(computeNodeTcpForwardInterface, signedAs(caller)),
3560
3561
  listServices: () => services.get(),
3561
- listPorts: async (serviceId) => (await connection.service(serviceId).get(computeNodePortsInterface).listListening({})).ports,
3562
+ listPorts: async (serviceId, caller) => (await connection.service(serviceId).get(computeNodePortsInterface, signedAs(caller)).listListening({})).ports,
3563
+ ...options.auth === "browser" ? { approval: {
3564
+ gatewayName: hostname(),
3565
+ ...options.sessionFile === void 0 ? {} : { stateFile: options.sessionFile },
3566
+ requestAccess: (request) => requestBrowserAccess(connection, request)
3567
+ } } : {},
3562
3568
  onError: options.onError
3563
3569
  });
3564
3570
  }
3571
+ /** Per-user state file for approved browser sessions of a gateway on `port`. */
3572
+ function defaultHttpExportSessionFile(port) {
3573
+ const base = process.platform === "win32" ? process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") : process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
3574
+ return join(base, "linkrpc", "http-export", `browser-sessions-${port}.json`);
3575
+ }
3576
+ /** Signs a browser's calls with its own key and approved capabilities instead of the gateway's identity. */
3577
+ function signedAs(caller) {
3578
+ return caller ? {
3579
+ signerOverride: caller.signer,
3580
+ capsOverride: caller.capabilities
3581
+ } : {};
3582
+ }
3583
+ /** Asks the Hub's approvers to grant `request.permissions` to the browser's principal; resolves once decided. */
3584
+ async function requestBrowserAccess(connection, request) {
3585
+ const result = await connection.get(hubAccessInterface).requestAccess({
3586
+ consumer: {
3587
+ name: request.name,
3588
+ purpose: request.purpose,
3589
+ principal: request.principal
3590
+ },
3591
+ permissions: request.permissions.map((permission) => ({
3592
+ ...permission,
3593
+ target: {
3594
+ ...permission.target,
3595
+ members: [...permission.target.members]
3596
+ }
3597
+ })),
3598
+ duration: "persistent"
3599
+ });
3600
+ return result.status === "granted" ? {
3601
+ status: "granted",
3602
+ capabilities: result.capabilities
3603
+ } : {
3604
+ status: result.status,
3605
+ ..."reason" in result && result.reason ? { reason: result.reason } : {}
3606
+ };
3607
+ }
3565
3608
  /** Caches the services that implement `tcpForward`; a slow or partial walk returns what it found. */
3566
3609
  var ForwardableServices = class {
3567
3610
  _channel;
@@ -9090,7 +9133,7 @@ Batch options:
9090
9133
  local.close();
9091
9134
  }
9092
9135
  });
9093
- program.command("http-export").description("Serve loopback ports of Hub compute nodes at <port>.<service>.localhost over HTTP and WebSocket.").option("--port <n>", "Local port to listen on", (value) => parsePositiveInteger(value, "--port"), 80).option("--host <address>", "Local address to bind (repeatable; default 127.0.0.1 and ::1)", collectOption).option("--base-domain <domain>", "Base domain of exported hostnames", "localhost").action(async (opts) => {
9136
+ program.command("http-export").description("Serve loopback ports of Hub compute nodes at <port>.<service>.localhost over HTTP and WebSocket.").option("--port <n>", "Local port to listen on", (value) => parsePositiveInteger(value, "--port"), 80).option("--host <address>", "Local address to bind (repeatable; default 127.0.0.1 and ::1)", collectOption).option("--base-domain <domain>", "Base domain of exported hostnames", "localhost").addOption(new Option("--auth <mode>", "browser: browsers must be approved once through the Hub consent flow (tools without browser headers are not affected); none: no approval").choices(["browser", "none"]).default("browser")).option("--session-file <path>", "Where approved browser sessions are kept across restarts (contains browser keys)").action(async (opts) => {
9094
9137
  const localEp = needEndpoint(endpoint);
9095
9138
  const local = await connect(localEp, wireLog("hub"));
9096
9139
  try {
@@ -9102,10 +9145,12 @@ Batch options:
9102
9145
  port: opts.port,
9103
9146
  hosts: opts.host ?? ["127.0.0.1", "::1"],
9104
9147
  baseDomain: opts.baseDomain,
9148
+ auth: opts.auth,
9149
+ sessionFile: opts.sessionFile ?? defaultHttpExportSessionFile(opts.port),
9105
9150
  onError: (error) => process.stderr.write(`linkrpc: ${error.message}\n`)
9106
9151
  });
9107
9152
  try {
9108
- process.stderr.write(`linkrpc: HTTP export listening at ${server.urls.join(", ")}\n`);
9153
+ process.stderr.write(`linkrpc: HTTP export listening at ${server.urls.join(", ")}${opts.auth === "browser" ? " (browsers need approval; --auth none disables it)" : ""}\n`);
9109
9154
  await _untilSignalled();
9110
9155
  } finally {
9111
9156
  await server.close();
@@ -9352,7 +9397,7 @@ Batch options:
9352
9397
  process.once("SIGINT", onSignal);
9353
9398
  process.once("SIGTERM", onSignal);
9354
9399
  try {
9355
- const { runApprovalUi } = await import("./runApprovalUi-Diol5P5a.js");
9400
+ const { runApprovalUi } = await import("./runApprovalUi-B8Gb0cg3.js");
9356
9401
  await runApprovalUi({
9357
9402
  client,
9358
9403
  signal: stop.signal
@@ -9367,7 +9412,7 @@ Batch options:
9367
9412
  registerViewCommands(program, (action) => withChannel(endpoint, getPrincipalSpec(), action));
9368
9413
  program.command("ui").description("Launch the terminal UI (form-based browser of services + methods).").action(async () => {
9369
9414
  const acquired = await acquireConnection(endpoint);
9370
- const { runUi } = await import("./runUi-DDJrDeBg.js");
9415
+ const { runUi } = await import("./runUi-BYS9ZvbD.js");
9371
9416
  try {
9372
9417
  await runUi({
9373
9418
  endpoint: acquired.ep,
@@ -9930,4 +9975,4 @@ function safeJson(value) {
9930
9975
  //#endregion
9931
9976
  export { formatCondition as _, formatCommandLegend as a, targetId as b, safeTerminalText as c, ObservablePromise as d, observableValue as f, resolveViewTarget as g, Disposable as h, dispatchHostKey as i, approvalDetailLines as l, derived as m, views as n, hostCommands as o, autorun as p, ViewFrame as r, matchesKey as s, runCli as t, ObservableLazyPromise as u, interfaceTarget as v, matchesTarget as y };
9932
9977
 
9933
- //# sourceMappingURL=cli-D_nbiyek.js.map
9978
+ //# sourceMappingURL=cli-CvfXZUzx.js.map