@mycadet/gateway 0.12.7-dev.202610111049.d2b52a8 → 0.12.8
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/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +0 -1
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config.test.ts +0 -24
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/guardian-init-lockfile.test.ts +0 -1
- package/src/__tests__/handle-inbound-admission.test.ts +0 -1
- package/src/__tests__/handle-inbound-app-context.test.ts +0 -1
- package/src/__tests__/handle-inbound-invite-intercept.test.ts +0 -1
- package/src/__tests__/handle-inbound-trust-verdict.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +3 -0
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +3 -2
- package/src/__tests__/runtime-proxy-device-stamp.test.ts +0 -1
- package/src/__tests__/runtime-proxy-error-body.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/runtime-stream-test-utils.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +0 -1
- package/src/__tests__/slack-normalize.test.ts +0 -1
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-group-dm.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-liveness.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-outbound-root.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-webhook-drop-visibility.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +0 -1
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/attachments/ingest.test.ts +0 -1
- package/src/config.ts +0 -2
- package/src/http/edge-forwarded-header.ts +1 -1
- package/src/http/loopback-guard.ts +1 -1
- package/src/http/routes/channel-verification-session-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ingress-domains.test.ts +129 -0
- package/src/http/routes/ingress-domains.ts +73 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +0 -1
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/twilio-voice-webhook.test.ts +0 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +12 -4
- package/src/ipc/debug-export-handlers.test.ts +0 -1
- package/src/ipc/log-tail-handlers.test.ts +0 -1
- package/src/telegram/send.test.ts +0 -1
- package/src/telegram/webhook-manager.ts +1 -1
- package/src/tunnel/client.test.ts +0 -1
- package/src/tunnel/client.ts +1 -1
package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -100,11 +100,17 @@ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
|
|
|
100
100
|
export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* The
|
|
104
|
-
* `cadet tunnel
|
|
105
|
-
*
|
|
103
|
+
* The tunnel providers the CLI offers, in the order they are presented —
|
|
104
|
+
* the managed `cadet` tunnel first (listed, not yet implemented), then the
|
|
105
|
+
* local ones. `cadet tunnel`'s accepted `--provider` values and the
|
|
106
|
+
* persisted-record validation below both derive from it.
|
|
106
107
|
*/
|
|
107
|
-
export const TUNNEL_PROVIDERS = [
|
|
108
|
+
export const TUNNEL_PROVIDERS = [
|
|
109
|
+
"cadet",
|
|
110
|
+
"ngrok",
|
|
111
|
+
"cloudflare",
|
|
112
|
+
"tailscale",
|
|
113
|
+
] as const;
|
|
108
114
|
|
|
109
115
|
export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
|
|
110
116
|
|
package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -100,11 +100,17 @@ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
|
|
|
100
100
|
export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* The
|
|
104
|
-
* `cadet tunnel
|
|
105
|
-
*
|
|
103
|
+
* The tunnel providers the CLI offers, in the order they are presented —
|
|
104
|
+
* the managed `cadet` tunnel first (listed, not yet implemented), then the
|
|
105
|
+
* local ones. `cadet tunnel`'s accepted `--provider` values and the
|
|
106
|
+
* persisted-record validation below both derive from it.
|
|
106
107
|
*/
|
|
107
|
-
export const TUNNEL_PROVIDERS = [
|
|
108
|
+
export const TUNNEL_PROVIDERS = [
|
|
109
|
+
"cadet",
|
|
110
|
+
"ngrok",
|
|
111
|
+
"cloudflare",
|
|
112
|
+
"tailscale",
|
|
113
|
+
] as const;
|
|
108
114
|
|
|
109
115
|
export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
|
|
110
116
|
|
|
@@ -100,11 +100,17 @@ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
|
|
|
100
100
|
export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* The
|
|
104
|
-
* `cadet tunnel
|
|
105
|
-
*
|
|
103
|
+
* The tunnel providers the CLI offers, in the order they are presented —
|
|
104
|
+
* the managed `cadet` tunnel first (listed, not yet implemented), then the
|
|
105
|
+
* local ones. `cadet tunnel`'s accepted `--provider` values and the
|
|
106
|
+
* persisted-record validation below both derive from it.
|
|
106
107
|
*/
|
|
107
|
-
export const TUNNEL_PROVIDERS = [
|
|
108
|
+
export const TUNNEL_PROVIDERS = [
|
|
109
|
+
"cadet",
|
|
110
|
+
"ngrok",
|
|
111
|
+
"cloudflare",
|
|
112
|
+
"tailscale",
|
|
113
|
+
] as const;
|
|
108
114
|
|
|
109
115
|
export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
|
|
110
116
|
|
package/package.json
CHANGED
|
@@ -7,30 +7,6 @@ import { testWorkspaceDir } from "./test-preload.js";
|
|
|
7
7
|
import { loadConfig } from "../config.js";
|
|
8
8
|
|
|
9
9
|
describe("config: hardcoded defaults", () => {
|
|
10
|
-
test("returns expected hardcoded values", () => {
|
|
11
|
-
const config = loadConfig();
|
|
12
|
-
expect(config.shutdownDrainMs).toBe(5000);
|
|
13
|
-
expect(config.runtimeTimeoutMs).toBe(30000);
|
|
14
|
-
expect(config.runtimeMaxRetries).toBe(2);
|
|
15
|
-
expect(config.runtimeInitialBackoffMs).toBe(500);
|
|
16
|
-
expect(config.maxWebhookPayloadBytes).toBe(1024 * 1024);
|
|
17
|
-
expect(config.maxEmailWebhookPayloadBytes).toBe(350 * 1024 * 1024);
|
|
18
|
-
expect(config.maxAttachmentBytes).toEqual({
|
|
19
|
-
telegram: 20 * 1024 * 1024,
|
|
20
|
-
slack: 100 * 1024 * 1024,
|
|
21
|
-
whatsapp: 16 * 1024 * 1024,
|
|
22
|
-
discord: 100 * 1024 * 1024,
|
|
23
|
-
email: 25 * 1024 * 1024,
|
|
24
|
-
default: 100 * 1024 * 1024,
|
|
25
|
-
});
|
|
26
|
-
expect(config.maxAttachmentConcurrency).toBe(3);
|
|
27
|
-
expect(config.runtimeProxyRequireAuth).toBe(true);
|
|
28
|
-
expect(config.trustProxy).toBe(false);
|
|
29
|
-
expect(config.routingEntries).toEqual([]);
|
|
30
|
-
expect(config.logFile.dir).toMatch(/logs$/);
|
|
31
|
-
expect(config.logFile.retentionDays).toBe(30);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
10
|
test("GATEWAY_PORT defaults to 7830", () => {
|
|
35
11
|
const saved = process.env.GATEWAY_PORT;
|
|
36
12
|
delete process.env.GATEWAY_PORT;
|
|
@@ -425,7 +425,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
425
425
|
routingEntries: [],
|
|
426
426
|
port: 7830,
|
|
427
427
|
runtimeProxyRequireAuth: true,
|
|
428
|
-
shutdownDrainMs: 5000,
|
|
429
428
|
runtimeTimeoutMs: 30000,
|
|
430
429
|
runtimeMaxRetries: 2,
|
|
431
430
|
runtimeInitialBackoffMs: 500,
|
|
@@ -158,7 +158,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
158
158
|
routingEntries: [],
|
|
159
159
|
port: 7830,
|
|
160
160
|
runtimeProxyRequireAuth: true,
|
|
161
|
-
shutdownDrainMs: 5000,
|
|
162
161
|
runtimeTimeoutMs: 30000,
|
|
163
162
|
runtimeMaxRetries: 2,
|
|
164
163
|
runtimeInitialBackoffMs: 500,
|
|
@@ -227,6 +227,9 @@ const EXCLUDED_FROM_SCHEMA = new Set([
|
|
|
227
227
|
"/v1/speech/stt/stream",
|
|
228
228
|
"/v2/speech/stt/stream",
|
|
229
229
|
"/v1/speech/tts/stream",
|
|
230
|
+
// Loopback-only ingress domain recording — the local CLI reports its
|
|
231
|
+
// tunnel's public URL (bearer-authed); not part of the public gateway API
|
|
232
|
+
"/v1/ingress/domains",
|
|
230
233
|
// Plugin-declared webhooks — which paths exist is decided at runtime by the
|
|
231
234
|
// installed plugins and the guardian's approvals, so there is no fixed set
|
|
232
235
|
// to document. The declaration schema is the contract, not this path.
|
|
@@ -45,7 +45,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
45
45
|
routingEntries: [],
|
|
46
46
|
port: 7830,
|
|
47
47
|
runtimeProxyRequireAuth: true,
|
|
48
|
-
shutdownDrainMs: 5000,
|
|
49
48
|
runtimeTimeoutMs: 30000,
|
|
50
49
|
runtimeMaxRetries: 2,
|
|
51
50
|
runtimeInitialBackoffMs: 500,
|
|
@@ -412,7 +411,9 @@ describe("runtime proxy OAuth grant path rewrite", () => {
|
|
|
412
411
|
},
|
|
413
412
|
);
|
|
414
413
|
|
|
415
|
-
expect(url).toBe(
|
|
414
|
+
expect(url).toBe(
|
|
415
|
+
"http://localhost:7821/v1/oauth/proxy/stripe_link/v1/charges",
|
|
416
|
+
);
|
|
416
417
|
});
|
|
417
418
|
|
|
418
419
|
test("does not rewrite a host-absolute path without a proxy grant", async () => {
|
|
@@ -161,7 +161,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
161
161
|
routingEntries: [],
|
|
162
162
|
port: 7830,
|
|
163
163
|
runtimeProxyRequireAuth: true,
|
|
164
|
-
shutdownDrainMs: 5000,
|
|
165
164
|
runtimeTimeoutMs: 30000,
|
|
166
165
|
runtimeMaxRetries: 2,
|
|
167
166
|
runtimeInitialBackoffMs: 500,
|
|
@@ -135,7 +135,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
135
135
|
routingEntries: [],
|
|
136
136
|
port: 7830,
|
|
137
137
|
runtimeProxyRequireAuth: true,
|
|
138
|
-
shutdownDrainMs: 5000,
|
|
139
138
|
runtimeTimeoutMs: 30000,
|
|
140
139
|
runtimeMaxRetries: 2,
|
|
141
140
|
runtimeInitialBackoffMs: 500,
|
package/src/config.ts
CHANGED
|
@@ -36,7 +36,6 @@ export type GatewayConfig = {
|
|
|
36
36
|
runtimeMaxRetries: number;
|
|
37
37
|
runtimeProxyRequireAuth: boolean;
|
|
38
38
|
runtimeTimeoutMs: number;
|
|
39
|
-
shutdownDrainMs: number;
|
|
40
39
|
/** When true, trust X-Forwarded-For for client IP resolution (set when behind a reverse proxy). */
|
|
41
40
|
trustProxy: boolean;
|
|
42
41
|
};
|
|
@@ -226,7 +225,6 @@ export function loadConfig(): GatewayConfig {
|
|
|
226
225
|
runtimeMaxRetries: 2,
|
|
227
226
|
runtimeProxyRequireAuth,
|
|
228
227
|
runtimeTimeoutMs,
|
|
229
|
-
shutdownDrainMs: 5000,
|
|
230
228
|
trustProxy,
|
|
231
229
|
};
|
|
232
230
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Loopback-only endpoints (`/v1/guardian/init`, `/v1/pair`) treat its presence
|
|
14
14
|
* as proof that the request did NOT arrive directly from a local loopback
|
|
15
15
|
* client: it was forwarded by the edge proxy, possibly from a remote browser
|
|
16
|
-
* over a tunnel (e.g.
|
|
16
|
+
* over a tunnel (e.g. a third-party tunnel). Because every hop in that chain is loopback
|
|
17
17
|
* (browser → tunnel agent → nginx@127.0.0.1 → gateway@127.0.0.1), the raw TCP
|
|
18
18
|
* peer is 127.0.0.1 and a peer-IP loopback check alone would misclassify the
|
|
19
19
|
* caller as local. This marker is the reliable signal instead — and crucially
|
|
@@ -91,7 +91,7 @@ export function enforceLoopbackOnly(
|
|
|
91
91
|
return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// The self-hosted nginx edge (e.g. the SPA over
|
|
94
|
+
// The self-hosted nginx edge (e.g. the SPA over a tunnel) sets this
|
|
95
95
|
// marker unconditionally; it cannot be spoofed or stripped by the client.
|
|
96
96
|
if (requestArrivedViaEdgeProxy(req)) {
|
|
97
97
|
auditDeny(req, clientIp, auditTag, "edge_proxy_forwarded");
|
|
@@ -166,7 +166,7 @@ export function createChannelVerificationSessionProxyHandler(
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// Defense-in-depth: reject requests forwarded by the self-hosted nginx
|
|
169
|
-
// edge (e.g. the SPA reached over
|
|
169
|
+
// edge (e.g. the SPA reached over a tunnel). Every hop in that
|
|
170
170
|
// chain is loopback, so the raw peer IP would otherwise misclassify a
|
|
171
171
|
// remote caller as local. The edge sets this marker unconditionally and
|
|
172
172
|
// it cannot be spoofed or stripped by the client. Only the self-hosted
|
|
@@ -221,7 +221,7 @@ export function createChannelVerificationSessionProxyHandler(
|
|
|
221
221
|
|
|
222
222
|
// Bare-metal mode: restrict to loopback callers only. Without a
|
|
223
223
|
// bootstrap secret the lockfile is the sole guard, so a remote
|
|
224
|
-
// client that can reach the gateway (e.g. via
|
|
224
|
+
// client that can reach the gateway (e.g. via a tunnel) must not be
|
|
225
225
|
// able to race the legitimate local user.
|
|
226
226
|
if (!isCloud && expectedSecrets.length === 0) {
|
|
227
227
|
// A genuine local bootstrap caller connects directly over loopback and
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { ConfigFileCache } from "../../config-file-cache.js";
|
|
8
|
+
import { handleSaveIngressDomain } from "./ingress-domains.js";
|
|
9
|
+
|
|
10
|
+
// The handler's write path resolves the workspace config through
|
|
11
|
+
// getWorkspaceDir; the mock redirects it into a per-test temp dir.
|
|
12
|
+
let workspaceDir = "";
|
|
13
|
+
|
|
14
|
+
mock.module("../../credential-reader.js", () => ({
|
|
15
|
+
getWorkspaceDir: () => workspaceDir,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const SavedIngressSchema = z.looseObject({
|
|
19
|
+
ingress: z
|
|
20
|
+
.looseObject({
|
|
21
|
+
publicBaseUrl: z.string().optional(),
|
|
22
|
+
publicBaseUrlManagedBy: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
.optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
let testDir = "";
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
testDir = mkdtempSync(join(tmpdir(), "gateway-ingress-domains-test-"));
|
|
31
|
+
workspaceDir = testDir;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function request(body?: unknown): Request {
|
|
39
|
+
return new Request("http://127.0.0.1/v1/ingress/domains", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readSavedIngress(): z.infer<typeof SavedIngressSchema>["ingress"] {
|
|
47
|
+
const raw = SavedIngressSchema.parse(
|
|
48
|
+
JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8")),
|
|
49
|
+
);
|
|
50
|
+
return raw.ingress;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("POST /v1/ingress/domains", () => {
|
|
54
|
+
test("records the tunnel URL as tunnel-managed in the gateway config", async () => {
|
|
55
|
+
const res = await handleSaveIngressDomain(
|
|
56
|
+
request({ publicBaseUrl: "https://foo.ngrok.app" }),
|
|
57
|
+
"127.0.0.1",
|
|
58
|
+
new ConfigFileCache(),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
expect(readSavedIngress()).toMatchObject({
|
|
63
|
+
publicBaseUrl: "https://foo.ngrok.app",
|
|
64
|
+
publicBaseUrlManagedBy: "tunnel",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("normalizes the URL the same way every other public base URL is", async () => {
|
|
69
|
+
await handleSaveIngressDomain(
|
|
70
|
+
request({ publicBaseUrl: "https://foo.ngrok.app/" }),
|
|
71
|
+
"127.0.0.1",
|
|
72
|
+
new ConfigFileCache(),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(readSavedIngress()?.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("is a no-op write when the URL is already recorded as tunnel-managed", async () => {
|
|
79
|
+
const configFile = new ConfigFileCache();
|
|
80
|
+
await handleSaveIngressDomain(
|
|
81
|
+
request({ publicBaseUrl: "https://foo.ngrok.app" }),
|
|
82
|
+
"127.0.0.1",
|
|
83
|
+
configFile,
|
|
84
|
+
);
|
|
85
|
+
const afterFirst = readFileSync(join(workspaceDir, "config.json"), "utf-8");
|
|
86
|
+
|
|
87
|
+
await handleSaveIngressDomain(
|
|
88
|
+
request({ publicBaseUrl: "https://foo.ngrok.app" }),
|
|
89
|
+
"127.0.0.1",
|
|
90
|
+
configFile,
|
|
91
|
+
);
|
|
92
|
+
const afterSecond = readFileSync(
|
|
93
|
+
join(workspaceDir, "config.json"),
|
|
94
|
+
"utf-8",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(afterSecond).toBe(afterFirst);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("rejects non-loopback callers", async () => {
|
|
101
|
+
const res = await handleSaveIngressDomain(
|
|
102
|
+
request({ publicBaseUrl: "https://foo.ngrok.app" }),
|
|
103
|
+
"10.0.0.1",
|
|
104
|
+
new ConfigFileCache(),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(res.status).toBe(403);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("rejects a body without a usable public URL", async () => {
|
|
111
|
+
const res = await handleSaveIngressDomain(
|
|
112
|
+
request({}),
|
|
113
|
+
"127.0.0.1",
|
|
114
|
+
new ConfigFileCache(),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(400);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rejects non-POST methods", async () => {
|
|
121
|
+
const res = await handleSaveIngressDomain(
|
|
122
|
+
new Request("http://127.0.0.1/v1/ingress/domains", { method: "GET" }),
|
|
123
|
+
"127.0.0.1",
|
|
124
|
+
new ConfigFileCache(),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(res.status).toBe(405);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback-guarded recording of a tunnel's public URL.
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/ingress/domains — the CLI (its ngrok agent, today) tells the
|
|
5
|
+
* gateway the public URL its tunnel published; the gateway writes
|
|
6
|
+
* `ingress.publicBaseUrl` + `publicBaseUrlManagedBy: "tunnel"` in its own
|
|
7
|
+
* config via the same machinery its own tunnel uses, so both publishers
|
|
8
|
+
* record through one path and the gateway's existing loss handling clears
|
|
9
|
+
* it uniformly. Write-only: reads keep flowing through the config the
|
|
10
|
+
* gateway already serves. While we ship one bundle the gateway still hosts
|
|
11
|
+
* the ngrok-related logic; once the bundle splits, the gateway manages its
|
|
12
|
+
* own tunnel (mycadet.ai vs <assistant>.mycadet.bot) and this route is what
|
|
13
|
+
* the CLI-side publishers migrate onto.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
import { normalizeHttpPublicBaseUrlWithoutTrailingSlash } from "@mycadet/service-contracts/ingress";
|
|
19
|
+
|
|
20
|
+
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
21
|
+
import { writeManagedPublicBaseUrl } from "../../tunnel/client.js";
|
|
22
|
+
import {
|
|
23
|
+
enforceLoopbackOnly,
|
|
24
|
+
errorResponse,
|
|
25
|
+
rejectBrowserOrigin,
|
|
26
|
+
} from "../loopback-guard.js";
|
|
27
|
+
|
|
28
|
+
const SaveIngressDomainRequestSchema = z.object({
|
|
29
|
+
publicBaseUrl: z.string(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export async function handleSaveIngressDomain(
|
|
33
|
+
req: Request,
|
|
34
|
+
clientIp: string,
|
|
35
|
+
configFile: ConfigFileCache,
|
|
36
|
+
): Promise<Response> {
|
|
37
|
+
if (req.method !== "POST") {
|
|
38
|
+
return new Response("method not allowed", {
|
|
39
|
+
status: 405,
|
|
40
|
+
headers: { Allow: "POST" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const guardError = enforceLoopbackOnly(req, clientIp, "ingress-domains");
|
|
45
|
+
if (guardError) return guardError;
|
|
46
|
+
const originError = rejectBrowserOrigin(req, clientIp, "ingress-domains");
|
|
47
|
+
if (originError) return originError;
|
|
48
|
+
|
|
49
|
+
let body: unknown;
|
|
50
|
+
try {
|
|
51
|
+
body = await req.json();
|
|
52
|
+
} catch {
|
|
53
|
+
body = {};
|
|
54
|
+
}
|
|
55
|
+
const parsed = SaveIngressDomainRequestSchema.safeParse(body);
|
|
56
|
+
if (!parsed.success) {
|
|
57
|
+
return errorResponse("BAD_REQUEST", "publicBaseUrl is required", 400);
|
|
58
|
+
}
|
|
59
|
+
const publicUrl = normalizeHttpPublicBaseUrlWithoutTrailingSlash(
|
|
60
|
+
parsed.data.publicBaseUrl,
|
|
61
|
+
);
|
|
62
|
+
if (!publicUrl) {
|
|
63
|
+
return errorResponse(
|
|
64
|
+
"BAD_REQUEST",
|
|
65
|
+
"publicBaseUrl must be an absolute http(s) URL",
|
|
66
|
+
400,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// No-op (no write) when the URL is already recorded as tunnel-managed.
|
|
71
|
+
await writeManagedPublicBaseUrl(publicUrl, configFile);
|
|
72
|
+
return Response.json({ ok: true });
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -104,6 +104,7 @@ import {
|
|
|
104
104
|
handleListDevices,
|
|
105
105
|
handleRevokeDevice,
|
|
106
106
|
} from "./http/routes/devices.js";
|
|
107
|
+
import { handleSaveIngressDomain } from "./http/routes/ingress-domains.js";
|
|
107
108
|
import { handlePair } from "./http/routes/pair.js";
|
|
108
109
|
import { handleCredentialEntryPage } from "./http/routes/credential-entry-page.js";
|
|
109
110
|
import {
|
|
@@ -888,6 +889,15 @@ async function main() {
|
|
|
888
889
|
handler: () => psHandler.handlePs(),
|
|
889
890
|
},
|
|
890
891
|
|
|
892
|
+
// ── Ingress domain recording (localhost CLI only, bearer-authed) ──
|
|
893
|
+
{
|
|
894
|
+
path: "/v1/ingress/domains",
|
|
895
|
+
method: "POST",
|
|
896
|
+
auth: "edge",
|
|
897
|
+
handler: (req, _params, getClientIp) =>
|
|
898
|
+
handleSaveIngressDomain(req, getClientIp(), configFileCache),
|
|
899
|
+
},
|
|
900
|
+
|
|
891
901
|
// ── Tunnel status ──
|
|
892
902
|
{
|
|
893
903
|
path: "/v1/tunnel/status",
|
|
@@ -3171,7 +3181,7 @@ async function main() {
|
|
|
3171
3181
|
// Force-reconnect Slack WebSocket (may be half-open after sleep)
|
|
3172
3182
|
slackSocketClient?.forceReconnect();
|
|
3173
3183
|
|
|
3174
|
-
// Invalidate caches so next read picks up any config changes (e.g. new
|
|
3184
|
+
// Invalidate caches so next read picks up any config changes (e.g. a new tunnel URL)
|
|
3175
3185
|
configFileCache.invalidate();
|
|
3176
3186
|
credentialCache.invalidate();
|
|
3177
3187
|
|
|
@@ -3190,8 +3200,6 @@ async function main() {
|
|
|
3190
3200
|
});
|
|
3191
3201
|
sleepWakeDetector.start();
|
|
3192
3202
|
|
|
3193
|
-
const drainMs = config.shutdownDrainMs;
|
|
3194
|
-
|
|
3195
3203
|
process.on("SIGTERM", () => {
|
|
3196
3204
|
log.info("SIGTERM received, starting graceful shutdown");
|
|
3197
3205
|
draining = true;
|
|
@@ -3225,7 +3233,7 @@ async function main() {
|
|
|
3225
3233
|
server.stop(true);
|
|
3226
3234
|
process.exit(0);
|
|
3227
3235
|
});
|
|
3228
|
-
},
|
|
3236
|
+
}, 5_000);
|
|
3229
3237
|
});
|
|
3230
3238
|
}
|
|
3231
3239
|
|
|
@@ -395,7 +395,7 @@ async function reconcileTelegramWebhookNow(
|
|
|
395
395
|
expectedUrl = await resolveExpectedTelegramWebhookUrl(caches);
|
|
396
396
|
} catch (err) {
|
|
397
397
|
// Managed callback route registration failed — this is a cloud-side
|
|
398
|
-
// issue. Do not suggest
|
|
398
|
+
// issue. Do not suggest third-party tunnel options; they are not
|
|
399
399
|
// usable in containerized deployments.
|
|
400
400
|
const detail = err instanceof Error ? err.message : String(err);
|
|
401
401
|
log.error(
|
package/src/tunnel/client.ts
CHANGED
|
@@ -845,7 +845,7 @@ async function mutateGatewayConfigFile(
|
|
|
845
845
|
|
|
846
846
|
const TUNNEL_MANAGED_BY = "tunnel";
|
|
847
847
|
|
|
848
|
-
async function writeManagedPublicBaseUrl(
|
|
848
|
+
export async function writeManagedPublicBaseUrl(
|
|
849
849
|
publicUrl: string,
|
|
850
850
|
configFile: ConfigFileCache,
|
|
851
851
|
): Promise<void> {
|