@moxxy/plugin-tunnel-proxy 0.27.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @moxxy/plugin-tunnel-proxy
2
+
3
+ The self-hosted **proxy** tunnel provider — a native-Node replacement for the
4
+ cloudflared/ngrok subprocess providers. It exposes a local port at a stable,
5
+ keypair-derived public URL (`https://<uuid>.proxy.moxxy.ai`) by dialing the
6
+ proxy relay; there is no binary to install, no account, and no login.
7
+
8
+ ## How it works
9
+
10
+ 1. `open({ port, host })` loads the agent's identity key (`@moxxy/e2e/node`,
11
+ `~/.moxxy/proxy-identity.key`) and dials the relay's control WebSocket
12
+ (`wss://relay.<host>/control`).
13
+ 2. The relay sends a challenge nonce; the provider signs it (proof of key
14
+ ownership), and the relay derives `uuid = base32(sha256(pubkey))` and binds
15
+ the subdomain. The provider cross-checks that the relay returned exactly the
16
+ uuid its own key derives.
17
+ 3. For every inbound peer the relay signals, the provider opens a `/data`
18
+ WebSocket and pipes its raw bytes to `host:port`. It is protocol-agnostic —
19
+ the bytes may be an (E2E-wrapped) phone WebSocket or a browser's HTTP.
20
+ 4. The control connection self-heals with capped exponential backoff. The uuid
21
+ is stable across reconnects because it derives from the (stable) public key.
22
+
23
+ The wire protocol is in [`src/protocol.ts`](./src/protocol.ts) — the single
24
+ coupling point with the private relay repo (see its `PROTOCOL.md`).
25
+
26
+ ## Configuration
27
+
28
+ - `MOXXY_PROXY_HOST` — public base host (default `proxy.moxxy.ai`).
29
+ - `createProxyTunnel({ baseHost, controlUrl, identityPath })` — factory form,
30
+ used by tests (point `controlUrl` at a local relay) and embedders.
31
+
32
+ ## Security
33
+
34
+ End-to-end encryption against a malicious relay is **not** in this provider — it
35
+ is a dumb byte pipe. For the mobile path, the phone and a local E2E shim run the
36
+ `@moxxy/e2e` handshake *inside* the tunnel; this provider just carries the
37
+ ciphertext. The browser-preview path has no in-tunnel E2E and relies on the
38
+ relay's wildcard TLS (same posture as cloudflared/ngrok, but self-owned).
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@moxxy/plugin-tunnel-proxy` — the self-hosted `proxy` tunnel provider.
3
+ *
4
+ * Registers the `proxy` provider so registry-driven channels (the web/preview
5
+ * channel) can activate it. The mobile channel imports {@link proxyTunnel} /
6
+ * {@link createProxyTunnel} directly, mirroring how it used to import the
7
+ * cloudflared/ngrok providers.
8
+ */
9
+ import { type Plugin } from '@moxxy/sdk';
10
+ export { createProxyTunnel, proxyTunnel, type ProxyTunnelOptions, type ProxyLogger, } from './provider.js';
11
+ export { PROXY_PROTOCOL_VERSION, DEFAULT_PROXY_HOST, relayControlHost, publicUrl, type ControlServerMsg, type ControlClientMsg, type DataAttachMsg, } from './protocol.js';
12
+ /** The plugin: contributes the `proxy` tunnel provider for auto-discovery. */
13
+ export declare const proxyTunnelPlugin: Plugin;
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AAGvD,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,KAAK,kBAAkB,EACvB,KAAK,WAAW,GACjB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,aAAa,GACnB,MAAM,eAAe,CAAC;AAEvB,8EAA8E;AAC9E,eAAO,MAAM,iBAAiB,EAAE,MAI9B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@moxxy/plugin-tunnel-proxy` — the self-hosted `proxy` tunnel provider.
3
+ *
4
+ * Registers the `proxy` provider so registry-driven channels (the web/preview
5
+ * channel) can activate it. The mobile channel imports {@link proxyTunnel} /
6
+ * {@link createProxyTunnel} directly, mirroring how it used to import the
7
+ * cloudflared/ngrok providers.
8
+ */
9
+ import { definePlugin } from '@moxxy/sdk';
10
+ import { proxyTunnel } from './provider.js';
11
+ export { createProxyTunnel, proxyTunnel, } from './provider.js';
12
+ export { PROXY_PROTOCOL_VERSION, DEFAULT_PROXY_HOST, relayControlHost, publicUrl, } from './protocol.js';
13
+ /** The plugin: contributes the `proxy` tunnel provider for auto-discovery. */
14
+ export const proxyTunnelPlugin = definePlugin({
15
+ name: '@moxxy/plugin-tunnel-proxy',
16
+ version: '0.0.0',
17
+ tunnelProviders: [proxyTunnel],
18
+ });
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,YAAY,EAAe,MAAM,YAAY,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EACL,iBAAiB,EACjB,WAAW,GAGZ,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,GAIV,MAAM,eAAe,CAAC;AAEvB,8EAA8E;AAC9E,MAAM,CAAC,MAAM,iBAAiB,GAAW,YAAY,CAAC;IACpD,IAAI,EAAE,4BAA4B;IAClC,OAAO,EAAE,OAAO;IAChB,eAAe,EAAE,CAAC,WAAW,CAAC;CAC/B,CAAC,CAAC"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The proxy client↔relay wire protocol (v1). This is the single coupling point
3
+ * between this provider (open source) and the private relay repo, which mirrors
4
+ * these shapes. See PROTOCOL.md in the relay repo for the prose spec.
5
+ *
6
+ * Two long-lived agent→relay WebSockets, both to `wss://relay.<baseHost>`:
7
+ * - `/control` — registration (keypair proof-of-possession) + "new inbound
8
+ * connection" signalling. One per agent.
9
+ * - `/data?...` — one per inbound connection; after a one-line `attach`, it
10
+ * carries the raw tunnelled bytes as binary frames.
11
+ *
12
+ * Public peers (phone, browser) reach the agent at `https://<uuid>.<baseHost>/<target>`;
13
+ * the relay terminates TLS, routes by the SNI subdomain to the agent, reads the
14
+ * first path segment as the `target`, and pipes the raw byte stream over a fresh
15
+ * `/data` connection. The agent maps `target` → a local port (several channels
16
+ * multiplex under one uuid). The provider is otherwise a dumb byte pipe — it
17
+ * doesn't parse whether the bytes are WebSocket or HTTP.
18
+ */
19
+ export declare const PROXY_PROTOCOL_VERSION = 1;
20
+ /** Default public base host; the agent dials `relay.<host>`, peers reach `<uuid>.<host>`. */
21
+ export declare const DEFAULT_PROXY_HOST = "proxy.moxxy.ai";
22
+ /** WebSocket paths on the relay control host. */
23
+ export declare const CONTROL_PATH = "/control";
24
+ export declare const DATA_PATH = "/data";
25
+ /** Relay → agent, on the control connection. */
26
+ export type ControlServerMsg =
27
+ /** Sent first: a random base64url nonce the agent must sign to prove key ownership. */
28
+ {
29
+ readonly t: 'challenge';
30
+ readonly v: number;
31
+ readonly nonce: string;
32
+ }
33
+ /** Sent after a valid registration: the derived subdomain + the public base host. */
34
+ | {
35
+ readonly t: 'registered';
36
+ readonly uuid: string;
37
+ readonly host: string;
38
+ }
39
+ /** A new inbound peer connection; the agent must open a `/data` conn with this
40
+ * id. `target` is the first path segment of the peer's request (e.g. `mobile`,
41
+ * `web`), naming which local service the agent pipes it to. */
42
+ | {
43
+ readonly t: 'open';
44
+ readonly connId: string;
45
+ readonly target: string;
46
+ }
47
+ /** Fatal: the relay is rejecting/closing the control connection. */
48
+ | {
49
+ readonly t: 'error';
50
+ readonly message: string;
51
+ };
52
+ /** Agent → relay, on the control connection. */
53
+ export type ControlClientMsg =
54
+ /** Register: present the Ed25519 public key + a signature over the challenge nonce. */
55
+ {
56
+ readonly t: 'register';
57
+ readonly v: number;
58
+ /** base64url(Ed25519 public key). */
59
+ readonly pubkey: string;
60
+ /** base64url(Ed25519 signature over the raw nonce bytes). */
61
+ readonly sig: string;
62
+ };
63
+ /** Agent → relay, the first (text) frame on a `/data` connection. */
64
+ export interface DataAttachMsg {
65
+ readonly t: 'attach';
66
+ readonly v: number;
67
+ /** The single-use capability id the relay sent in the matching `open`. */
68
+ readonly connId: string;
69
+ }
70
+ /** The host the agent dials for control/data (covered by the `*.<host>` cert). */
71
+ export declare function relayControlHost(baseHost: string): string;
72
+ /** The public URL a peer uses to reach this agent (optionally a routing target). */
73
+ export declare function publicUrl(uuid: string, baseHost: string, target?: string): string;
74
+ export declare function encodeJson(msg: unknown): string;
75
+ export declare function decodeControlServerMsg(raw: string): ControlServerMsg;
76
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB,mBAAmB,CAAC;AAEnD,iDAAiD;AACjD,eAAO,MAAM,YAAY,aAAa,CAAC;AACvC,eAAO,MAAM,SAAS,UAAU,CAAC;AAEjC,gDAAgD;AAChD,MAAM,MAAM,gBAAgB;AAC1B,uFAAuF;AACrF;IAAE,QAAQ,CAAC,CAAC,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACzE,qFAAqF;GACnF;IAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAC5E;;gEAEgE;GAC9D;IAAE,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AAC1E,oEAAoE;GAClE;IAAE,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtD,gDAAgD;AAChD,MAAM,MAAM,gBAAgB;AAC1B,uFAAuF;AACvF;IACE,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC;IACvB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB,CAAC;AAEJ,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,CAAC,EAAE,QAAQ,CAAC;IACrB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,kFAAkF;AAClF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,oFAAoF;AACpF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAGjF;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAE/C;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,CAIpE"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The proxy client↔relay wire protocol (v1). This is the single coupling point
3
+ * between this provider (open source) and the private relay repo, which mirrors
4
+ * these shapes. See PROTOCOL.md in the relay repo for the prose spec.
5
+ *
6
+ * Two long-lived agent→relay WebSockets, both to `wss://relay.<baseHost>`:
7
+ * - `/control` — registration (keypair proof-of-possession) + "new inbound
8
+ * connection" signalling. One per agent.
9
+ * - `/data?...` — one per inbound connection; after a one-line `attach`, it
10
+ * carries the raw tunnelled bytes as binary frames.
11
+ *
12
+ * Public peers (phone, browser) reach the agent at `https://<uuid>.<baseHost>/<target>`;
13
+ * the relay terminates TLS, routes by the SNI subdomain to the agent, reads the
14
+ * first path segment as the `target`, and pipes the raw byte stream over a fresh
15
+ * `/data` connection. The agent maps `target` → a local port (several channels
16
+ * multiplex under one uuid). The provider is otherwise a dumb byte pipe — it
17
+ * doesn't parse whether the bytes are WebSocket or HTTP.
18
+ */
19
+ export const PROXY_PROTOCOL_VERSION = 1;
20
+ /** Default public base host; the agent dials `relay.<host>`, peers reach `<uuid>.<host>`. */
21
+ export const DEFAULT_PROXY_HOST = 'proxy.moxxy.ai';
22
+ /** WebSocket paths on the relay control host. */
23
+ export const CONTROL_PATH = '/control';
24
+ export const DATA_PATH = '/data';
25
+ /** The host the agent dials for control/data (covered by the `*.<host>` cert). */
26
+ export function relayControlHost(baseHost) {
27
+ return `relay.${baseHost}`;
28
+ }
29
+ /** The public URL a peer uses to reach this agent (optionally a routing target). */
30
+ export function publicUrl(uuid, baseHost, target) {
31
+ const base = `https://${uuid}.${baseHost}`;
32
+ return target ? `${base}/${target}` : base;
33
+ }
34
+ export function encodeJson(msg) {
35
+ return JSON.stringify(msg);
36
+ }
37
+ export function decodeControlServerMsg(raw) {
38
+ const parsed = JSON.parse(raw);
39
+ if (typeof parsed.t !== 'string')
40
+ throw new Error('proxy: control message missing type');
41
+ return parsed;
42
+ }
43
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC;AAExC,6FAA6F;AAC7F,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC;AAEnD,iDAAiD;AACjD,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;AACvC,MAAM,CAAC,MAAM,SAAS,GAAG,OAAO,CAAC;AAmCjC,kFAAkF;AAClF,MAAM,UAAU,gBAAgB,CAAC,QAAgB;IAC/C,OAAO,SAAS,QAAQ,EAAE,CAAC;AAC7B,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,QAAgB,EAAE,MAAe;IACvE,MAAM,IAAI,GAAG,WAAW,IAAI,IAAI,QAAQ,EAAE,CAAC;IAC3C,OAAO,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7C,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,GAAY;IACrC,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,GAAW;IAChD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAoB,CAAC;IAClD,IAAI,OAAO,MAAM,CAAC,CAAC,KAAK,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACzF,OAAO,MAA0B,CAAC;AACpC,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { type TunnelProviderDef } from '@moxxy/sdk';
2
+ export interface ProxyLogger {
3
+ info?(msg: string, meta?: Record<string, unknown>): void;
4
+ warn?(msg: string, meta?: Record<string, unknown>): void;
5
+ }
6
+ export interface ProxyTunnelOptions {
7
+ /** Public base host. Default: `MOXXY_PROXY_HOST` env, else `proxy.moxxy.ai`. */
8
+ readonly baseHost?: string;
9
+ /** Override the control/data WS base (e.g. `ws://127.0.0.1:PORT` in tests). */
10
+ readonly controlUrl?: string;
11
+ /** Override the identity key path (defaults to `~/.moxxy/proxy-identity.key`). */
12
+ readonly identityPath?: string;
13
+ readonly logger?: ProxyLogger;
14
+ }
15
+ /** Build a `proxy` tunnel provider (factory form, for config/tests). */
16
+ export declare function createProxyTunnel(opts?: ProxyTunnelOptions): TunnelProviderDef;
17
+ /** The default `proxy` provider (reads `MOXXY_PROXY_HOST` at open time). */
18
+ export declare const proxyTunnel: TunnelProviderDef;
19
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AA0BA,OAAO,EAA2C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAe7F,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,IAAI,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1D;AAED,MAAM,WAAW,kBAAkB;IACjC,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,kFAAkF;IAClF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AA4PD,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,kBAAuB,GAAG,iBAAiB,CAwBlF;AAED,4EAA4E;AAC5E,eAAO,MAAM,WAAW,EAAE,iBAAuC,CAAC"}
@@ -0,0 +1,301 @@
1
+ /**
2
+ * The `proxy` tunnel provider — a native-Node replacement for the cloudflared/
3
+ * ngrok subprocess providers.
4
+ *
5
+ * Several channels (the mobile bridge, the web preview, the webhooks listener)
6
+ * can be exposed at once under ONE keypair-derived subdomain: a process-singleton
7
+ * {@link ProxyHub} owns a single control connection per identity and a
8
+ * `target → local port` map. Each `open({ port, host, label })` registers a
9
+ * target and returns `https://<uuid>.<host>/<label>`. For every inbound peer the
10
+ * relay signals (`open { connId, target }`), the hub opens a `/data` WebSocket
11
+ * and pipes its raw bytes to that target's port — protocol-agnostic, so the bytes
12
+ * may be a phone's (E2E-wrapped) WebSocket or a browser's HTTP.
13
+ *
14
+ * The control connection self-heals with capped exponential backoff; because the
15
+ * uuid derives from the (stable) public key, the public URL never changes.
16
+ */
17
+ import { connect as netConnect } from 'node:net';
18
+ import { base64urlDecode, base64urlEncode, deriveUuid, fingerprint, sign, } from '@moxxy/e2e';
19
+ import { loadOrCreateIdentity } from '@moxxy/e2e/node';
20
+ import { defineTunnelProvider } from '@moxxy/sdk';
21
+ import { WebSocket } from 'ws';
22
+ import { CONTROL_PATH, DATA_PATH, DEFAULT_PROXY_HOST, PROXY_PROTOCOL_VERSION, decodeControlServerMsg, encodeJson, publicUrl, relayControlHost, } from './protocol.js';
23
+ const RECONNECT_MIN_MS = 1_000;
24
+ const RECONNECT_MAX_MS = 30_000;
25
+ function resolveBaseHost(opts) {
26
+ return (opts.baseHost ?? process.env.MOXXY_PROXY_HOST ?? DEFAULT_PROXY_HOST).trim();
27
+ }
28
+ /** `ws://relay.<host>` / `wss://relay.<host>` unless an explicit controlUrl is given. */
29
+ function resolveControlBase(opts, baseHost) {
30
+ if (opts.controlUrl)
31
+ return opts.controlUrl.replace(/\/$/, '');
32
+ return `wss://${relayControlHost(baseHost)}`;
33
+ }
34
+ /**
35
+ * One per (controlBase, identity): owns the control connection and the
36
+ * `target → port` map, multiplexing every channel's exposure under one uuid.
37
+ */
38
+ class ProxyHub {
39
+ controlBase;
40
+ baseHost;
41
+ identityPath;
42
+ logger;
43
+ onEmpty;
44
+ identity = null;
45
+ uuid = null;
46
+ control = null;
47
+ registering = null;
48
+ targets = new Map();
49
+ dataConns = new Set();
50
+ localSockets = new Set();
51
+ closed = false;
52
+ reconnectMs = RECONNECT_MIN_MS;
53
+ reconnectTimer = null;
54
+ constructor(controlBase, baseHost, identityPath, logger, onEmpty) {
55
+ this.controlBase = controlBase;
56
+ this.baseHost = baseHost;
57
+ this.identityPath = identityPath;
58
+ this.logger = logger;
59
+ this.onEmpty = onEmpty;
60
+ }
61
+ /** Register a target and return its public URL + a remove() handle. */
62
+ async addTarget(label, host, port) {
63
+ const uuid = await this.ensureRegistered();
64
+ this.targets.set(label, { host, port });
65
+ this.logger?.info?.('proxy: target added', { uuid, label, port });
66
+ return {
67
+ url: publicUrl(uuid, this.baseHost, label),
68
+ close: () => {
69
+ this.removeTarget(label);
70
+ return Promise.resolve();
71
+ },
72
+ };
73
+ }
74
+ removeTarget(label) {
75
+ this.targets.delete(label);
76
+ if (this.targets.size === 0)
77
+ this.close();
78
+ }
79
+ /** Connect + register once; later callers reuse the resolved uuid. */
80
+ async ensureRegistered() {
81
+ if (this.uuid && this.control)
82
+ return this.uuid;
83
+ if (this.registering)
84
+ return this.registering;
85
+ if (!this.identity)
86
+ this.identity = await loadOrCreateIdentity(this.identityPath);
87
+ this.registering = this.connectControl();
88
+ try {
89
+ return await this.registering;
90
+ }
91
+ finally {
92
+ this.registering = null;
93
+ }
94
+ }
95
+ connectControl() {
96
+ const identity = this.identity;
97
+ const expected = deriveUuid(identity.publicKey);
98
+ return new Promise((resolve, reject) => {
99
+ let settled = false;
100
+ const ws = new WebSocket(`${this.controlBase}${CONTROL_PATH}`);
101
+ this.control = ws;
102
+ ws.on('message', (raw) => {
103
+ let msg;
104
+ try {
105
+ msg = decodeControlServerMsg(raw.toString());
106
+ }
107
+ catch {
108
+ return;
109
+ }
110
+ switch (msg.t) {
111
+ case 'challenge': {
112
+ const reply = {
113
+ t: 'register',
114
+ v: PROXY_PROTOCOL_VERSION,
115
+ pubkey: base64urlEncode(identity.publicKey),
116
+ sig: base64urlEncode(sign(base64urlDecode(msg.nonce), identity.secretKey)),
117
+ };
118
+ ws.send(encodeJson(reply));
119
+ return;
120
+ }
121
+ case 'registered': {
122
+ if (msg.uuid !== expected) {
123
+ // A buggy or hostile relay handed back a uuid our key didn't derive.
124
+ if (!settled) {
125
+ settled = true;
126
+ reject(new Error(`proxy: relay returned uuid '${msg.uuid}', expected '${expected}'`));
127
+ }
128
+ this.close();
129
+ return;
130
+ }
131
+ this.uuid = msg.uuid;
132
+ this.reconnectMs = RECONNECT_MIN_MS;
133
+ if (!settled) {
134
+ settled = true;
135
+ resolve(msg.uuid);
136
+ }
137
+ return;
138
+ }
139
+ case 'open':
140
+ this.openDataConn(msg.connId, msg.target);
141
+ return;
142
+ case 'error':
143
+ this.logger?.warn?.('proxy: relay error', { message: msg.message });
144
+ return;
145
+ }
146
+ });
147
+ ws.on('close', () => {
148
+ if (this.control === ws)
149
+ this.control = null;
150
+ if (!settled) {
151
+ settled = true;
152
+ reject(new Error('proxy: control connection closed before registration'));
153
+ }
154
+ this.scheduleReconnect();
155
+ });
156
+ ws.on('error', (err) => {
157
+ this.logger?.warn?.('proxy: control connection error', { err: String(err) });
158
+ // 'close' follows and drives reconnect / rejection.
159
+ });
160
+ });
161
+ }
162
+ scheduleReconnect() {
163
+ if (this.closed || this.reconnectTimer || this.targets.size === 0)
164
+ return;
165
+ const delay = this.reconnectMs;
166
+ this.reconnectMs = Math.min(this.reconnectMs * 2, RECONNECT_MAX_MS);
167
+ this.reconnectTimer = setTimeout(() => {
168
+ this.reconnectTimer = null;
169
+ if (this.closed)
170
+ return;
171
+ this.connectControl()
172
+ .then(() => this.logger?.info?.('proxy: control reconnected'))
173
+ .catch(() => {
174
+ /* 'close' reschedules */
175
+ });
176
+ }, delay);
177
+ this.reconnectTimer.unref?.();
178
+ }
179
+ /** Pipe one inbound peer (raw bytes over a `/data` WS) to its target's port. */
180
+ openDataConn(connId, target) {
181
+ if (this.closed)
182
+ return;
183
+ const entry = this.targets.get(target);
184
+ if (!entry) {
185
+ this.logger?.warn?.('proxy: open for unknown target; dropping', { target });
186
+ return; // relay's attach timeout tears down the waiting ingress
187
+ }
188
+ const ws = new WebSocket(`${this.controlBase}${DATA_PATH}`);
189
+ const local = netConnect({ host: entry.host, port: entry.port });
190
+ this.dataConns.add(ws);
191
+ this.localSockets.add(local);
192
+ let attached = false;
193
+ const localOutbox = [];
194
+ const cleanup = () => {
195
+ this.dataConns.delete(ws);
196
+ this.localSockets.delete(local);
197
+ try {
198
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
199
+ ws.close();
200
+ }
201
+ catch {
202
+ /* ignore */
203
+ }
204
+ try {
205
+ local.destroy();
206
+ }
207
+ catch {
208
+ /* ignore */
209
+ }
210
+ };
211
+ ws.on('open', () => {
212
+ const attach = { t: 'attach', v: PROXY_PROTOCOL_VERSION, connId };
213
+ ws.send(encodeJson(attach));
214
+ attached = true;
215
+ for (const chunk of localOutbox.splice(0))
216
+ ws.send(chunk, { binary: true });
217
+ });
218
+ ws.on('message', (data, isBinary) => {
219
+ if (isBinary)
220
+ local.write(data);
221
+ });
222
+ local.on('data', (buf) => {
223
+ if (attached && ws.readyState === WebSocket.OPEN)
224
+ ws.send(buf, { binary: true });
225
+ else
226
+ localOutbox.push(buf);
227
+ });
228
+ ws.on('close', cleanup);
229
+ ws.on('error', cleanup);
230
+ local.on('close', cleanup);
231
+ local.on('error', cleanup);
232
+ }
233
+ close() {
234
+ if (this.closed)
235
+ return;
236
+ this.closed = true;
237
+ if (this.reconnectTimer) {
238
+ clearTimeout(this.reconnectTimer);
239
+ this.reconnectTimer = null;
240
+ }
241
+ for (const ws of this.dataConns) {
242
+ try {
243
+ ws.close();
244
+ }
245
+ catch {
246
+ /* ignore */
247
+ }
248
+ }
249
+ for (const s of this.localSockets) {
250
+ try {
251
+ s.destroy();
252
+ }
253
+ catch {
254
+ /* ignore */
255
+ }
256
+ }
257
+ this.dataConns.clear();
258
+ this.localSockets.clear();
259
+ this.targets.clear();
260
+ try {
261
+ this.control?.close();
262
+ }
263
+ catch {
264
+ /* ignore */
265
+ }
266
+ this.control = null;
267
+ this.onEmpty();
268
+ }
269
+ }
270
+ /** Process-wide hub registry, so all channels share one control conn per identity. */
271
+ const hubs = new Map();
272
+ function hubKey(controlBase, identityPath) {
273
+ return `${controlBase}${identityPath ?? ''}`;
274
+ }
275
+ /** Build a `proxy` tunnel provider (factory form, for config/tests). */
276
+ export function createProxyTunnel(opts = {}) {
277
+ return defineTunnelProvider({
278
+ name: 'proxy',
279
+ open: async ({ port, host, label }) => {
280
+ const baseHost = resolveBaseHost(opts);
281
+ const controlBase = resolveControlBase(opts, baseHost);
282
+ const key = hubKey(controlBase, opts.identityPath);
283
+ let hub = hubs.get(key);
284
+ if (!hub) {
285
+ hub = new ProxyHub(controlBase, baseHost, opts.identityPath, opts.logger, () => hubs.delete(key));
286
+ hubs.set(key, hub);
287
+ }
288
+ const handle = await hub.addTarget(label ?? '', host, port);
289
+ // Surface the pinned fingerprint once per process for the QR/debugging.
290
+ const identity = await loadOrCreateIdentity(opts.identityPath);
291
+ opts.logger?.info?.('proxy: exposed', {
292
+ url: handle.url,
293
+ fingerprint: fingerprint(identity.publicKey),
294
+ });
295
+ return handle;
296
+ },
297
+ });
298
+ }
299
+ /** The default `proxy` provider (reads `MOXXY_PROXY_HOST` at open time). */
300
+ export const proxyTunnel = createProxyTunnel();
301
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,OAAO,IAAI,UAAU,EAAe,MAAM,UAAU,CAAC;AAC9D,OAAO,EACL,eAAe,EACf,eAAe,EACf,UAAU,EACV,WAAW,EACX,IAAI,GAEL,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAA6C,MAAM,YAAY,CAAC;AAC7F,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,EACL,YAAY,EACZ,SAAS,EACT,kBAAkB,EAClB,sBAAsB,EACtB,sBAAsB,EACtB,UAAU,EACV,SAAS,EACT,gBAAgB,GAGjB,MAAM,eAAe,CAAC;AAiBvB,MAAM,gBAAgB,GAAG,KAAK,CAAC;AAC/B,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC,SAAS,eAAe,CAAC,IAAwB;IAC/C,OAAO,CAAC,IAAI,CAAC,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,kBAAkB,CAAC,CAAC,IAAI,EAAE,CAAC;AACtF,CAAC;AAED,yFAAyF;AACzF,SAAS,kBAAkB,CAAC,IAAwB,EAAE,QAAgB;IACpE,IAAI,IAAI,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAC/D,OAAO,SAAS,gBAAgB,CAAC,QAAQ,CAAC,EAAE,CAAC;AAC/C,CAAC;AAOD;;;GAGG;AACH,MAAM,QAAQ;IAaO;IACA;IACA;IACA;IACA;IAhBX,QAAQ,GAAoB,IAAI,CAAC;IACjC,IAAI,GAAkB,IAAI,CAAC;IAC3B,OAAO,GAAqB,IAAI,CAAC;IACjC,WAAW,GAA2B,IAAI,CAAC;IAClC,OAAO,GAAG,IAAI,GAAG,EAAuB,CAAC;IACzC,SAAS,GAAG,IAAI,GAAG,EAAa,CAAC;IACjC,YAAY,GAAG,IAAI,GAAG,EAAU,CAAC;IAC1C,MAAM,GAAG,KAAK,CAAC;IACf,WAAW,GAAG,gBAAgB,CAAC;IAC/B,cAAc,GAAyC,IAAI,CAAC;IAEpE,YACmB,WAAmB,EACnB,QAAgB,EAChB,YAAgC,EAChC,MAA+B,EAC/B,OAAmB;QAJnB,gBAAW,GAAX,WAAW,CAAQ;QACnB,aAAQ,GAAR,QAAQ,CAAQ;QAChB,iBAAY,GAAZ,YAAY,CAAoB;QAChC,WAAM,GAAN,MAAM,CAAyB;QAC/B,YAAO,GAAP,OAAO,CAAY;IACnC,CAAC;IAEJ,uEAAuE;IACvE,KAAK,CAAC,SAAS,CAAC,KAAa,EAAE,IAAY,EAAE,IAAY;QACvD,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC3C,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QACxC,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,qBAAqB,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAClE,OAAO;YACL,GAAG,EAAE,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC;YAC1C,KAAK,EAAE,GAAG,EAAE;gBACV,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;gBACzB,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;YAC3B,CAAC;SACF,CAAC;IACJ,CAAC;IAEO,YAAY,CAAC,KAAa;QAChC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC3B,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC;YAAE,IAAI,CAAC,KAAK,EAAE,CAAC;IAC5C,CAAC;IAED,sEAAsE;IAC9D,KAAK,CAAC,gBAAgB;QAC5B,IAAI,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC,IAAI,CAAC;QAChD,IAAI,IAAI,CAAC,WAAW;YAAE,OAAO,IAAI,CAAC,WAAW,CAAC;QAC9C,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,IAAI,CAAC,QAAQ,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAClF,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,cAAc,EAAE,CAAC;QACzC,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,CAAC,WAAW,CAAC;QAChC,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QAC1B,CAAC;IACH,CAAC;IAEO,cAAc;QACpB,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAoB,CAAC;QAC3C,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QAChD,OAAO,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC7C,IAAI,OAAO,GAAG,KAAK,CAAC;YACpB,MAAM,EAAE,GAAG,IAAI,SAAS,CAAC,GAAG,IAAI,CAAC,WAAW,GAAG,YAAY,EAAE,CAAC,CAAC;YAC/D,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;YAElB,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE;gBACvB,IAAI,GAAG,CAAC;gBACR,IAAI,CAAC;oBACH,GAAG,GAAG,sBAAsB,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC;gBAC/C,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO;gBACT,CAAC;gBACD,QAAQ,GAAG,CAAC,CAAC,EAAE,CAAC;oBACd,KAAK,WAAW,CAAC,CAAC,CAAC;wBACjB,MAAM,KAAK,GAAqB;4BAC9B,CAAC,EAAE,UAAU;4BACb,CAAC,EAAE,sBAAsB;4BACzB,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,SAAS,CAAC;4BAC3C,GAAG,EAAE,eAAe,CAAC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC;yBAC3E,CAAC;wBACF,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;wBAC3B,OAAO;oBACT,CAAC;oBACD,KAAK,YAAY,CAAC,CAAC,CAAC;wBAClB,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;4BAC1B,qEAAqE;4BACrE,IAAI,CAAC,OAAO,EAAE,CAAC;gCACb,OAAO,GAAG,IAAI,CAAC;gCACf,MAAM,CACJ,IAAI,KAAK,CAAC,+BAA+B,GAAG,CAAC,IAAI,gBAAgB,QAAQ,GAAG,CAAC,CAC9E,CAAC;4BACJ,CAAC;4BACD,IAAI,CAAC,KAAK,EAAE,CAAC;4BACb,OAAO;wBACT,CAAC;wBACD,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC;wBACrB,IAAI,CAAC,WAAW,GAAG,gBAAgB,CAAC;wBACpC,IAAI,CAAC,OAAO,EAAE,CAAC;4BACb,OAAO,GAAG,IAAI,CAAC;4BACf,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;wBACpB,CAAC;wBACD,OAAO;oBACT,CAAC;oBACD,KAAK,MAAM;wBACT,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;wBAC1C,OAAO;oBACT,KAAK,OAAO;wBACV,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,oBAAoB,EAAE,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;wBACpE,OAAO;gBACX,CAAC;YACH,CAAC,CAAC,CAAC;YAEH,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;gBAClB,IAAI,IAAI,CAAC,OAAO,KAAK,EAAE;oBAAE,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;gBAC7C,IAAI,CAAC,OAAO,EAAE,CAAC;oBACb,OAAO,GAAG,IAAI,CAAC;oBACf,MAAM,CAAC,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC,CAAC;gBAC5E,CAAC;gBACD,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC3B,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;gBACrB,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,iCAAiC,EAAE,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;gBAC7E,oDAAoD;YACtD,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,iBAAiB;QACvB,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,cAAc,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC;YAAE,OAAO;QAC1E,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,CAAC;QAC/B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,GAAG,CAAC,EAAE,gBAAgB,CAAC,CAAC;QACpE,IAAI,CAAC,cAAc,GAAG,UAAU,CAAC,GAAG,EAAE;YACpC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;YAC3B,IAAI,IAAI,CAAC,MAAM;gBAAE,OAAO;YACxB,IAAI,CAAC,cAAc,EAAE;iBAClB,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,4BAA4B,CAAC,CAAC;iBAC7D,KAAK,CAAC,GAAG,EAAE;gBACV,yBAAyB;YAC3B,CAAC,CAAC,CAAC;QACP,CAAC,EAAE,KAAK,CAAC,CAAC;QACV,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,CAAC;IAChC,CAAC;IAED,gFAAgF;IACxE,YAAY,CAAC,MAAc,EAAE,MAAc;QACjD,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO;QACxB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACvC,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,0CAA0C,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;YAC5E,OAAO,CAAC,wDAAwD;QAClE,CAAC;QACD,MAAM,EAAE,GAAG,IAAI,SAAS,CAAC,GAAG,IAAI,CAAC,WAAW,GAAG,SAAS,EAAE,CAAC,CAAC;QAC5D,MAAM,KAAK,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QACjE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACvB,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAE7B,IAAI,QAAQ,GAAG,KAAK,CAAC;QACrB,MAAM,WAAW,GAAiB,EAAE,CAAC;QACrC,MAAM,OAAO,GAAG,GAAS,EAAE;YACzB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YAC1B,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAChC,IAAI,CAAC;gBACH,IAAI,EAAE,CAAC,UAAU,KAAK,SAAS,CAAC,IAAI,IAAI,EAAE,CAAC,UAAU,KAAK,SAAS,CAAC,UAAU;oBAAE,EAAE,CAAC,KAAK,EAAE,CAAC;YAC7F,CAAC;YAAC,MAAM,CAAC;gBACP,YAAY;YACd,CAAC;YACD,IAAI,CAAC;gBACH,KAAK,CAAC,OAAO,EAAE,CAAC;YAClB,CAAC;YAAC,MAAM,CAAC;gBACP,YAAY;YACd,CAAC;QACH,CAAC,CAAC;QAEF,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE;YACjB,MAAM,MAAM,GAAkB,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,sBAAsB,EAAE,MAAM,EAAE,CAAC;YACjF,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;YAC5B,QAAQ,GAAG,IAAI,CAAC;YAChB,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;gBAAE,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9E,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE;YAClC,IAAI,QAAQ;gBAAE,KAAK,CAAC,KAAK,CAAC,IAAc,CAAC,CAAC;QAC5C,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,GAAW,EAAE,EAAE;YAC/B,IAAI,QAAQ,IAAI,EAAE,CAAC,UAAU,KAAK,SAAS,CAAC,IAAI;gBAAE,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;;gBAC5E,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC7B,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC3B,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7B,CAAC;IAED,KAAK;QACH,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YACxB,YAAY,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YAClC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;QAC7B,CAAC;QACD,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAChC,IAAI,CAAC;gBACH,EAAE,CAAC,KAAK,EAAE,CAAC;YACb,CAAC;YAAC,MAAM,CAAC;gBACP,YAAY;YACd,CAAC;QACH,CAAC;QACD,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YAClC,IAAI,CAAC;gBACH,CAAC,CAAC,OAAO,EAAE,CAAC;YACd,CAAC;YAAC,MAAM,CAAC;gBACP,YAAY;YACd,CAAC;QACH,CAAC;QACD,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;QACvB,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC;QAC1B,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QACrB,IAAI,CAAC;YACH,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC;QACxB,CAAC;QAAC,MAAM,CAAC;YACP,YAAY;QACd,CAAC;QACD,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QACpB,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;CACF;AAED,sFAAsF;AACtF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAoB,CAAC;AAEzC,SAAS,MAAM,CAAC,WAAmB,EAAE,YAAgC;IACnE,OAAO,GAAG,WAAW,IAAI,YAAY,IAAI,EAAE,EAAE,CAAC;AAChD,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,iBAAiB,CAAC,OAA2B,EAAE;IAC7D,OAAO,oBAAoB,CAAC;QAC1B,IAAI,EAAE,OAAO;QACb,IAAI,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,EAAyB,EAAE;YAC3D,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,CAAC;YACvC,MAAM,WAAW,GAAG,kBAAkB,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;YACvD,MAAM,GAAG,GAAG,MAAM,CAAC,WAAW,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;YACnD,IAAI,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YACxB,IAAI,CAAC,GAAG,EAAE,CAAC;gBACT,GAAG,GAAG,IAAI,QAAQ,CAAC,WAAW,EAAE,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAC7E,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CACjB,CAAC;gBACF,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YACrB,CAAC;YACD,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,SAAS,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;YAC5D,wEAAwE;YACxE,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YAC/D,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,gBAAgB,EAAE;gBACpC,GAAG,EAAE,MAAM,CAAC,GAAG;gBACf,WAAW,EAAE,WAAW,CAAC,QAAQ,CAAC,SAAS,CAAC;aAC7C,CAAC,CAAC;YACH,OAAO,MAAM,CAAC;QAChB,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED,4EAA4E;AAC5E,MAAM,CAAC,MAAM,WAAW,GAAsB,iBAAiB,EAAE,CAAC"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@moxxy/plugin-tunnel-proxy",
3
+ "version": "0.27.0",
4
+ "description": "Self-hosted tunnel provider for moxxy. Dials the proxy relay over an outbound control WebSocket, proves ownership of a keypair-derived subdomain (uuid.proxy.moxxy.ai), and pipes each inbound connection to a local port — a native-Node replacement for the cloudflared/ngrok subprocess providers.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "tunnel",
8
+ "proxy",
9
+ "client"
10
+ ],
11
+ "homepage": "https://moxxy.ai",
12
+ "bugs": {
13
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
18
+ "directory": "packages/plugin-tunnel-proxy"
19
+ },
20
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src"
37
+ ],
38
+ "dependencies": {
39
+ "ws": "^8.18.0",
40
+ "@moxxy/e2e": "0.27.0",
41
+ "@moxxy/sdk": "0.27.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.10.0",
45
+ "@types/ws": "^8.5.0",
46
+ "typescript": "^5.7.3",
47
+ "vitest": "^2.1.8",
48
+ "@moxxy/vitest-preset": "0.0.0",
49
+ "@moxxy/tsconfig": "0.0.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.json",
53
+ "typecheck": "tsc -p tsconfig.json --noEmit",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "clean": "rm -rf dist .turbo"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@moxxy/plugin-tunnel-proxy` — the self-hosted `proxy` tunnel provider.
3
+ *
4
+ * Registers the `proxy` provider so registry-driven channels (the web/preview
5
+ * channel) can activate it. The mobile channel imports {@link proxyTunnel} /
6
+ * {@link createProxyTunnel} directly, mirroring how it used to import the
7
+ * cloudflared/ngrok providers.
8
+ */
9
+ import { definePlugin, type Plugin } from '@moxxy/sdk';
10
+ import { proxyTunnel } from './provider.js';
11
+
12
+ export {
13
+ createProxyTunnel,
14
+ proxyTunnel,
15
+ type ProxyTunnelOptions,
16
+ type ProxyLogger,
17
+ } from './provider.js';
18
+
19
+ export {
20
+ PROXY_PROTOCOL_VERSION,
21
+ DEFAULT_PROXY_HOST,
22
+ relayControlHost,
23
+ publicUrl,
24
+ type ControlServerMsg,
25
+ type ControlClientMsg,
26
+ type DataAttachMsg,
27
+ } from './protocol.js';
28
+
29
+ /** The plugin: contributes the `proxy` tunnel provider for auto-discovery. */
30
+ export const proxyTunnelPlugin: Plugin = definePlugin({
31
+ name: '@moxxy/plugin-tunnel-proxy',
32
+ version: '0.0.0',
33
+ tunnelProviders: [proxyTunnel],
34
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The proxy client↔relay wire protocol (v1). This is the single coupling point
3
+ * between this provider (open source) and the private relay repo, which mirrors
4
+ * these shapes. See PROTOCOL.md in the relay repo for the prose spec.
5
+ *
6
+ * Two long-lived agent→relay WebSockets, both to `wss://relay.<baseHost>`:
7
+ * - `/control` — registration (keypair proof-of-possession) + "new inbound
8
+ * connection" signalling. One per agent.
9
+ * - `/data?...` — one per inbound connection; after a one-line `attach`, it
10
+ * carries the raw tunnelled bytes as binary frames.
11
+ *
12
+ * Public peers (phone, browser) reach the agent at `https://<uuid>.<baseHost>/<target>`;
13
+ * the relay terminates TLS, routes by the SNI subdomain to the agent, reads the
14
+ * first path segment as the `target`, and pipes the raw byte stream over a fresh
15
+ * `/data` connection. The agent maps `target` → a local port (several channels
16
+ * multiplex under one uuid). The provider is otherwise a dumb byte pipe — it
17
+ * doesn't parse whether the bytes are WebSocket or HTTP.
18
+ */
19
+
20
+ export const PROXY_PROTOCOL_VERSION = 1;
21
+
22
+ /** Default public base host; the agent dials `relay.<host>`, peers reach `<uuid>.<host>`. */
23
+ export const DEFAULT_PROXY_HOST = 'proxy.moxxy.ai';
24
+
25
+ /** WebSocket paths on the relay control host. */
26
+ export const CONTROL_PATH = '/control';
27
+ export const DATA_PATH = '/data';
28
+
29
+ /** Relay → agent, on the control connection. */
30
+ export type ControlServerMsg =
31
+ /** Sent first: a random base64url nonce the agent must sign to prove key ownership. */
32
+ | { readonly t: 'challenge'; readonly v: number; readonly nonce: string }
33
+ /** Sent after a valid registration: the derived subdomain + the public base host. */
34
+ | { readonly t: 'registered'; readonly uuid: string; readonly host: string }
35
+ /** A new inbound peer connection; the agent must open a `/data` conn with this
36
+ * id. `target` is the first path segment of the peer's request (e.g. `mobile`,
37
+ * `web`), naming which local service the agent pipes it to. */
38
+ | { readonly t: 'open'; readonly connId: string; readonly target: string }
39
+ /** Fatal: the relay is rejecting/closing the control connection. */
40
+ | { readonly t: 'error'; readonly message: string };
41
+
42
+ /** Agent → relay, on the control connection. */
43
+ export type ControlClientMsg =
44
+ /** Register: present the Ed25519 public key + a signature over the challenge nonce. */
45
+ {
46
+ readonly t: 'register';
47
+ readonly v: number;
48
+ /** base64url(Ed25519 public key). */
49
+ readonly pubkey: string;
50
+ /** base64url(Ed25519 signature over the raw nonce bytes). */
51
+ readonly sig: string;
52
+ };
53
+
54
+ /** Agent → relay, the first (text) frame on a `/data` connection. */
55
+ export interface DataAttachMsg {
56
+ readonly t: 'attach';
57
+ readonly v: number;
58
+ /** The single-use capability id the relay sent in the matching `open`. */
59
+ readonly connId: string;
60
+ }
61
+
62
+ /** The host the agent dials for control/data (covered by the `*.<host>` cert). */
63
+ export function relayControlHost(baseHost: string): string {
64
+ return `relay.${baseHost}`;
65
+ }
66
+
67
+ /** The public URL a peer uses to reach this agent (optionally a routing target). */
68
+ export function publicUrl(uuid: string, baseHost: string, target?: string): string {
69
+ const base = `https://${uuid}.${baseHost}`;
70
+ return target ? `${base}/${target}` : base;
71
+ }
72
+
73
+ export function encodeJson(msg: unknown): string {
74
+ return JSON.stringify(msg);
75
+ }
76
+
77
+ export function decodeControlServerMsg(raw: string): ControlServerMsg {
78
+ const parsed = JSON.parse(raw) as { t?: unknown };
79
+ if (typeof parsed.t !== 'string') throw new Error('proxy: control message missing type');
80
+ return parsed as ControlServerMsg;
81
+ }
@@ -0,0 +1,200 @@
1
+ import { once } from 'node:events';
2
+ import { createServer, type AddressInfo, type Server, type Socket } from 'node:net';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import {
8
+ base64urlDecode,
9
+ base64urlEncode,
10
+ deriveUuid,
11
+ publicKeyFromFingerprint,
12
+ verify,
13
+ } from '@moxxy/e2e';
14
+ import { WebSocketServer, type WebSocket } from 'ws';
15
+ import { createProxyTunnel } from './provider.js';
16
+ import { encodeJson, PROXY_PROTOCOL_VERSION } from './protocol.js';
17
+
18
+ /**
19
+ * A minimal in-process stand-in for the relay: a `ws` server that runs the
20
+ * challenge/register handshake on `/control` and matches `/data` attaches by
21
+ * connId. `uuidOverride` lets a test play a hostile/buggy relay.
22
+ */
23
+ class MockRelay {
24
+ private wss!: WebSocketServer;
25
+ private control: WebSocket | null = null;
26
+ private readonly pendingData = new Map<string, (ws: WebSocket) => void>();
27
+ lastPubkey: Uint8Array | null = null;
28
+ port = 0;
29
+
30
+ constructor(private readonly opts: { uuidOverride?: string } = {}) {}
31
+
32
+ async start(): Promise<void> {
33
+ this.wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
34
+ await once(this.wss, 'listening');
35
+ this.port = (this.wss.address() as AddressInfo).port;
36
+ this.wss.on('connection', (ws, req) => {
37
+ if (req.url?.startsWith('/control')) this.onControl(ws);
38
+ else if (req.url?.startsWith('/data')) this.onData(ws);
39
+ else ws.close();
40
+ });
41
+ }
42
+
43
+ get url(): string {
44
+ return `ws://127.0.0.1:${this.port}`;
45
+ }
46
+
47
+ private onControl(ws: WebSocket): void {
48
+ this.control = ws;
49
+ const nonce = new Uint8Array(16).map((_, i) => (i * 31 + 7) & 0xff);
50
+ ws.on('message', (raw) => {
51
+ const msg = JSON.parse(raw.toString()) as {
52
+ t: string;
53
+ pubkey?: string;
54
+ sig?: string;
55
+ };
56
+ if (msg.t === 'register' && msg.pubkey && msg.sig) {
57
+ const pubkey = base64urlDecode(msg.pubkey);
58
+ const sig = base64urlDecode(msg.sig);
59
+ this.lastPubkey = pubkey;
60
+ if (!verify(sig, nonce, pubkey)) {
61
+ ws.send(encodeJson({ t: 'error', message: 'bad signature' }));
62
+ ws.close();
63
+ return;
64
+ }
65
+ const uuid = this.opts.uuidOverride ?? deriveUuid(pubkey);
66
+ ws.send(encodeJson({ t: 'registered', uuid, host: 'proxy.test' }));
67
+ }
68
+ });
69
+ ws.send(encodeJson({ t: 'challenge', v: PROXY_PROTOCOL_VERSION, nonce: base64urlEncode(nonce) }));
70
+ }
71
+
72
+ private onData(ws: WebSocket): void {
73
+ ws.once('message', (raw) => {
74
+ const msg = JSON.parse(raw.toString()) as { t: string; connId?: string };
75
+ if (msg.t === 'attach' && msg.connId) {
76
+ const waiter = this.pendingData.get(msg.connId);
77
+ this.pendingData.delete(msg.connId);
78
+ waiter?.(ws);
79
+ }
80
+ });
81
+ }
82
+
83
+ /** Signal a new inbound connection and resolve with its attached data WS. */
84
+ openConn(connId: string, target = ''): Promise<WebSocket> {
85
+ const attached = new Promise<WebSocket>((resolve) => this.pendingData.set(connId, resolve));
86
+ this.control?.send(encodeJson({ t: 'open', connId, target }));
87
+ return attached;
88
+ }
89
+
90
+ async stop(): Promise<void> {
91
+ this.wss.close();
92
+ await once(this.wss, 'close').catch(() => undefined);
93
+ }
94
+ }
95
+
96
+ /** A loopback TCP echo server standing in for the local bridge/shim. */
97
+ function startEchoServer(): Promise<{ port: number; close: () => Promise<void> }> {
98
+ return new Promise((resolve) => {
99
+ const sockets = new Set<Socket>();
100
+ const server: Server = createServer((socket) => {
101
+ sockets.add(socket);
102
+ socket.on('data', (b) => socket.write(b));
103
+ socket.on('close', () => sockets.delete(socket));
104
+ socket.on('error', () => sockets.delete(socket));
105
+ });
106
+ server.listen(0, '127.0.0.1', () => {
107
+ resolve({
108
+ port: (server.address() as AddressInfo).port,
109
+ close: () =>
110
+ new Promise<void>((res) => {
111
+ for (const s of sockets) s.destroy();
112
+ server.close(() => res());
113
+ }),
114
+ });
115
+ });
116
+ });
117
+ }
118
+
119
+ describe('proxy provider', () => {
120
+ let relay: MockRelay;
121
+ let echo: { port: number; close: () => Promise<void> };
122
+ let dir: string;
123
+
124
+ beforeEach(async () => {
125
+ dir = await mkdtemp(join(tmpdir(), 'proxy-prov-'));
126
+ echo = await startEchoServer();
127
+ });
128
+ afterEach(async () => {
129
+ await relay?.stop();
130
+ await echo.close();
131
+ await rm(dir, { recursive: true, force: true });
132
+ });
133
+
134
+ it('registers via key proof-of-possession and returns the derived url', async () => {
135
+ relay = new MockRelay();
136
+ await relay.start();
137
+ const provider = createProxyTunnel({
138
+ baseHost: 'proxy.test',
139
+ controlUrl: relay.url,
140
+ identityPath: join(dir, 'id.key'),
141
+ });
142
+
143
+ const handle = await provider.open({ port: echo.port, host: '127.0.0.1' });
144
+ expect(relay.lastPubkey).not.toBeNull();
145
+ const expectedUuid = deriveUuid(relay.lastPubkey as Uint8Array);
146
+ expect(handle.url).toBe(`https://${expectedUuid}.proxy.test`);
147
+ await handle.close();
148
+ });
149
+
150
+ it('pipes inbound bytes to the local port and back', async () => {
151
+ relay = new MockRelay();
152
+ await relay.start();
153
+ const provider = createProxyTunnel({
154
+ baseHost: 'proxy.test',
155
+ controlUrl: relay.url,
156
+ identityPath: join(dir, 'id.key'),
157
+ });
158
+ const handle = await provider.open({ port: echo.port, host: '127.0.0.1' });
159
+
160
+ const dataWs = await relay.openConn('conn-1');
161
+ const echoed = once(dataWs, 'message');
162
+ dataWs.send(Buffer.from('ping-through-tunnel'), { binary: true });
163
+
164
+ const [data] = (await echoed) as [Buffer];
165
+ expect(data.toString()).toBe('ping-through-tunnel');
166
+ await handle.close();
167
+ });
168
+
169
+ it('rejects when the relay returns the wrong uuid (hostile/buggy relay)', async () => {
170
+ relay = new MockRelay({ uuidOverride: 'aaaaaaaaaaaaaaaa' });
171
+ await relay.start();
172
+ const provider = createProxyTunnel({
173
+ baseHost: 'proxy.test',
174
+ controlUrl: relay.url,
175
+ identityPath: join(dir, 'id.key'),
176
+ });
177
+ await expect(provider.open({ port: echo.port, host: '127.0.0.1' })).rejects.toThrow(
178
+ /expected/,
179
+ );
180
+ });
181
+
182
+ it('signs the real challenge so a tampered key would fail verification', async () => {
183
+ relay = new MockRelay();
184
+ await relay.start();
185
+ const provider = createProxyTunnel({
186
+ baseHost: 'proxy.test',
187
+ controlUrl: relay.url,
188
+ identityPath: join(dir, 'id.key'),
189
+ });
190
+ const handle = await provider.open({ port: echo.port, host: '127.0.0.1' });
191
+ // The fingerprint embedded in the url derives from the same key the relay verified.
192
+ const uuid = deriveUuid(relay.lastPubkey as Uint8Array);
193
+ expect(handle.url).toContain(uuid);
194
+ // Sanity: the relay-verified pubkey round-trips through the fingerprint codec.
195
+ expect([...publicKeyFromFingerprint(base64urlEncode(relay.lastPubkey as Uint8Array))]).toEqual([
196
+ ...(relay.lastPubkey as Uint8Array),
197
+ ]);
198
+ await handle.close();
199
+ });
200
+ });
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The `proxy` tunnel provider — a native-Node replacement for the cloudflared/
3
+ * ngrok subprocess providers.
4
+ *
5
+ * Several channels (the mobile bridge, the web preview, the webhooks listener)
6
+ * can be exposed at once under ONE keypair-derived subdomain: a process-singleton
7
+ * {@link ProxyHub} owns a single control connection per identity and a
8
+ * `target → local port` map. Each `open({ port, host, label })` registers a
9
+ * target and returns `https://<uuid>.<host>/<label>`. For every inbound peer the
10
+ * relay signals (`open { connId, target }`), the hub opens a `/data` WebSocket
11
+ * and pipes its raw bytes to that target's port — protocol-agnostic, so the bytes
12
+ * may be a phone's (E2E-wrapped) WebSocket or a browser's HTTP.
13
+ *
14
+ * The control connection self-heals with capped exponential backoff; because the
15
+ * uuid derives from the (stable) public key, the public URL never changes.
16
+ */
17
+ import { connect as netConnect, type Socket } from 'node:net';
18
+ import {
19
+ base64urlDecode,
20
+ base64urlEncode,
21
+ deriveUuid,
22
+ fingerprint,
23
+ sign,
24
+ type Identity,
25
+ } from '@moxxy/e2e';
26
+ import { loadOrCreateIdentity } from '@moxxy/e2e/node';
27
+ import { defineTunnelProvider, type TunnelHandle, type TunnelProviderDef } from '@moxxy/sdk';
28
+ import { WebSocket } from 'ws';
29
+ import {
30
+ CONTROL_PATH,
31
+ DATA_PATH,
32
+ DEFAULT_PROXY_HOST,
33
+ PROXY_PROTOCOL_VERSION,
34
+ decodeControlServerMsg,
35
+ encodeJson,
36
+ publicUrl,
37
+ relayControlHost,
38
+ type ControlClientMsg,
39
+ type DataAttachMsg,
40
+ } from './protocol.js';
41
+
42
+ export interface ProxyLogger {
43
+ info?(msg: string, meta?: Record<string, unknown>): void;
44
+ warn?(msg: string, meta?: Record<string, unknown>): void;
45
+ }
46
+
47
+ export interface ProxyTunnelOptions {
48
+ /** Public base host. Default: `MOXXY_PROXY_HOST` env, else `proxy.moxxy.ai`. */
49
+ readonly baseHost?: string;
50
+ /** Override the control/data WS base (e.g. `ws://127.0.0.1:PORT` in tests). */
51
+ readonly controlUrl?: string;
52
+ /** Override the identity key path (defaults to `~/.moxxy/proxy-identity.key`). */
53
+ readonly identityPath?: string;
54
+ readonly logger?: ProxyLogger;
55
+ }
56
+
57
+ const RECONNECT_MIN_MS = 1_000;
58
+ const RECONNECT_MAX_MS = 30_000;
59
+
60
+ function resolveBaseHost(opts: ProxyTunnelOptions): string {
61
+ return (opts.baseHost ?? process.env.MOXXY_PROXY_HOST ?? DEFAULT_PROXY_HOST).trim();
62
+ }
63
+
64
+ /** `ws://relay.<host>` / `wss://relay.<host>` unless an explicit controlUrl is given. */
65
+ function resolveControlBase(opts: ProxyTunnelOptions, baseHost: string): string {
66
+ if (opts.controlUrl) return opts.controlUrl.replace(/\/$/, '');
67
+ return `wss://${relayControlHost(baseHost)}`;
68
+ }
69
+
70
+ interface TargetEntry {
71
+ readonly host: string;
72
+ readonly port: number;
73
+ }
74
+
75
+ /**
76
+ * One per (controlBase, identity): owns the control connection and the
77
+ * `target → port` map, multiplexing every channel's exposure under one uuid.
78
+ */
79
+ class ProxyHub {
80
+ private identity: Identity | null = null;
81
+ private uuid: string | null = null;
82
+ private control: WebSocket | null = null;
83
+ private registering: Promise<string> | null = null;
84
+ private readonly targets = new Map<string, TargetEntry>();
85
+ private readonly dataConns = new Set<WebSocket>();
86
+ private readonly localSockets = new Set<Socket>();
87
+ private closed = false;
88
+ private reconnectMs = RECONNECT_MIN_MS;
89
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
90
+
91
+ constructor(
92
+ private readonly controlBase: string,
93
+ private readonly baseHost: string,
94
+ private readonly identityPath: string | undefined,
95
+ private readonly logger: ProxyLogger | undefined,
96
+ private readonly onEmpty: () => void,
97
+ ) {}
98
+
99
+ /** Register a target and return its public URL + a remove() handle. */
100
+ async addTarget(label: string, host: string, port: number): Promise<TunnelHandle> {
101
+ const uuid = await this.ensureRegistered();
102
+ this.targets.set(label, { host, port });
103
+ this.logger?.info?.('proxy: target added', { uuid, label, port });
104
+ return {
105
+ url: publicUrl(uuid, this.baseHost, label),
106
+ close: () => {
107
+ this.removeTarget(label);
108
+ return Promise.resolve();
109
+ },
110
+ };
111
+ }
112
+
113
+ private removeTarget(label: string): void {
114
+ this.targets.delete(label);
115
+ if (this.targets.size === 0) this.close();
116
+ }
117
+
118
+ /** Connect + register once; later callers reuse the resolved uuid. */
119
+ private async ensureRegistered(): Promise<string> {
120
+ if (this.uuid && this.control) return this.uuid;
121
+ if (this.registering) return this.registering;
122
+ if (!this.identity) this.identity = await loadOrCreateIdentity(this.identityPath);
123
+ this.registering = this.connectControl();
124
+ try {
125
+ return await this.registering;
126
+ } finally {
127
+ this.registering = null;
128
+ }
129
+ }
130
+
131
+ private connectControl(): Promise<string> {
132
+ const identity = this.identity as Identity;
133
+ const expected = deriveUuid(identity.publicKey);
134
+ return new Promise<string>((resolve, reject) => {
135
+ let settled = false;
136
+ const ws = new WebSocket(`${this.controlBase}${CONTROL_PATH}`);
137
+ this.control = ws;
138
+
139
+ ws.on('message', (raw) => {
140
+ let msg;
141
+ try {
142
+ msg = decodeControlServerMsg(raw.toString());
143
+ } catch {
144
+ return;
145
+ }
146
+ switch (msg.t) {
147
+ case 'challenge': {
148
+ const reply: ControlClientMsg = {
149
+ t: 'register',
150
+ v: PROXY_PROTOCOL_VERSION,
151
+ pubkey: base64urlEncode(identity.publicKey),
152
+ sig: base64urlEncode(sign(base64urlDecode(msg.nonce), identity.secretKey)),
153
+ };
154
+ ws.send(encodeJson(reply));
155
+ return;
156
+ }
157
+ case 'registered': {
158
+ if (msg.uuid !== expected) {
159
+ // A buggy or hostile relay handed back a uuid our key didn't derive.
160
+ if (!settled) {
161
+ settled = true;
162
+ reject(
163
+ new Error(`proxy: relay returned uuid '${msg.uuid}', expected '${expected}'`),
164
+ );
165
+ }
166
+ this.close();
167
+ return;
168
+ }
169
+ this.uuid = msg.uuid;
170
+ this.reconnectMs = RECONNECT_MIN_MS;
171
+ if (!settled) {
172
+ settled = true;
173
+ resolve(msg.uuid);
174
+ }
175
+ return;
176
+ }
177
+ case 'open':
178
+ this.openDataConn(msg.connId, msg.target);
179
+ return;
180
+ case 'error':
181
+ this.logger?.warn?.('proxy: relay error', { message: msg.message });
182
+ return;
183
+ }
184
+ });
185
+
186
+ ws.on('close', () => {
187
+ if (this.control === ws) this.control = null;
188
+ if (!settled) {
189
+ settled = true;
190
+ reject(new Error('proxy: control connection closed before registration'));
191
+ }
192
+ this.scheduleReconnect();
193
+ });
194
+ ws.on('error', (err) => {
195
+ this.logger?.warn?.('proxy: control connection error', { err: String(err) });
196
+ // 'close' follows and drives reconnect / rejection.
197
+ });
198
+ });
199
+ }
200
+
201
+ private scheduleReconnect(): void {
202
+ if (this.closed || this.reconnectTimer || this.targets.size === 0) return;
203
+ const delay = this.reconnectMs;
204
+ this.reconnectMs = Math.min(this.reconnectMs * 2, RECONNECT_MAX_MS);
205
+ this.reconnectTimer = setTimeout(() => {
206
+ this.reconnectTimer = null;
207
+ if (this.closed) return;
208
+ this.connectControl()
209
+ .then(() => this.logger?.info?.('proxy: control reconnected'))
210
+ .catch(() => {
211
+ /* 'close' reschedules */
212
+ });
213
+ }, delay);
214
+ this.reconnectTimer.unref?.();
215
+ }
216
+
217
+ /** Pipe one inbound peer (raw bytes over a `/data` WS) to its target's port. */
218
+ private openDataConn(connId: string, target: string): void {
219
+ if (this.closed) return;
220
+ const entry = this.targets.get(target);
221
+ if (!entry) {
222
+ this.logger?.warn?.('proxy: open for unknown target; dropping', { target });
223
+ return; // relay's attach timeout tears down the waiting ingress
224
+ }
225
+ const ws = new WebSocket(`${this.controlBase}${DATA_PATH}`);
226
+ const local = netConnect({ host: entry.host, port: entry.port });
227
+ this.dataConns.add(ws);
228
+ this.localSockets.add(local);
229
+
230
+ let attached = false;
231
+ const localOutbox: Uint8Array[] = [];
232
+ const cleanup = (): void => {
233
+ this.dataConns.delete(ws);
234
+ this.localSockets.delete(local);
235
+ try {
236
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
237
+ } catch {
238
+ /* ignore */
239
+ }
240
+ try {
241
+ local.destroy();
242
+ } catch {
243
+ /* ignore */
244
+ }
245
+ };
246
+
247
+ ws.on('open', () => {
248
+ const attach: DataAttachMsg = { t: 'attach', v: PROXY_PROTOCOL_VERSION, connId };
249
+ ws.send(encodeJson(attach));
250
+ attached = true;
251
+ for (const chunk of localOutbox.splice(0)) ws.send(chunk, { binary: true });
252
+ });
253
+ ws.on('message', (data, isBinary) => {
254
+ if (isBinary) local.write(data as Buffer);
255
+ });
256
+ local.on('data', (buf: Buffer) => {
257
+ if (attached && ws.readyState === WebSocket.OPEN) ws.send(buf, { binary: true });
258
+ else localOutbox.push(buf);
259
+ });
260
+ ws.on('close', cleanup);
261
+ ws.on('error', cleanup);
262
+ local.on('close', cleanup);
263
+ local.on('error', cleanup);
264
+ }
265
+
266
+ close(): void {
267
+ if (this.closed) return;
268
+ this.closed = true;
269
+ if (this.reconnectTimer) {
270
+ clearTimeout(this.reconnectTimer);
271
+ this.reconnectTimer = null;
272
+ }
273
+ for (const ws of this.dataConns) {
274
+ try {
275
+ ws.close();
276
+ } catch {
277
+ /* ignore */
278
+ }
279
+ }
280
+ for (const s of this.localSockets) {
281
+ try {
282
+ s.destroy();
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ }
287
+ this.dataConns.clear();
288
+ this.localSockets.clear();
289
+ this.targets.clear();
290
+ try {
291
+ this.control?.close();
292
+ } catch {
293
+ /* ignore */
294
+ }
295
+ this.control = null;
296
+ this.onEmpty();
297
+ }
298
+ }
299
+
300
+ /** Process-wide hub registry, so all channels share one control conn per identity. */
301
+ const hubs = new Map<string, ProxyHub>();
302
+
303
+ function hubKey(controlBase: string, identityPath: string | undefined): string {
304
+ return `${controlBase}${identityPath ?? ''}`;
305
+ }
306
+
307
+ /** Build a `proxy` tunnel provider (factory form, for config/tests). */
308
+ export function createProxyTunnel(opts: ProxyTunnelOptions = {}): TunnelProviderDef {
309
+ return defineTunnelProvider({
310
+ name: 'proxy',
311
+ open: async ({ port, host, label }): Promise<TunnelHandle> => {
312
+ const baseHost = resolveBaseHost(opts);
313
+ const controlBase = resolveControlBase(opts, baseHost);
314
+ const key = hubKey(controlBase, opts.identityPath);
315
+ let hub = hubs.get(key);
316
+ if (!hub) {
317
+ hub = new ProxyHub(controlBase, baseHost, opts.identityPath, opts.logger, () =>
318
+ hubs.delete(key),
319
+ );
320
+ hubs.set(key, hub);
321
+ }
322
+ const handle = await hub.addTarget(label ?? '', host, port);
323
+ // Surface the pinned fingerprint once per process for the QR/debugging.
324
+ const identity = await loadOrCreateIdentity(opts.identityPath);
325
+ opts.logger?.info?.('proxy: exposed', {
326
+ url: handle.url,
327
+ fingerprint: fingerprint(identity.publicKey),
328
+ });
329
+ return handle;
330
+ },
331
+ });
332
+ }
333
+
334
+ /** The default `proxy` provider (reads `MOXXY_PROXY_HOST` at open time). */
335
+ export const proxyTunnel: TunnelProviderDef = createProxyTunnel();