@usine/gateway 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/dist/admin/api.d.ts +206 -0
  4. package/dist/admin/api.d.ts.map +1 -0
  5. package/dist/admin/api.js +105 -0
  6. package/dist/admin/api.js.map +1 -0
  7. package/dist/admin/server.d.ts +10 -0
  8. package/dist/admin/server.d.ts.map +1 -0
  9. package/dist/admin/server.js +79 -0
  10. package/dist/admin/server.js.map +1 -0
  11. package/dist/attachment/attachment.d.ts +77 -0
  12. package/dist/attachment/attachment.d.ts.map +1 -0
  13. package/dist/attachment/attachment.js +57 -0
  14. package/dist/attachment/attachment.js.map +1 -0
  15. package/dist/attachment/store.d.ts +27 -0
  16. package/dist/attachment/store.d.ts.map +1 -0
  17. package/dist/attachment/store.js +101 -0
  18. package/dist/attachment/store.js.map +1 -0
  19. package/dist/caller/caller.d.ts +60 -0
  20. package/dist/caller/caller.d.ts.map +1 -0
  21. package/dist/caller/caller.js +130 -0
  22. package/dist/caller/caller.js.map +1 -0
  23. package/dist/egress/client-hello.d.ts +120 -0
  24. package/dist/egress/client-hello.d.ts.map +1 -0
  25. package/dist/egress/client-hello.js +155 -0
  26. package/dist/egress/client-hello.js.map +1 -0
  27. package/dist/egress/egress.d.ts +66 -0
  28. package/dist/egress/egress.d.ts.map +1 -0
  29. package/dist/egress/egress.js +114 -0
  30. package/dist/egress/egress.js.map +1 -0
  31. package/dist/gateway.d.ts +23 -0
  32. package/dist/gateway.d.ts.map +1 -0
  33. package/dist/gateway.js +23 -0
  34. package/dist/gateway.js.map +1 -0
  35. package/dist/github/github.d.ts +43 -0
  36. package/dist/github/github.d.ts.map +1 -0
  37. package/dist/github/github.js +88 -0
  38. package/dist/github/github.js.map +1 -0
  39. package/dist/llm/llm.d.ts +64 -0
  40. package/dist/llm/llm.d.ts.map +1 -0
  41. package/dist/llm/llm.js +92 -0
  42. package/dist/llm/llm.js.map +1 -0
  43. package/dist/node/egress-proxy.d.ts +30 -0
  44. package/dist/node/egress-proxy.d.ts.map +1 -0
  45. package/dist/node/egress-proxy.js +491 -0
  46. package/dist/node/egress-proxy.js.map +1 -0
  47. package/dist/node/environment.d.ts +108 -0
  48. package/dist/node/environment.d.ts.map +1 -0
  49. package/dist/node/environment.js +113 -0
  50. package/dist/node/environment.js.map +1 -0
  51. package/dist/node/main.d.ts +3 -0
  52. package/dist/node/main.d.ts.map +1 -0
  53. package/dist/node/main.js +13 -0
  54. package/dist/node/main.js.map +1 -0
  55. package/dist/node/node.d.ts +29 -0
  56. package/dist/node/node.d.ts.map +1 -0
  57. package/dist/node/node.js +82 -0
  58. package/dist/node/node.js.map +1 -0
  59. package/dist/node/reload.d.ts +26 -0
  60. package/dist/node/reload.d.ts.map +1 -0
  61. package/dist/node/reload.js +142 -0
  62. package/dist/node/reload.js.map +1 -0
  63. package/dist/proxy/forward.d.ts +12 -0
  64. package/dist/proxy/forward.d.ts.map +1 -0
  65. package/dist/proxy/forward.js +44 -0
  66. package/dist/proxy/forward.js.map +1 -0
  67. package/dist/proxy/proxy.d.ts +28 -0
  68. package/dist/proxy/proxy.d.ts.map +1 -0
  69. package/dist/proxy/proxy.js +189 -0
  70. package/dist/proxy/proxy.js.map +1 -0
  71. package/dist/settings.d.ts +52 -0
  72. package/dist/settings.d.ts.map +1 -0
  73. package/dist/settings.js +27 -0
  74. package/dist/settings.js.map +1 -0
  75. package/dist/upstream.d.ts +5 -0
  76. package/dist/upstream.d.ts.map +1 -0
  77. package/dist/upstream.js +6 -0
  78. package/dist/upstream.js.map +1 -0
  79. package/package.json +58 -3
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llm.d.ts","sourceRoot":"","sources":["../../src/llm/llm.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,EAAE,MAAM,EAAY,MAAM,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;;;;AAK/C,0DAA0D;AAC1D,qBAAa,eAAgB,SAAQ,oBAEnC;CAAG;;;;AAEL;;;;GAIG;AACH,qBAAa,0BAA2B,SAAQ,+BAG/C;CAAG;AAEJ,kDAAkD;AAClD,eAAO,MAAM,mBAAmB,oFAA8D,CAAC;AAC/F,MAAM,MAAM,mBAAmB,GAAG,OAAO,mBAAmB,CAAC,IAAI,CAAC;;;;;AAElE,sGAAsG;AACtG,qBAAa,iBAAkB,SAAQ,sBAGtC;CAAG;;;;;AAEJ;;;GAGG;AACH,qBAAa,cAAe,SAAQ,mBAGlC;CAAG;AAEL,2GAA2G;AAC3G,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC;IACrD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;CAChD;AAID,eAAO,MAAM,eAAe,mDAAsC,CAAC;AACnE,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC,IAAI,CAAC;AAI1D,+DAA+D;AAC/D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,6HAA6H;AAC7H,eAAO,MAAM,SAAS,aAAc,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAKnE,CAAC;AAEF,oFAAoF;AACpF,eAAO,MAAM,cAAc,aACf,iBAAiB,GAAG,cAAc,KAC3C,qBAYF,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,cAClB,YAAY,QACjB,eAAe,KACpB,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,cAAc,CACY,CAAC;AAgBhE;;;GAGG;AACH,eAAO,MAAM,sBAAsB,aACvB,iBAAiB,GAAG,cAAc,WACnC,OAAO,CAAC,OAAO,KACvB,OAAO,CAAC,OAiBV,CAAC"}
@@ -0,0 +1,92 @@
1
+ import { Option, Redacted, Schema } from "effect";
2
+ import { Headers } from "effect/unstable/http";
3
+ import { UpstreamBase } from "../upstream.js";
4
+ const Secret = Schema.Redacted(Schema.NonEmptyString);
5
+ /** An Anthropic API key, sent upstream as `x-api-key`. */
6
+ export class AnthropicApiKey extends Schema.TaggedClass()("AnthropicApiKey", {
7
+ key: Secret,
8
+ }) {
9
+ }
10
+ /**
11
+ * A Claude subscription token from `claude setup-token`, sent upstream as a bearer token with
12
+ * the OAuth beta flag. Anthropic doesn't document swapping a placeholder for one through a
13
+ * proxy; `test/placeholder-swap` checks that it works.
14
+ */
15
+ export class AnthropicSubscriptionToken extends Schema.TaggedClass()("AnthropicSubscriptionToken", { token: Secret }) {
16
+ }
17
+ /** The credential added to Anthropic requests. */
18
+ export const AnthropicCredential = Schema.Union([AnthropicApiKey, AnthropicSubscriptionToken]);
19
+ /** Claude Code's upstream. On the Worker, `ANTHROPIC_BASE_URL` is `http://llm.<domain>/anthropic`. */
20
+ export class AnthropicProvider extends Schema.TaggedClass()("AnthropicProvider", { upstream: UpstreamBase, credential: AnthropicCredential }) {
21
+ }
22
+ /**
23
+ * An OpenAI-compatible upstream for Codex. On the Worker, the `model_provider` `base_url` is
24
+ * `http://llm.<domain>/openai/v1`. The key is sent as a bearer token.
25
+ */
26
+ export class OpenAiProvider extends Schema.TaggedClass()("OpenAiProvider", {
27
+ upstream: UpstreamBase,
28
+ key: Secret,
29
+ }) {
30
+ }
31
+ /** The `llm.<domain>` path prefixes that select a provider. */
32
+ const LLM_PROVIDER_NAMES = ["anthropic", "openai"];
33
+ export const LlmProviderName = Schema.Literals(LLM_PROVIDER_NAMES);
34
+ const isProviderName = Schema.is(LlmProviderName);
35
+ /** Returns the provider from the first path segment; for example, `/anthropic/v1/messages` is Anthropic's `/v1/messages`. */
36
+ export const llmTarget = (pathname) => {
37
+ const match = /^\/([a-z]+)(\/.*)?$/u.exec(pathname);
38
+ const provider = match?.[1];
39
+ if (match === null || provider === undefined || !isProviderName(provider))
40
+ return Option.none();
41
+ return Option.some({ provider, rest: match[2] ?? "" });
42
+ };
43
+ /** Returns the credential kind sent to `provider`; always an API key for OpenAI. */
44
+ export const credentialKind = (provider) => {
45
+ switch (provider._tag) {
46
+ case "OpenAiProvider":
47
+ return "api-key";
48
+ case "AnthropicProvider":
49
+ switch (provider.credential._tag) {
50
+ case "AnthropicApiKey":
51
+ return "api-key";
52
+ case "AnthropicSubscriptionToken":
53
+ return "subscription";
54
+ }
55
+ }
56
+ };
57
+ /** Returns the configured provider for `name` as a single forwardable value. */
58
+ export const configuredProvider = (providers, name) => name === "anthropic" ? providers.anthropic : providers.openai;
59
+ const OAUTH_BETA = "oauth-2025-04-20";
60
+ const withBeta = (incoming, flag) => {
61
+ const flags = Option.match(incoming, {
62
+ onNone: () => [],
63
+ onSome: (value) => value
64
+ .split(",")
65
+ .map((part) => part.trim())
66
+ .filter((part) => part !== ""),
67
+ });
68
+ return (flags.includes(flag) ? flags : [...flags, flag]).join(",");
69
+ };
70
+ /**
71
+ * Adds the provider's credential to headers without the caller's credential. For a
72
+ * subscription token, also merges the OAuth beta flag into the harness's flags.
73
+ */
74
+ export const withProviderCredential = (provider, headers) => {
75
+ switch (provider._tag) {
76
+ case "OpenAiProvider":
77
+ return Headers.set(headers, "authorization", `Bearer ${Redacted.value(provider.key)}`);
78
+ case "AnthropicProvider": {
79
+ const credential = provider.credential;
80
+ switch (credential._tag) {
81
+ case "AnthropicApiKey":
82
+ return Headers.set(headers, "x-api-key", Redacted.value(credential.key));
83
+ case "AnthropicSubscriptionToken":
84
+ return Headers.setAll(headers, {
85
+ authorization: `Bearer ${Redacted.value(credential.token)}`,
86
+ "anthropic-beta": withBeta(Headers.get(headers, "anthropic-beta"), OAUTH_BETA),
87
+ });
88
+ }
89
+ }
90
+ }
91
+ };
92
+ //# sourceMappingURL=llm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llm.js","sourceRoot":"","sources":["../../src/llm/llm.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { CallerTokens } from "../caller/caller.ts";
3
+ import { GatewaySettings, SettingsInForce } from "../settings.ts";
4
+ import type { Listener } from "./environment.ts";
5
+ declare const EgressProxyListenError_base: Schema.Class<EgressProxyListenError, Schema.TaggedStruct<"EgressProxyListenError", {
6
+ readonly host: Schema.String;
7
+ readonly port: Schema.Int;
8
+ readonly message: Schema.String;
9
+ }>, import("effect/Cause").YieldableError>;
10
+ /** The error when the forward proxy can't listen on its address. */
11
+ export declare class EgressProxyListenError extends EgressProxyListenError_base {
12
+ }
13
+ /**
14
+ * Serves the forward proxy that Workers use for package registries and other allowlisted
15
+ * hosts: `CONNECT host:443` for HTTPS, and absolute-form requests (`GET http://host/...`) for
16
+ * plain HTTP mirrors such as apt.
17
+ *
18
+ * - A tunnel opens only after the ClientHello's server name matches the CONNECT host.
19
+ * - The proxy resolves names itself and refuses one that resolves to a private address.
20
+ * - Requests for `git.<domain>` and `llm.<domain>` on port 80 go to the reverse proxy at
21
+ * `gateway`, so a client that proxies everything (Claude Code with `HTTPS_PROXY`) still
22
+ * connects to them.
23
+ * - A caller token in `Proxy-Authorization` is optional and only identifies the Worker in logs.
24
+ * - Each refusal is logged with the host, reason, and client address.
25
+ * - Each request is checked against the allowlist current at arrival, so an open tunnel stays
26
+ * open after a reload removes its host.
27
+ */
28
+ export declare const serveEgressProxy: (listener: Listener, gateway: Listener) => Effect.Effect<Listener, EgressProxyListenError, CallerTokens | GatewaySettings | import("effect/Scope").Scope | SettingsInForce>;
29
+ export {};
30
+ //# sourceMappingURL=egress-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"egress-proxy.d.ts","sourceRoot":"","sources":["../../src/node/egress-proxy.ts"],"names":[],"mappings":"AAUA,OAAO,EAAQ,MAAM,EAAmC,MAAM,EAAE,MAAM,QAAQ,CAAC;AAE/E,OAAO,EAA0B,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAK3E,OAAO,EAAE,eAAe,EAAgB,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;;;;;;AAqCjD,oEAAoE;AACpE,qBAAa,sBAAuB,SAAQ,2BAG3C;CAAG;AA4KJ;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,6KA0a3B,CAAC"}
@@ -0,0 +1,491 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { createServer, request as httpRequest, } from "node:http";
3
+ import { BlockList, connect } from "node:net";
4
+ import { Data, Effect, Fiber, Match, Option, Predicate, Schema } from "effect";
5
+ import { Headers } from "effect/unstable/http";
6
+ import { tokenFromAuthorization, CallerTokens } from "../caller/caller.js";
7
+ import { ClientHelloReader, comparableHost } from "../egress/client-hello.js";
8
+ import { EgressRefused } from "../egress/egress.js";
9
+ import { endToEndHeaders } from "../proxy/forward.js";
10
+ import { REFUSAL_HEADER } from "../proxy/proxy.js";
11
+ import { GatewaySettings, gatewayHosts, SettingsInForce } from "../settings.js";
12
+ /**
13
+ * Returns the ranges a public name must never resolve to: private, loopback, link-local,
14
+ * shared, multicast, and reserved. Node checks IPv4-mapped IPv6 addresses against the IPv4
15
+ * ranges, so there's no `::ffff:0:0/96` rule, which would match every IPv4 address.
16
+ */
17
+ const privateAddresses = () => {
18
+ const list = new BlockList();
19
+ for (const [network, prefix] of [
20
+ ["0.0.0.0", 8],
21
+ ["10.0.0.0", 8],
22
+ ["100.64.0.0", 10],
23
+ ["127.0.0.0", 8],
24
+ ["169.254.0.0", 16],
25
+ ["172.16.0.0", 12],
26
+ ["192.0.0.0", 24],
27
+ ["192.168.0.0", 16],
28
+ ["198.18.0.0", 15],
29
+ ["224.0.0.0", 3],
30
+ ])
31
+ list.addSubnet(network, prefix, "ipv4");
32
+ for (const [network, prefix] of [
33
+ ["::", 127],
34
+ ["fc00::", 7],
35
+ ["fe80::", 10],
36
+ ["ff00::", 8],
37
+ ])
38
+ list.addSubnet(network, prefix, "ipv6");
39
+ return list;
40
+ };
41
+ /** The error when the DNS lookup of an allowed host fails or returns nothing. */
42
+ class Unresolvable extends Schema.TaggedError()("Unresolvable", {
43
+ host: Schema.String,
44
+ }) {
45
+ }
46
+ /** The error when the forward proxy can't listen on its address. */
47
+ export class EgressProxyListenError extends Schema.TaggedError()("EgressProxyListenError", { host: Schema.String, port: Schema.Int, message: Schema.String }) {
48
+ }
49
+ /** The error when the upstream connection can't be opened or its response doesn't finish. */
50
+ class Unreachable extends Schema.TaggedError()("Unreachable", {
51
+ host: Schema.String,
52
+ port: Schema.Int,
53
+ message: Schema.String,
54
+ }) {
55
+ }
56
+ /**
57
+ * The error for a tunnel refused after `200 Connection Established`: the first bytes weren't a
58
+ * TLS ClientHello; the ClientHello had no server name, hid it with ECH, or had a different one
59
+ * than the CONNECT host; or the host resolved to a private address. `serverName` is the
60
+ * ClientHello's server name, if any.
61
+ */
62
+ class TunnelRefused extends Data.TaggedError("TunnelRefused") {
63
+ }
64
+ /** The error when the client closes a tunnel before sending any bytes. */
65
+ class TunnelAbandoned extends Data.TaggedError("TunnelAbandoned") {
66
+ }
67
+ /** A fatal TLS `unrecognized_name` alert (RFC 8446, section 6), sent so the client can report why the handshake ended. */
68
+ const UNRECOGNIZED_NAME_ALERT = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70]);
69
+ const CLIENT_HELLO_TIMEOUT = "5 seconds";
70
+ const notTls = (detail) => new TunnelRefused({ reason: "not-tls", detail, serverName: Option.none() });
71
+ /**
72
+ * Buffers a tunnel's first bytes, including `head`, until they contain a complete ClientHello
73
+ * (at most 16 KiB and five seconds), then pauses the client so nothing more is read before the
74
+ * upstream connection exists. Fails with `TunnelAbandoned` if the client closes before sending
75
+ * any bytes.
76
+ */
77
+ const clientHello = (client, head) => Effect.callback((resume) => {
78
+ const reader = new ClientHelloReader();
79
+ const stop = () => {
80
+ client.off("data", onData);
81
+ client.off("end", onEnd);
82
+ client.off("close", onEnd);
83
+ };
84
+ function onData(chunk) {
85
+ const read = reader.push(chunk);
86
+ if (read._tag === "Incomplete")
87
+ return;
88
+ stop();
89
+ client.pause();
90
+ if (read._tag === "NotTls")
91
+ return resume(Effect.fail(notTls(read.detail)));
92
+ resume(Effect.succeed({
93
+ buffered: Buffer.from(reader.bytes),
94
+ serverName: read.serverName,
95
+ encryptedClientHello: read.encryptedClientHello,
96
+ }));
97
+ }
98
+ function onEnd() {
99
+ stop();
100
+ resume(Effect.fail(reader.bytes.length === 0
101
+ ? new TunnelAbandoned()
102
+ : notTls("the client closed the tunnel before its ClientHello was complete")));
103
+ }
104
+ client.on("data", onData);
105
+ client.once("end", onEnd);
106
+ client.once("close", onEnd);
107
+ if (head.length > 0)
108
+ onData(head);
109
+ return Effect.sync(stop);
110
+ }).pipe(Effect.timeoutOrElse({
111
+ duration: CLIENT_HELLO_TIMEOUT,
112
+ orElse: () => Effect.fail(notTls("no ClientHello arrived within five seconds")),
113
+ }));
114
+ /**
115
+ * Checks that the ClientHello's server name is the CONNECT host, so a tunnel to a shared CDN
116
+ * address can't connect to another tenant of that address.
117
+ */
118
+ const pinnedServerName = (host, hello) => {
119
+ if (hello.encryptedClientHello)
120
+ return Effect.fail(new TunnelRefused({
121
+ reason: "server-name-missing",
122
+ detail: "the ClientHello encrypts its server name (ECH), so it cannot be checked",
123
+ serverName: hello.serverName,
124
+ }));
125
+ if (Option.isNone(hello.serverName))
126
+ return Effect.fail(new TunnelRefused({
127
+ reason: "server-name-missing",
128
+ detail: "the ClientHello names no server",
129
+ serverName: Option.none(),
130
+ }));
131
+ const serverName = hello.serverName.value;
132
+ return comparableHost(serverName) === comparableHost(host)
133
+ ? Effect.succeed({ buffered: hello.buffered, serverName })
134
+ : Effect.fail(new TunnelRefused({
135
+ reason: "server-name-mismatch",
136
+ detail: `the ClientHello asks for ${serverName}, not the CONNECT host`,
137
+ serverName: hello.serverName,
138
+ }));
139
+ };
140
+ const RelayEnd = Data.taggedEnum();
141
+ const AUTHORITY = /^(?:\[([0-9a-fA-F:.]+)\]|([^:[\]]+)):(\d{1,5})$/u;
142
+ const parseAuthority = (authority) => {
143
+ const match = AUTHORITY.exec(authority);
144
+ const host = match?.[1] ?? match?.[2];
145
+ if (match === null || host === undefined)
146
+ return Option.none();
147
+ return Option.some({ host, port: Number(match[3]) });
148
+ };
149
+ const statusLine = (status, reason, body, refusal) => [
150
+ `HTTP/1.1 ${status} ${reason}`,
151
+ "Content-Type: text/plain; charset=utf-8",
152
+ `Content-Length: ${Buffer.byteLength(body)}`,
153
+ ...Option.match(refusal, {
154
+ onNone: () => [],
155
+ onSome: (value) => [`${REFUSAL_HEADER}: ${value}`],
156
+ }),
157
+ "Connection: close",
158
+ "",
159
+ body,
160
+ ].join("\r\n");
161
+ /**
162
+ * Serves the forward proxy that Workers use for package registries and other allowlisted
163
+ * hosts: `CONNECT host:443` for HTTPS, and absolute-form requests (`GET http://host/...`) for
164
+ * plain HTTP mirrors such as apt.
165
+ *
166
+ * - A tunnel opens only after the ClientHello's server name matches the CONNECT host.
167
+ * - The proxy resolves names itself and refuses one that resolves to a private address.
168
+ * - Requests for `git.<domain>` and `llm.<domain>` on port 80 go to the reverse proxy at
169
+ * `gateway`, so a client that proxies everything (Claude Code with `HTTPS_PROXY`) still
170
+ * connects to them.
171
+ * - A caller token in `Proxy-Authorization` is optional and only identifies the Worker in logs.
172
+ * - Each refusal is logged with the host, reason, and client address.
173
+ * - Each request is checked against the allowlist current at arrival, so an open tunnel stays
174
+ * open after a reload removes its host.
175
+ */
176
+ export const serveEgressProxy = Effect.fn("EgressProxy.serve")(function* (listener, gateway) {
177
+ const settings = yield* GatewaySettings;
178
+ const ownHosts = new Set(Object.values(gatewayHosts(settings.domain)));
179
+ const inForce = yield* SettingsInForce;
180
+ const callers = yield* CallerTokens;
181
+ const blocked = privateAddresses();
182
+ const scope = yield* Effect.scope;
183
+ const context = yield* Effect.context();
184
+ /** Returns the log fields for a request's caller: the Worker from a valid caller token, if any. */
185
+ const caller = (value) => Option.fromNullishOr(value).pipe(Option.flatMap(tokenFromAuthorization), Option.match({
186
+ onNone: () => Effect.succeed({ caller: "none" }),
187
+ onSome: (token) => callers.verify(token).pipe(Effect.map(({ worker }) => ({ worker })), Effect.orElseSucceed(() => ({ caller: "invalid-token" }))),
188
+ }));
189
+ const logRefusal = (refusal, client, who) => Effect.logWarning("Gateway refused a request", {
190
+ reason: refusal.reason,
191
+ host: refusal.host,
192
+ port: refusal.port,
193
+ client,
194
+ ...who,
195
+ });
196
+ /** Resolves an allowed host and returns its first address. Fails if any address is private. */
197
+ const resolve = Effect.fn("EgressProxy.resolve")(function* (permitted) {
198
+ const addresses = yield* Effect.tryPromise({
199
+ try: () => lookup(permitted.host, { all: true, verbatim: true }),
200
+ catch: () => new Unresolvable({ host: permitted.host }),
201
+ });
202
+ const first = addresses[0];
203
+ if (first === undefined)
204
+ return yield* new Unresolvable({ host: permitted.host });
205
+ if (addresses.some(({ address, family }) => blocked.check(address, family === 6 ? "ipv6" : "ipv4")))
206
+ return yield* Effect.fail(new EgressRefused({
207
+ host: permitted.host,
208
+ port: permitted.port,
209
+ reason: "private-address",
210
+ }));
211
+ return first.address;
212
+ });
213
+ const decide = (host, port) => Effect.flatMap(inForce.current, ({ egress }) => {
214
+ const decision = egress.decide(host, port);
215
+ return decision._tag === "EgressRefused" ? Effect.fail(decision) : Effect.succeed(decision);
216
+ });
217
+ /** Returns the target for `host` on `port`: the reverse proxy for the Gateway's own names, otherwise the resolved allowed host. */
218
+ const destination = (host, port) => ownHosts.has(host.toLowerCase()) && port === 80
219
+ ? Effect.succeed({ address: gateway.host, port: gateway.port, kind: "gateway" })
220
+ : decide(host, port).pipe(Effect.flatMap(resolve), Effect.map((address) => ({ address, port, kind: "egress" })));
221
+ const openTunnel = (address, port, host) => Effect.callback((resume) => {
222
+ const socket = connect({ host: address, port });
223
+ const onError = (error) => resume(Effect.fail(new Unreachable({ host, port, message: error.message })));
224
+ socket.once("error", onError);
225
+ socket.once("connect", () => {
226
+ socket.off("error", onError);
227
+ resume(Effect.succeed(socket));
228
+ });
229
+ return Effect.sync(() => socket.destroy());
230
+ });
231
+ /** Copies bytes both ways until either side closes, then closes both. */
232
+ const splice = (client, upstream) => Effect.callback((resume) => {
233
+ const close = () => {
234
+ client.destroy();
235
+ upstream.destroy();
236
+ resume(Effect.void);
237
+ };
238
+ client.on("error", close).on("close", close);
239
+ upstream.on("error", close).on("close", close);
240
+ client.pipe(upstream);
241
+ upstream.pipe(client);
242
+ return Effect.sync(close);
243
+ });
244
+ /** Closes a tunnel refused after its `200 Connection Established`. */
245
+ const closeRefused = (client, refusal) => {
246
+ if (refusal.reason === "not-tls")
247
+ client.destroy();
248
+ else
249
+ client.end(UNRECOGNIZED_NAME_ALERT);
250
+ };
251
+ const tunnel = Effect.fn("EgressProxy.tunnel")(function* (request, client, head) {
252
+ const from = request.socket.remoteAddress ?? "unknown";
253
+ const who = yield* caller(request.headers["proxy-authorization"]);
254
+ const authority = parseAuthority(request.url ?? "");
255
+ if (Option.isNone(authority)) {
256
+ client.end(statusLine(400, "Bad Request", "Expected CONNECT host:port\n", Option.none()));
257
+ return yield* Effect.logWarning("Gateway refused a request", {
258
+ reason: "malformed-connect",
259
+ client: from,
260
+ ...who,
261
+ });
262
+ }
263
+ const { host, port } = authority.value;
264
+ yield* Effect.annotateCurrentSpan({ host, port });
265
+ if (ownHosts.has(comparableHost(host)) && port === 80) {
266
+ const opened = yield* openTunnel(gateway.host, gateway.port, host).pipe(Effect.result);
267
+ if (opened._tag === "Failure") {
268
+ client.end(statusLine(502, "Bad Gateway", "usine gateway: the reverse proxy is unreachable.\n", Option.none()));
269
+ return yield* Effect.logError("The gateway's reverse proxy could not be reached", {
270
+ host,
271
+ port,
272
+ client: from,
273
+ ...who,
274
+ error: opened.failure._tag,
275
+ });
276
+ }
277
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
278
+ if (head.length > 0)
279
+ opened.success.write(head);
280
+ yield* Effect.logInfo("Opened an egress tunnel", {
281
+ host,
282
+ port,
283
+ destination: "gateway",
284
+ client: from,
285
+ ...who,
286
+ });
287
+ return yield* splice(client, opened.success);
288
+ }
289
+ const decision = (yield* inForce.current).egress.decide(host, port);
290
+ // Only port 443 tunnels are allowed; plain HTTP goes through the absolute-form relay, which checks the host.
291
+ if (decision._tag === "EgressRefused" || port !== 443) {
292
+ const refused = decision._tag === "EgressRefused"
293
+ ? decision
294
+ : new EgressRefused({ host, port, reason: "port-not-allowed" });
295
+ client.end(statusLine(403, "Forbidden", `usine gateway: ${host}:${port} is refused (${refused.reason}).\n`, Option.some(refused.reason)));
296
+ return yield* logRefusal(refused, from, who);
297
+ }
298
+ // The client sends its ClientHello after this line; no upstream connection is opened until its server name is checked.
299
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
300
+ const opened = yield* clientHello(client, head).pipe(Effect.flatMap((hello) => pinnedServerName(host, hello)), Effect.flatMap(({ buffered, serverName }) => resolve(decision).pipe(Effect.catchTag("EgressRefused", (refused) => Effect.fail(new TunnelRefused({
301
+ reason: "private-address",
302
+ detail: `${refused.host} resolves to a private address`,
303
+ serverName: Option.some(serverName),
304
+ }))), Effect.flatMap((address) => openTunnel(address, port, decision.host)), Effect.map((upstream) => ({ upstream, buffered, serverName })))), Effect.result);
305
+ if (opened._tag === "Failure") {
306
+ const failure = opened.failure;
307
+ if (failure._tag === "TunnelAbandoned") {
308
+ client.destroy();
309
+ return yield* Effect.logInfo("The client abandoned a tunnel before sending anything", {
310
+ host,
311
+ port,
312
+ client: from,
313
+ ...who,
314
+ });
315
+ }
316
+ if (failure._tag === "TunnelRefused") {
317
+ closeRefused(client, failure);
318
+ return yield* Effect.logWarning("Gateway refused a request", {
319
+ reason: failure.reason,
320
+ host,
321
+ port,
322
+ serverName: Option.getOrElse(failure.serverName, () => "none"),
323
+ detail: failure.detail,
324
+ client: from,
325
+ ...who,
326
+ });
327
+ }
328
+ client.destroy();
329
+ return yield* Effect.logError("An allowed egress host could not be reached", {
330
+ host,
331
+ port,
332
+ client: from,
333
+ ...who,
334
+ error: failure._tag,
335
+ });
336
+ }
337
+ const { upstream, buffered, serverName } = opened.success;
338
+ upstream.write(buffered);
339
+ yield* Effect.logInfo("Opened an egress tunnel", {
340
+ host,
341
+ port,
342
+ serverName,
343
+ destination: "egress",
344
+ client: from,
345
+ ...who,
346
+ });
347
+ yield* splice(client, upstream);
348
+ });
349
+ const relay = Effect.fn("EgressProxy.relay")(function* (request, response) {
350
+ const from = request.socket.remoteAddress ?? "unknown";
351
+ const who = yield* caller(request.headers["proxy-authorization"]);
352
+ const url = Option.liftThrowable(() => new URL(request.url ?? ""))().pipe(Option.filter((parsed) => parsed.protocol === "http:"));
353
+ if (Option.isNone(url)) {
354
+ response.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
355
+ response.end("usine gateway: the forward proxy accepts only CONNECT or an absolute http:// URL.\n");
356
+ return yield* Effect.logWarning("Gateway refused a request", {
357
+ reason: "not-a-proxy-request",
358
+ client: from,
359
+ ...who,
360
+ });
361
+ }
362
+ const port = url.value.port === "" ? 80 : Number(url.value.port);
363
+ const host = url.value.hostname.replace(/^\[(.*)\]$/u, "$1");
364
+ yield* Effect.annotateCurrentSpan({ host, port });
365
+ const forwarded = endToEndHeaders(Headers.fromInput(request.headers));
366
+ /**
367
+ * Sends the request to `target` and copies the response back until it ends or the client
368
+ * disconnects. If the client disconnects mid-transfer, destroys the upstream connection
369
+ * immediately. Fails with `Unreachable` if the upstream connection fails or ends early, after
370
+ * the client's response is destroyed or set to 502.
371
+ */
372
+ const copy = (target, headers) => Effect.callback((resume) => {
373
+ let settled = false;
374
+ const settle = (end) => {
375
+ settled = true;
376
+ resume(end);
377
+ };
378
+ const fail = (error) => {
379
+ if (settled)
380
+ return;
381
+ if (response.headersSent)
382
+ response.destroy();
383
+ else {
384
+ response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
385
+ response.end();
386
+ }
387
+ settle(Effect.fail(new Unreachable({ host, port, message: error.message })));
388
+ };
389
+ const outgoing = httpRequest({
390
+ host: target.address,
391
+ port: target.port,
392
+ method: request.method,
393
+ path: `${url.value.pathname}${url.value.search}`,
394
+ headers: { ...headers, host: url.value.host },
395
+ setHost: false,
396
+ }, (incoming) => {
397
+ const status = incoming.statusCode ?? 502;
398
+ response.writeHead(status, {
399
+ ...endToEndHeaders(Headers.fromInput(incoming.headers)),
400
+ });
401
+ incoming.once("error", fail);
402
+ incoming.once("close", () => {
403
+ if (!incoming.complete)
404
+ fail(new Error("The upstream response ended prematurely"));
405
+ });
406
+ incoming.once("end", () => settle(Effect.succeed(RelayEnd.Relayed({ status }))));
407
+ incoming.pipe(response);
408
+ });
409
+ // `close` also fires after a finished response, which already completed the relay; before that, it means the client disconnected.
410
+ response.once("close", () => {
411
+ if (settled)
412
+ return;
413
+ outgoing.destroy();
414
+ settle(Effect.succeed(RelayEnd.Abandoned({
415
+ status: response.headersSent ? Option.some(response.statusCode) : Option.none(),
416
+ })));
417
+ });
418
+ outgoing.once("error", fail);
419
+ request.pipe(outgoing);
420
+ return Effect.sync(() => outgoing.destroy());
421
+ });
422
+ const relayed = yield* destination(host, port).pipe(Effect.flatMap((target) =>
423
+ // Only the reverse proxy receives the caller token; hosts outside the Gateway must never receive it.
424
+ (target.kind === "gateway"
425
+ ? Effect.succeed(forwarded)
426
+ : callers.withoutCallerTokens(forwarded)).pipe(Effect.map((headers) => ({ target, headers })))), Effect.flatMap(({ target, headers }) => copy(target, headers).pipe(Effect.map((end) => ({ end, kind: target.kind })))), Effect.result);
427
+ if (relayed._tag === "Success") {
428
+ const fields = {
429
+ host,
430
+ port,
431
+ destination: relayed.success.kind,
432
+ method: request.method ?? "GET",
433
+ client: from,
434
+ ...who,
435
+ };
436
+ return yield* Match.valueTags(relayed.success.end, {
437
+ Relayed: ({ status }) => Effect.logInfo("Relayed a plain HTTP request", { ...fields, status }),
438
+ Abandoned: ({ status }) => Effect.logInfo("The client abandoned a relayed request", {
439
+ ...fields,
440
+ status: Option.getOrElse(status, () => "none"),
441
+ }),
442
+ });
443
+ }
444
+ const failure = relayed.failure;
445
+ if (failure._tag === "EgressRefused") {
446
+ response.writeHead(403, {
447
+ "content-type": "text/plain; charset=utf-8",
448
+ [REFUSAL_HEADER]: failure.reason,
449
+ });
450
+ response.end(`usine gateway: ${host}:${port} is refused (${failure.reason}).\n`);
451
+ return yield* logRefusal(failure, from, who);
452
+ }
453
+ if (!response.headersSent) {
454
+ response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
455
+ response.end(`usine gateway: ${host} is unreachable.\n`);
456
+ }
457
+ yield* Effect.logError("An allowed egress host could not be reached", {
458
+ host,
459
+ port,
460
+ client: from,
461
+ ...who,
462
+ error: failure._tag,
463
+ });
464
+ });
465
+ const server = createServer({ requestTimeout: 0 });
466
+ const run = (effect) => Fiber.runIn(Effect.runForkWith(context)(effect), scope);
467
+ server.on("connect", (request, client, head) => {
468
+ client.on("error", () => client.destroy());
469
+ // The server's sockets are half-open, so `server.close` waits for an interrupted tunnel until its client socket is destroyed.
470
+ run(tunnel(request, client, head).pipe(Effect.onInterrupt(() => Effect.sync(() => client.destroy()))));
471
+ });
472
+ server.on("request", (request, response) => run(relay(request, response)));
473
+ yield* Effect.acquireRelease(Effect.callback((resume) => {
474
+ server.once("error", (error) => resume(Effect.fail(new EgressProxyListenError({
475
+ host: listener.host,
476
+ port: listener.port,
477
+ message: error.message,
478
+ }))));
479
+ server.listen(listener.port, listener.host, () => resume(Effect.void));
480
+ }), () => Effect.callback((resume) => {
481
+ server.close(() => resume(Effect.void));
482
+ server.closeAllConnections();
483
+ }));
484
+ const address = server.address();
485
+ const bound = address === null || Predicate.isString(address)
486
+ ? listener
487
+ : { host: address.address, port: address.port };
488
+ yield* Effect.logInfo("The egress proxy is listening", bound);
489
+ return bound;
490
+ });
491
+ //# sourceMappingURL=egress-proxy.js.map