@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.4
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/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +19 -11
- package/dist/nodefony/command/assetsPublishCommand.js +5 -3
- package/dist/nodefony/command/certificatesCommand.js +5 -3
- package/dist/nodefony/command/networkCommand.js +5 -3
- package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
- package/dist/nodefony/config/config.js +45 -6
- package/dist/nodefony/config/defineModuleConfig.js +2 -3
- package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +2 -3
- package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
- package/dist/nodefony/service/audit-logger.js +22 -16
- package/dist/nodefony/service/certificates.js +228 -19
- package/dist/nodefony/service/error-renderer.js +30 -13
- package/dist/nodefony/service/http-kernel.js +331 -82
- package/dist/nodefony/service/pretty-request-logger.js +5 -4
- package/dist/nodefony/service/request-logger.js +24 -16
- package/dist/nodefony/service/reverse-proxy.js +336 -0
- package/dist/nodefony/service/servers/clientError.js +22 -3
- package/dist/nodefony/service/servers/server-h2c.js +126 -0
- package/dist/nodefony/service/servers/server-http.js +5 -8
- package/dist/nodefony/service/servers/server-https.js +12 -24
- package/dist/nodefony/service/servers/server-static.js +7 -10
- package/dist/nodefony/service/servers/server-websocket-secure.js +18 -18
- package/dist/nodefony/service/servers/server-websocket.js +18 -18
- package/dist/nodefony/service/servers/serverShutdown.js +78 -18
- package/dist/nodefony/service/servers/wsHeartbeat.js +32 -17
- package/dist/nodefony/service/sessions/sessions-service.js +34 -20
- package/dist/nodefony/service/trace.js +1 -2
- package/dist/nodefony/service/upload/upload-service.js +5 -8
- package/dist/nodefony/service/x509.js +1 -2
- package/dist/nodefony/src/assets/collectAssets.js +1 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
- package/dist/nodefony/src/context/Context.js +51 -32
- package/dist/nodefony/src/context/domainMatcher.js +21 -4
- package/dist/nodefony/src/context/forwarded.js +3 -4
- package/dist/nodefony/src/context/http/HttpContext.js +98 -46
- package/dist/nodefony/src/context/http/Request.js +34 -38
- package/dist/nodefony/src/context/http/Response.js +39 -7
- package/dist/nodefony/src/context/http/SseStream.js +349 -0
- package/dist/nodefony/src/context/http/parser.js +36 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Request.js +4 -8
- package/dist/nodefony/src/context/http2/Response.js +22 -9
- package/dist/nodefony/src/context/metaData.js +1 -2
- package/dist/nodefony/src/context/requestId.js +1 -2
- package/dist/nodefony/src/context/responseEnded.js +26 -0
- package/dist/nodefony/src/context/transport.js +45 -0
- package/dist/nodefony/src/context/trustProxy.js +1 -2
- package/dist/nodefony/src/context/websocket/Response.js +42 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +94 -20
- package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
- package/dist/nodefony/src/context/websocket/websocketUrl.js +20 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
- package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
- package/dist/nodefony/src/cookies/cookie.js +3 -7
- package/dist/nodefony/src/errors/httpError.js +1 -2
- package/dist/nodefony/src/errors/thrownMessage.js +1 -2
- package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
- package/dist/nodefony/src/profiler/Profiler.js +108 -30
- package/dist/nodefony/src/profiler/aggregate.js +422 -0
- package/dist/nodefony/src/proxy/forward.js +500 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
- package/dist/nodefony/src/proxy/rules.js +188 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
- package/dist/nodefony/src/servers/http2Limits.js +50 -0
- package/dist/nodefony/src/servers/kernelServers.js +12 -3
- package/dist/nodefony/src/servers/portBinder.js +41 -10
- package/dist/nodefony/src/servers/upgradeDispatch.js +128 -0
- package/dist/nodefony/src/session/session.js +30 -15
- package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
- package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
- package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
- package/dist/types/index.d.ts +12 -3
- package/dist/types/nodefony/config/config.d.ts +16 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +65 -4
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
- package/dist/types/nodefony/interfaces/index.d.ts +3 -2
- package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
- package/dist/types/nodefony/service/certificates.d.ts +147 -5
- package/dist/types/nodefony/service/error-renderer.d.ts +32 -1
- package/dist/types/nodefony/service/http-kernel.d.ts +157 -15
- package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
- package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +2 -3
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +2 -3
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
- package/dist/types/nodefony/src/context/Context.d.ts +35 -6
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +48 -1
- package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
- package/dist/types/nodefony/src/context/http/Response.d.ts +14 -0
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
- package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
- package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
- package/dist/types/nodefony/src/context/transport.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
- package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +69 -15
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +168 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +88 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
- package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +69 -0
- package/dist/types/nodefony/src/session/session.d.ts +2 -0
- package/docs/cookies.md +85 -78
- package/docs/index.md +5 -1
- package/docs/observabilite.md +81 -81
- package/docs/rate-limit.md +83 -83
- package/docs/reverse-proxy.md +522 -0
- package/docs/servers.md +378 -230
- package/docs/session.md +180 -180
- package/docs/sse.md +287 -0
- package/docs/upload.md +120 -117
- package/package.json +10 -11
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
// nodefony/src/proxy/forward.ts
|
|
4
|
+
/**
|
|
5
|
+
* En-têtes de CONNEXION (RFC 9110 §7.6.1) : ils décrivent un saut, jamais le
|
|
6
|
+
* message — un intermédiaire ne les transmet pas. Les cinq premiers sont en
|
|
7
|
+
* outre INTERDITS en HTTP/2 (RFC 9113 §8.2.2) : Node lève
|
|
8
|
+
* `ERR_HTTP2_INVALID_CONNECTION_HEADERS` si une réponse les porte.
|
|
9
|
+
*/
|
|
10
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
11
|
+
"connection",
|
|
12
|
+
"keep-alive",
|
|
13
|
+
"proxy-connection",
|
|
14
|
+
"transfer-encoding",
|
|
15
|
+
"upgrade",
|
|
16
|
+
"te",
|
|
17
|
+
"trailer",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"proxy-authenticate"
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* En-têtes qui décrivent la chaîne de relais. Crus d'un relais de confiance,
|
|
23
|
+
* écartés sinon : un client direct qui les pose ment sur son origine.
|
|
24
|
+
*/
|
|
25
|
+
const FORWARDING = /* @__PURE__ */ new Set([
|
|
26
|
+
"forwarded",
|
|
27
|
+
"x-forwarded-for",
|
|
28
|
+
"x-forwarded-proto",
|
|
29
|
+
"x-forwarded-host",
|
|
30
|
+
"x-forwarded-port",
|
|
31
|
+
"x-forwarded-prefix",
|
|
32
|
+
"x-forwarded-scheme",
|
|
33
|
+
"x-real-ip"
|
|
34
|
+
]);
|
|
35
|
+
/** Corps d'erreur du proxy — courts, sans détail interne. */
|
|
36
|
+
const BAD_GATEWAY = "Bad Gateway";
|
|
37
|
+
const GATEWAY_TIMEOUT = "Gateway Timeout";
|
|
38
|
+
const LOOP_DETECTED = "Loop Detected";
|
|
39
|
+
const NOT_IMPLEMENTED = "Not Implemented";
|
|
40
|
+
/**
|
|
41
|
+
* Sauts `Via` au-delà desquels un message est tenu pour une boucle. Notre
|
|
42
|
+
* pseudonyme ne repère une boucle qu'au RETOUR dans ce processus : derrière un
|
|
43
|
+
* répartiteur qui tourne sur N instances, cela peut prendre N+1 sauts, chacun
|
|
44
|
+
* tenant une connexion. Une chaîne légitime (CDN, répartiteur, ingress) en
|
|
45
|
+
* compte rarement plus de cinq.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_VIA_HOPS = 10;
|
|
48
|
+
/**
|
|
49
|
+
* Noms listés par l'en-tête `Connection` d'un message : eux aussi ne valent
|
|
50
|
+
* que pour ce saut (RFC 9110 §7.6.1).
|
|
51
|
+
*
|
|
52
|
+
* @param value - valeur brute de `Connection`
|
|
53
|
+
* @returns noms en minuscules, ou `null` s'il n'y en a aucun
|
|
54
|
+
*/
|
|
55
|
+
function connectionTokens(value) {
|
|
56
|
+
if (value === void 0) return null;
|
|
57
|
+
const raw = Array.isArray(value) ? value.join(",") : value;
|
|
58
|
+
let tokens = null;
|
|
59
|
+
for (const t of raw.split(",")) {
|
|
60
|
+
const name = t.trim().toLowerCase();
|
|
61
|
+
if (name === "") continue;
|
|
62
|
+
(tokens ??= /* @__PURE__ */ new Set()).add(name);
|
|
63
|
+
}
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
/** Valeur d'en-tête aplatie (`a, b` pour un tableau). */
|
|
67
|
+
function flat(value) {
|
|
68
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Le message est-il déjà passé par CE processus ? Un `Via` qui porte notre
|
|
72
|
+
* pseudonyme signe une boucle (RFC 9110 §7.6 : un proxy ne se renvoie pas un
|
|
73
|
+
* message sans s'en protéger).
|
|
74
|
+
*
|
|
75
|
+
* @param via - en-tête `Via` reçu
|
|
76
|
+
* @param pseudonym - pseudonyme de ce processus
|
|
77
|
+
*/
|
|
78
|
+
function viaLoops(via, pseudonym) {
|
|
79
|
+
const value = flat(via);
|
|
80
|
+
if (value === void 0) return false;
|
|
81
|
+
const hops = value.split(",");
|
|
82
|
+
if (hops.length >= 10) return true;
|
|
83
|
+
for (const hop of hops) if (hop.trim().split(/\s+/)[1] === pseudonym) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/** Entrée `Via` de ce saut : `<version> <pseudonyme>` (RFC 9110 §7.6.3). */
|
|
87
|
+
function viaEntry(httpVersion, pseudonym) {
|
|
88
|
+
return `${httpVersion.startsWith("2") ? "2" : httpVersion || "1.1"} ${pseudonym}`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Cadrage du corps reçu — décidé UNE fois, pour que l'amont lise exactement
|
|
92
|
+
* les octets que le proxy lui envoie (RFC 9112 §6.3). Un `Transfer-Encoding`
|
|
93
|
+
* l'emporte sur un `Content-Length` (que l'on retire alors) ; en HTTP/2, un
|
|
94
|
+
* flux sans fin à la tête porte un corps même sans longueur annoncée.
|
|
95
|
+
*
|
|
96
|
+
* @param req - requête reçue
|
|
97
|
+
* @returns le cadrage à appliquer vers l'amont
|
|
98
|
+
*/
|
|
99
|
+
function bodyFramingOf(req) {
|
|
100
|
+
const coding = req.headers["transfer-encoding"];
|
|
101
|
+
if (coding !== void 0) {
|
|
102
|
+
if (coding === "chunked") return "chunked";
|
|
103
|
+
return coding.trim().toLowerCase() === "chunked" ? "chunked" : "unsupported";
|
|
104
|
+
}
|
|
105
|
+
if (req.headers["content-length"] !== void 0) return "length";
|
|
106
|
+
const stream = req.stream;
|
|
107
|
+
if (stream !== void 0 && !stream.endAfterHeaders) return "chunked";
|
|
108
|
+
return "none";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* En-têtes de la requête à transmettre à l'amont.
|
|
112
|
+
*
|
|
113
|
+
* - écartés : pseudo-en-têtes HTTP/2, en-têtes de connexion et ceux que nomme
|
|
114
|
+
* `Connection`, `Expect` (le serveur a déjà répondu `100`), en-têtes retirés
|
|
115
|
+
* par le montage, chaîne de relais venue d'un pair NON fiable ;
|
|
116
|
+
* - `Host` réécrit sur la cible (sauf `preserveHost`) ;
|
|
117
|
+
* - `X-Forwarded-For` prolongé (pair fiable) ou recommencé ;
|
|
118
|
+
* `X-Forwarded-Proto`/`-Host` hérités d'un pair fiable, posés sinon ;
|
|
119
|
+
* `X-Forwarded-Prefix` quand le préfixe est retiré — à la suite de celui
|
|
120
|
+
* d'un pair fiable ; `Forwarded` d'un pair fiable prolongé de ce saut ;
|
|
121
|
+
* - `Via` prolongé de ce saut ;
|
|
122
|
+
* - cadrage du corps rendu cohérent ({@link bodyFramingOf}).
|
|
123
|
+
*
|
|
124
|
+
* @returns un objet neuf, prêt pour `http.request`
|
|
125
|
+
*/
|
|
126
|
+
function requestHeadersFor(headers, mount, target, from, pseudonym, framing) {
|
|
127
|
+
const listed = connectionTokens(headers.connection);
|
|
128
|
+
const stripped = mount.stripHeaders;
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const name of Object.keys(headers)) {
|
|
131
|
+
const lower = name.toLowerCase();
|
|
132
|
+
if (lower.charCodeAt(0) === 58) continue;
|
|
133
|
+
if (HOP_BY_HOP.has(lower) || listed?.has(lower)) continue;
|
|
134
|
+
if (lower === "host" || lower === "expect" || lower === "via") continue;
|
|
135
|
+
if (lower === "content-length" && framing !== "length") continue;
|
|
136
|
+
if (FORWARDING.has(lower) && !from.trustedPeer) continue;
|
|
137
|
+
if (stripped?.includes(lower)) continue;
|
|
138
|
+
const value = headers[name];
|
|
139
|
+
if (value !== void 0) out[lower] = value;
|
|
140
|
+
}
|
|
141
|
+
out.host = mount.preserveHost === true && from.host ? from.host : target.host;
|
|
142
|
+
const prior = from.trustedPeer ? flat(headers["x-forwarded-for"]) : void 0;
|
|
143
|
+
if (from.remoteAddress) out["x-forwarded-for"] = prior ? `${prior}, ${from.remoteAddress}` : from.remoteAddress;
|
|
144
|
+
out["x-forwarded-proto"] ??= from.scheme;
|
|
145
|
+
if (from.host) out["x-forwarded-host"] ??= from.host;
|
|
146
|
+
if (mount.stripPrefix === true) {
|
|
147
|
+
let outer = from.trustedPeer ? flat(headers["x-forwarded-prefix"]) : void 0;
|
|
148
|
+
if (outer?.endsWith("/")) outer = outer.slice(0, -1);
|
|
149
|
+
out["x-forwarded-prefix"] = (outer ?? "") + mount.prefix.slice(0, -1);
|
|
150
|
+
}
|
|
151
|
+
const forwarded = from.trustedPeer ? flat(headers.forwarded) : void 0;
|
|
152
|
+
if (forwarded) out.forwarded = `${forwarded}, ${forwardedElement(from)}`;
|
|
153
|
+
const via = flat(headers.via);
|
|
154
|
+
const hop = viaEntry(from.httpVersion, pseudonym);
|
|
155
|
+
out.via = via ? `${via}, ${hop}` : hop;
|
|
156
|
+
if (framing === "chunked") out["transfer-encoding"] = "chunked";
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/** Caractères d'un `token` (RFC 9110 §5.6.2) — compilé une fois. */
|
|
160
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
161
|
+
/** Valeur d'un paramètre `Forwarded` : jeton nu, sinon chaîne entre guillemets (RFC 7239 §4). */
|
|
162
|
+
function forwardedValue(value) {
|
|
163
|
+
return TOKEN.test(value) ? value : `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Élément `Forwarded` de ce saut : `for=` (une IPv6 entre crochets et
|
|
167
|
+
* guillemets, RFC 7239 §6), `proto=`, `host=`.
|
|
168
|
+
*/
|
|
169
|
+
function forwardedElement(from) {
|
|
170
|
+
const address = from.remoteAddress;
|
|
171
|
+
let element = `for=${forwardedValue(address === void 0 ? "unknown" : address.includes(":") ? `[${address}]` : address)};proto=${forwardedValue(from.scheme)}`;
|
|
172
|
+
if (from.host) element += `;host=${forwardedValue(from.host)}`;
|
|
173
|
+
return element;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Ramène sous le préfixe un `Location` émis par un amont qui ignore son
|
|
177
|
+
* montage (`stripPrefix`) — l'équivalent de `proxy_redirect default` de
|
|
178
|
+
* nginx. Un chemin absolu (`/login`) et une URL sur l'origine de l'amont sont
|
|
179
|
+
* réécrits ; toute autre URL (un tiers) part intacte.
|
|
180
|
+
*
|
|
181
|
+
* @returns la valeur réécrite, ou celle reçue
|
|
182
|
+
*/
|
|
183
|
+
function rewriteLocation(location, mount, target) {
|
|
184
|
+
if (mount.stripPrefix !== true) return location;
|
|
185
|
+
if (location.startsWith("/") && !location.startsWith("//")) return mount.prefix + location.slice(1);
|
|
186
|
+
if (location.startsWith(target.origin + "/") || location === target.origin) return mount.prefix + location.slice(target.origin.length + 1);
|
|
187
|
+
return location;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* En-têtes de la réponse de l'amont à renvoyer au client : en-têtes de
|
|
191
|
+
* connexion écartés — y compris ceux que nomme `Connection` —, `Via`
|
|
192
|
+
* prolongé, `Location` ramené sous le préfixe (`stripPrefix`) ; le reste
|
|
193
|
+
* intact (un `set-cookie` multiple reste un tableau).
|
|
194
|
+
*
|
|
195
|
+
* @returns un objet neuf, valable en HTTP/1.1 comme en HTTP/2
|
|
196
|
+
*/
|
|
197
|
+
function responseHeadersFor(headers, mount, target, hop) {
|
|
198
|
+
const listed = connectionTokens(headers.connection);
|
|
199
|
+
const out = {};
|
|
200
|
+
for (const name of Object.keys(headers)) {
|
|
201
|
+
if (HOP_BY_HOP.has(name) || listed?.has(name)) continue;
|
|
202
|
+
const value = headers[name];
|
|
203
|
+
if (value !== void 0) out[name] = value;
|
|
204
|
+
}
|
|
205
|
+
const via = flat(headers.via);
|
|
206
|
+
out.via = via ? `${via}, ${hop}` : hop;
|
|
207
|
+
if (typeof out.location === "string") out.location = rewriteLocation(out.location, mount, target);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Chemin transmis à l'amont : tel quel, ou sans le préfixe (`stripPrefix`).
|
|
212
|
+
*
|
|
213
|
+
* @param url - cible de la requête (chemin + requête), qui commence par le préfixe
|
|
214
|
+
* @param mount - montage qui relaie
|
|
215
|
+
* @returns le chemin, toujours avec un `/` en tête
|
|
216
|
+
*/
|
|
217
|
+
function upstreamPath(url, mount) {
|
|
218
|
+
if (mount.stripPrefix !== true) return url;
|
|
219
|
+
return `/${url.slice(mount.prefix.length)}`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Options de `http.request` communes aux deux relais : cible, pool, TLS.
|
|
223
|
+
* La vérification du certificat suit `secure` (refusé hors boucle locale
|
|
224
|
+
* au montage).
|
|
225
|
+
*/
|
|
226
|
+
function requestOptions(target, mount, relay) {
|
|
227
|
+
const options = {
|
|
228
|
+
protocol: target.protocol,
|
|
229
|
+
hostname: target.hostname,
|
|
230
|
+
port: target.port
|
|
231
|
+
};
|
|
232
|
+
if (relay.agent !== void 0) options.agent = relay.agent;
|
|
233
|
+
if (target.protocol === "https:") options.rejectUnauthorized = mount.secure !== false;
|
|
234
|
+
return options;
|
|
235
|
+
}
|
|
236
|
+
/** Module client selon le scheme de la cible. */
|
|
237
|
+
function requestFn(target) {
|
|
238
|
+
return target.protocol === "https:" ? https.request : http.request;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Arme le délai d'ÉTABLISSEMENT de la connexion amont — distinct du délai
|
|
242
|
+
* d'inactivité : un amont qui n'accepte pas la connexion ne doit pas tenir le
|
|
243
|
+
* client pendant tout `timeoutMs`. Un socket réutilisé (pool) est déjà
|
|
244
|
+
* connecté : rien à armer.
|
|
245
|
+
*
|
|
246
|
+
* @param upstream - requête vers l'amont
|
|
247
|
+
* @param ms - délai
|
|
248
|
+
* @param onTimeout - appelé si la connexion n'est pas établie à temps
|
|
249
|
+
*/
|
|
250
|
+
function armConnectTimeout(upstream, ms, onTimeout) {
|
|
251
|
+
upstream.once("socket", (socket) => {
|
|
252
|
+
if (!socket.connecting) return;
|
|
253
|
+
const timer = setTimeout(onTimeout, ms);
|
|
254
|
+
const clear = () => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
socket.removeListener("connect", clear);
|
|
257
|
+
socket.removeListener("close", clear);
|
|
258
|
+
};
|
|
259
|
+
socket.once("connect", clear);
|
|
260
|
+
socket.once("close", clear);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
|
|
265
|
+
*
|
|
266
|
+
* Le corps est transmis avec un cadrage cohérent ({@link bodyFramingOf}). Un
|
|
267
|
+
* message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Un
|
|
268
|
+
* client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
|
|
269
|
+
* connexion non établie à temps ou un amont muet rendent 504 — à condition
|
|
270
|
+
* que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
|
|
271
|
+
* statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
|
|
272
|
+
* réponse au client au lieu de la laisser pendre.
|
|
273
|
+
*
|
|
274
|
+
* @returns une promesse résolue à la fermeture de la réponse — jamais rejetée
|
|
275
|
+
*/
|
|
276
|
+
function forwardRequest(req, res, mount, target, from, relay) {
|
|
277
|
+
return new Promise((resolve) => {
|
|
278
|
+
const out = res;
|
|
279
|
+
let closed = false;
|
|
280
|
+
let failed = false;
|
|
281
|
+
const fail = (status, body) => {
|
|
282
|
+
if (failed || closed) return;
|
|
283
|
+
failed = true;
|
|
284
|
+
if (out.headersSent) {
|
|
285
|
+
out.destroy();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
out.writeHead(status, {
|
|
289
|
+
"content-type": "text/plain; charset=utf-8",
|
|
290
|
+
"content-length": Buffer.byteLength(body),
|
|
291
|
+
"cache-control": "no-store"
|
|
292
|
+
});
|
|
293
|
+
out.end(body);
|
|
294
|
+
};
|
|
295
|
+
let upstream = null;
|
|
296
|
+
out.once("close", () => {
|
|
297
|
+
closed = true;
|
|
298
|
+
if (!out.writableFinished) upstream?.destroy();
|
|
299
|
+
resolve();
|
|
300
|
+
});
|
|
301
|
+
if (viaLoops(req.headers.via, relay.pseudonym)) {
|
|
302
|
+
fail(508, LOOP_DETECTED);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const framing = bodyFramingOf(req);
|
|
306
|
+
if (framing === "unsupported") {
|
|
307
|
+
fail(501, NOT_IMPLEMENTED);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const options = requestOptions(target, mount, relay);
|
|
311
|
+
options.method = req.method ?? "GET";
|
|
312
|
+
options.path = upstreamPath(req.url ?? "/", mount);
|
|
313
|
+
options.headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
|
|
314
|
+
if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
|
|
315
|
+
const request = requestFn(target)(options);
|
|
316
|
+
upstream = request;
|
|
317
|
+
let timedOut = false;
|
|
318
|
+
const giveUp = () => {
|
|
319
|
+
timedOut = true;
|
|
320
|
+
fail(504, GATEWAY_TIMEOUT);
|
|
321
|
+
request.destroy();
|
|
322
|
+
};
|
|
323
|
+
armConnectTimeout(request, relay.connectTimeoutMs, giveUp);
|
|
324
|
+
request.on("timeout", giveUp);
|
|
325
|
+
request.on("error", () => {
|
|
326
|
+
if (!timedOut) fail(502, BAD_GATEWAY);
|
|
327
|
+
});
|
|
328
|
+
const hop = viaEntry(from.httpVersion, relay.pseudonym);
|
|
329
|
+
request.on("response", (answer) => {
|
|
330
|
+
if (closed || failed) {
|
|
331
|
+
answer.resume();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
out.writeHead(answer.statusCode ?? 502, responseHeadersFor(answer.headers, mount, target, hop));
|
|
336
|
+
} catch {
|
|
337
|
+
answer.resume();
|
|
338
|
+
fail(502, BAD_GATEWAY);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
answer.once("close", () => {
|
|
342
|
+
if (!answer.complete) out.destroy();
|
|
343
|
+
});
|
|
344
|
+
answer.pipe(out);
|
|
345
|
+
});
|
|
346
|
+
if (framing === "none") {
|
|
347
|
+
request.end();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const body = req;
|
|
351
|
+
const onClientError = () => {
|
|
352
|
+
request.destroy();
|
|
353
|
+
};
|
|
354
|
+
body.once("error", onClientError);
|
|
355
|
+
request.once("close", () => {
|
|
356
|
+
body.removeListener("error", onClientError);
|
|
357
|
+
body.unpipe(request);
|
|
358
|
+
if (!body.readableEnded) body.resume();
|
|
359
|
+
});
|
|
360
|
+
body.pipe(request);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Sérialise une tête de réponse HTTP/1.1 depuis des en-têtes BRUTS (paires
|
|
365
|
+
* nom/valeur), sans réordonner ni fusionner, en écartant les noms donnés —
|
|
366
|
+
* une réponse `101` doit rendre au client exactement ce que l'amont a négocié.
|
|
367
|
+
*
|
|
368
|
+
* @param status - ligne de statut, sans CRLF
|
|
369
|
+
* @param rawHeaders - `IncomingMessage.rawHeaders`
|
|
370
|
+
* @param skip - noms à écarter (minuscules)
|
|
371
|
+
* @param extra - lignes ajoutées à la fin (`Nom: valeur`)
|
|
372
|
+
* @returns la tête complète, CRLF final compris
|
|
373
|
+
*/
|
|
374
|
+
function rawHead(status, rawHeaders, skip = null, extra = []) {
|
|
375
|
+
let head = `${status}\r\n`;
|
|
376
|
+
for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
|
|
377
|
+
const name = rawHeaders[i] ?? "";
|
|
378
|
+
if (skip?.has(name.toLowerCase())) continue;
|
|
379
|
+
head += `${name}: ${rawHeaders[i + 1]}\r\n`;
|
|
380
|
+
}
|
|
381
|
+
for (const line of extra) head += `${line}\r\n`;
|
|
382
|
+
return `${head}\r\n`;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Réponse brute minimale sur un socket d'upgrade, puis fermeture.
|
|
386
|
+
*
|
|
387
|
+
* Les en-têtes passent par la validation de `node:http` : un nom ou une valeur
|
|
388
|
+
* invalide — un saut de ligne surtout, qui forgerait un en-tête de plus — est
|
|
389
|
+
* OMIS, jamais écrit. `Connection` et `Content-Length` sont posés ici et ne se
|
|
390
|
+
* remplacent pas.
|
|
391
|
+
*
|
|
392
|
+
* @param socket - socket du client, encore en attente de sa réponse
|
|
393
|
+
* @param status - statut HTTP du refus
|
|
394
|
+
* @param reason - phrase de raison
|
|
395
|
+
* @param headers - en-têtes du refus (`WWW-Authenticate`, `Retry-After`…)
|
|
396
|
+
*/
|
|
397
|
+
function refuseUpgrade(socket, status, reason, headers) {
|
|
398
|
+
if (!socket.writable) {
|
|
399
|
+
socket.destroy();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
let extra = "";
|
|
403
|
+
if (headers) for (const name of Object.keys(headers)) {
|
|
404
|
+
const lower = name.toLowerCase();
|
|
405
|
+
if (lower === "connection" || lower === "content-length") continue;
|
|
406
|
+
const value = String(headers[name]);
|
|
407
|
+
try {
|
|
408
|
+
http.validateHeaderName(name);
|
|
409
|
+
http.validateHeaderValue(name, value);
|
|
410
|
+
} catch {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
extra += `${name}: ${value}\r\n`;
|
|
414
|
+
}
|
|
415
|
+
socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
416
|
+
}
|
|
417
|
+
/** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
|
|
418
|
+
const REFUSAL_SKIP = /* @__PURE__ */ new Set([
|
|
419
|
+
"transfer-encoding",
|
|
420
|
+
"content-length",
|
|
421
|
+
"connection",
|
|
422
|
+
"keep-alive"
|
|
423
|
+
]);
|
|
424
|
+
/**
|
|
425
|
+
* Relaie un upgrade WebSocket DÉJÀ validé (méthode, `Upgrade`, clé, version,
|
|
426
|
+
* `Origin` — cf `ReverseProxy.handleUpgrade`) : rejoue le handshake vers
|
|
427
|
+
* l'amont, renvoie sa réponse `101` au client puis raccorde les deux sockets
|
|
428
|
+
* octet pour octet. Un refus de l'amont (`400`, `403`…) est rendu avec un
|
|
429
|
+
* cadrage refait (fin de corps = fermeture) ; un amont injoignable rend `502`,
|
|
430
|
+
* une connexion non établie à temps — ou un amont qui accepte puis se tait
|
|
431
|
+
* au-delà de `timeoutMs` — `504`, une boucle `508`. Le délai d'inactivité
|
|
432
|
+
* cesse au `101` : un tunnel établi peut rester muet. Une erreur d'un côté
|
|
433
|
+
* détruit l'autre.
|
|
434
|
+
*/
|
|
435
|
+
function forwardUpgrade(req, socket, head, mount, target, from, relay) {
|
|
436
|
+
if (viaLoops(req.headers.via, relay.pseudonym)) {
|
|
437
|
+
refuseUpgrade(socket, 508, LOOP_DETECTED);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const options = requestOptions(target, mount, relay);
|
|
441
|
+
options.agent = false;
|
|
442
|
+
options.method = "GET";
|
|
443
|
+
options.path = upstreamPath(req.url ?? "/", mount);
|
|
444
|
+
const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, "none");
|
|
445
|
+
headers.connection = "Upgrade";
|
|
446
|
+
headers.upgrade = "websocket";
|
|
447
|
+
options.headers = headers;
|
|
448
|
+
if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
|
|
449
|
+
if (head.length > 0) socket.unshift(head);
|
|
450
|
+
const upstream = requestFn(target)(options);
|
|
451
|
+
let state = "pending";
|
|
452
|
+
const onClientError = () => {
|
|
453
|
+
upstream.destroy();
|
|
454
|
+
};
|
|
455
|
+
socket.once("error", onClientError);
|
|
456
|
+
armConnectTimeout(upstream, relay.connectTimeoutMs, () => {
|
|
457
|
+
state = "refused";
|
|
458
|
+
refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
|
|
459
|
+
upstream.destroy();
|
|
460
|
+
});
|
|
461
|
+
upstream.on("timeout", () => {
|
|
462
|
+
if (state === "open") return;
|
|
463
|
+
if (state === "pending") refuseUpgrade(socket, 504, GATEWAY_TIMEOUT);
|
|
464
|
+
else socket.destroy();
|
|
465
|
+
state = "refused";
|
|
466
|
+
upstream.destroy();
|
|
467
|
+
});
|
|
468
|
+
upstream.on("upgrade", (answer, upSocket, upHead) => {
|
|
469
|
+
state = "open";
|
|
470
|
+
upSocket.setTimeout(0);
|
|
471
|
+
socket.removeListener("error", onClientError);
|
|
472
|
+
const destroyBoth = () => {
|
|
473
|
+
socket.destroy();
|
|
474
|
+
upSocket.destroy();
|
|
475
|
+
};
|
|
476
|
+
socket.on("error", destroyBoth);
|
|
477
|
+
upSocket.on("error", destroyBoth);
|
|
478
|
+
upSocket.setKeepAlive(true);
|
|
479
|
+
socket.setKeepAlive(true);
|
|
480
|
+
if (upHead.length > 0) upSocket.unshift(upHead);
|
|
481
|
+
socket.write(rawHead(`HTTP/1.1 101 ${answer.statusMessage ?? "Switching Protocols"}`, answer.rawHeaders));
|
|
482
|
+
upSocket.pipe(socket).pipe(upSocket);
|
|
483
|
+
});
|
|
484
|
+
upstream.on("response", (answer) => {
|
|
485
|
+
state = "refused";
|
|
486
|
+
socket.write(rawHead(`HTTP/1.1 ${answer.statusCode ?? 502} ${answer.statusMessage ?? ""}`, answer.rawHeaders, REFUSAL_SKIP, ["Connection: close"]));
|
|
487
|
+
answer.pipe(socket);
|
|
488
|
+
});
|
|
489
|
+
upstream.on("error", () => {
|
|
490
|
+
if (state === "refused") return;
|
|
491
|
+
if (state === "open") {
|
|
492
|
+
socket.destroy();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
state = "refused";
|
|
496
|
+
refuseUpgrade(socket, 502, BAD_GATEWAY);
|
|
497
|
+
});
|
|
498
|
+
upstream.end();
|
|
499
|
+
}
|
|
500
|
+
export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/proxy/generateProxyConfig.ts
|
|
2
2
|
/** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
|
|
3
3
|
const defaultIntrospection = {
|
|
4
4
|
domains: [],
|
|
5
5
|
backendHost: "127.0.0.1",
|
|
6
6
|
httpPort: 5151,
|
|
7
7
|
httpsPort: 5152,
|
|
8
|
+
h2cPort: null,
|
|
8
9
|
staticRoots: [],
|
|
9
10
|
mounts: [],
|
|
10
11
|
listen: 80,
|
|
11
12
|
reencrypt: false,
|
|
12
13
|
maxBodyBytes: 0,
|
|
13
14
|
keepaliveIntervalMs: 0,
|
|
15
|
+
readinessPath: "/readyz",
|
|
14
16
|
tls: null
|
|
15
17
|
};
|
|
16
18
|
/**
|
|
@@ -123,8 +125,16 @@ function nginxServerBlock(intro, scheme, idleSeconds, tls) {
|
|
|
123
125
|
*/
|
|
124
126
|
function generateHaproxyConfig(intro) {
|
|
125
127
|
const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
|
|
128
|
+
const h2cPort = intro.reencrypt ? null : intro.h2cPort ?? null;
|
|
126
129
|
const idleSeconds = idleTimeoutSeconds(intro);
|
|
127
130
|
const serverSsl = intro.reencrypt ? ` ssl ca-file /etc/haproxy/certs/ca.pem verify required verifyhost ${firstDomain(intro)} sni str(${firstDomain(intro)})` : "";
|
|
131
|
+
const readiness = intro.readinessPath ?? null;
|
|
132
|
+
const healthCheck = readiness === null ? "" : ` # Retire l'exemplaire dès que ${readiness} répond 503 (début de l'arrêt),
|
|
133
|
+
# pas seulement quand son port se ferme.
|
|
134
|
+
option httpchk GET ${readiness}
|
|
135
|
+
http-check expect status 200
|
|
136
|
+
`;
|
|
137
|
+
const h2cCheck = readiness === null ? "check" : `check port ${intro.httpPort} check-proto h1`;
|
|
128
138
|
const staticNote = intro.staticRoots.length > 0 || intro.mounts.length > 0 ? " # NB : haproxy ne sert pas de fichiers — les statiques sont proxifiés au\n # backend. Pour les offloader, utiliser nginx (proxy:generate nginx) +\n # `statics.enabled: false` côté Nodefony.\n" : "";
|
|
129
139
|
return `# Généré par \`nodefony proxy:generate haproxy\` — NE PAS éditer à la main.
|
|
130
140
|
# Reverse-proxy dérivé de l'introspection Nodefony.
|
|
@@ -166,11 +176,10 @@ frontend fe_nodefony
|
|
|
166
176
|
http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
|
|
167
177
|
http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
|
|
168
178
|
|
|
169
|
-
default_backend be_nodefony
|
|
170
|
-
|
|
179
|
+
${h2cPort === null ? " default_backend be_nodefony\n" : " # h2c (servers.h2c) : HTTP/2 en clair vers le backend, plusieurs flux par\n # connexion. WebSocket reste en HTTP/1.1 sur le port HTTP : le port h2c\n # n'annonce pas le CONNECT étendu (RFC 8441).\n use_backend be_nodefony if { hdr(upgrade) -i websocket }\n default_backend be_nodefony_h2c\n"}
|
|
171
180
|
backend be_nodefony
|
|
172
|
-
${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
|
|
173
|
-
`;
|
|
181
|
+
${healthCheck}${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
|
|
182
|
+
${h2cPort === null ? "" : `\nbackend be_nodefony_h2c\n${healthCheck} server nodefony_h2c ${intro.backendHost}:${h2cPort} ${h2cCheck} proto h2\n`}`;
|
|
174
183
|
}
|
|
175
184
|
/** Premier domaine non-IP (pour verifyhost/sni), défaut `localhost`. */
|
|
176
185
|
function firstDomain(intro) {
|
|
@@ -180,5 +189,4 @@ function firstDomain(intro) {
|
|
|
180
189
|
function ensureTrailingSlash(dir) {
|
|
181
190
|
return dir.endsWith("/") ? dir : `${dir}/`;
|
|
182
191
|
}
|
|
183
|
-
//#endregion
|
|
184
192
|
export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig };
|