@serve.zone/dcrouter 13.42.4 → 13.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +722 -678
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +3 -3
- package/dist_ts/config/classes.route-config-manager.d.ts +4 -2
- package/dist_ts/config/classes.route-config-manager.js +17 -9
- package/dist_ts/config/helpers.http-redirects.d.ts +10 -0
- package/dist_ts/config/helpers.http-redirects.js +387 -0
- package/dist_ts/config/index.d.ts +1 -0
- package/dist_ts/config/index.js +2 -1
- package/dist_ts/opsserver/handlers/route-management.handler.js +10 -1
- package/dist_ts_interfaces/data/route-management.d.ts +20 -0
- package/dist_ts_interfaces/requests/route-management.d.ts +14 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.d.ts +2 -0
- package/dist_ts_web/appstate.js +28 -1
- package/dist_ts_web/elements/access/ops-view-apitokens.js +2 -1
- package/dist_ts_web/elements/access/ops-view-gatewayclients.js +2 -1
- package/dist_ts_web/elements/network/index.d.ts +1 -0
- package/dist_ts_web/elements/network/index.js +2 -1
- package/dist_ts_web/elements/network/ops-view-redirects.d.ts +18 -0
- package/dist_ts_web/elements/network/ops-view-redirects.js +236 -0
- package/dist_ts_web/elements/network/ops-view-routes.js +2 -1
- package/dist_ts_web/elements/ops-dashboard.js +3 -1
- package/dist_ts_web/router.js +2 -2
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +2 -2
- package/ts/config/classes.route-config-manager.ts +22 -10
- package/ts/config/helpers.http-redirects.ts +462 -0
- package/ts/config/index.ts +1 -0
- package/ts/opsserver/handlers/route-management.handler.ts +15 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +32 -0
- package/ts_web/elements/access/ops-view-apitokens.ts +1 -0
- package/ts_web/elements/access/ops-view-gatewayclients.ts +1 -0
- package/ts_web/elements/network/index.ts +1 -0
- package/ts_web/elements/network/ops-view-redirects.ts +202 -0
- package/ts_web/elements/network/ops-view-routes.ts +1 -0
- package/ts_web/elements/ops-dashboard.ts +2 -0
- package/ts_web/router.ts +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
|
|
3
|
+
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
|
4
|
+
|
|
5
|
+
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
|
|
6
|
+
const REDIRECT_STATUS_CODE = 301;
|
|
7
|
+
const REDIRECT_PRIORITY = 0;
|
|
8
|
+
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
|
|
9
|
+
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
interface IRedirectCandidate {
|
|
12
|
+
key: string;
|
|
13
|
+
id: string;
|
|
14
|
+
domainPattern: string;
|
|
15
|
+
pathPattern?: string;
|
|
16
|
+
sourceRouteNames: Set<string>;
|
|
17
|
+
sourceRouteIds: Set<string>;
|
|
18
|
+
remoteIngress?: IRouteRemoteIngress;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface IRedirectConflict {
|
|
22
|
+
routeName: string;
|
|
23
|
+
covers: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IHttpRedirectDerivationResult {
|
|
27
|
+
redirects: IHttpRedirectInfo[];
|
|
28
|
+
runtimeRoutes: IDcRouterRouteConfig[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function deriveHttpRedirectConfiguration(
|
|
32
|
+
routes: plugins.smartproxy.IRouteConfig[],
|
|
33
|
+
): IHttpRedirectDerivationResult {
|
|
34
|
+
const candidates = collectRedirectCandidates(routes);
|
|
35
|
+
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
|
|
36
|
+
const redirects: IHttpRedirectInfo[] = [];
|
|
37
|
+
const runtimeRoutes: IDcRouterRouteConfig[] = [];
|
|
38
|
+
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
const conflict = findHttpConflict(candidate, httpRoutes);
|
|
41
|
+
const redirectInfo: IHttpRedirectInfo = {
|
|
42
|
+
id: candidate.id,
|
|
43
|
+
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
|
|
44
|
+
domainPattern: candidate.domainPattern,
|
|
45
|
+
pathPattern: candidate.pathPattern,
|
|
46
|
+
fromTemplate: 'http://{domain}{path}',
|
|
47
|
+
toTemplate: REDIRECT_TARGET_TEMPLATE,
|
|
48
|
+
statusCode: REDIRECT_STATUS_CODE,
|
|
49
|
+
priority: REDIRECT_PRIORITY,
|
|
50
|
+
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
|
|
51
|
+
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
|
|
52
|
+
coveredByRouteNames: conflict ? [conflict.routeName] : [],
|
|
53
|
+
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
|
|
54
|
+
notes: conflict
|
|
55
|
+
? conflict.covers
|
|
56
|
+
? 'An explicit HTTP route already covers this redirect scope.'
|
|
57
|
+
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
|
|
58
|
+
: undefined,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
redirects.push(redirectInfo);
|
|
62
|
+
|
|
63
|
+
if (redirectInfo.status === 'active') {
|
|
64
|
+
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { redirects, runtimeRoutes };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deriveHttpRedirects(
|
|
72
|
+
routes: plugins.smartproxy.IRouteConfig[],
|
|
73
|
+
): IHttpRedirectInfo[] {
|
|
74
|
+
return deriveHttpRedirectConfiguration(routes).redirects;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildHttpRedirectRuntimeRoutes(
|
|
78
|
+
routes: plugins.smartproxy.IRouteConfig[],
|
|
79
|
+
): IDcRouterRouteConfig[] {
|
|
80
|
+
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
|
|
84
|
+
const candidates = new Map<string, IRedirectCandidate>();
|
|
85
|
+
|
|
86
|
+
for (const route of routes) {
|
|
87
|
+
if (!isHttpsRedirectSource(route)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const domainPattern of getDomainPatterns(route)) {
|
|
92
|
+
const key = createRedirectKey(domainPattern, route.match.path);
|
|
93
|
+
const existing = candidates.get(key);
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.sourceRouteNames.add(getRouteDisplayName(route));
|
|
96
|
+
if (route.id) existing.sourceRouteIds.add(route.id);
|
|
97
|
+
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const id = createRedirectRouteName(domainPattern, route.match.path);
|
|
102
|
+
candidates.set(key, {
|
|
103
|
+
key,
|
|
104
|
+
id,
|
|
105
|
+
domainPattern,
|
|
106
|
+
pathPattern: route.match.path,
|
|
107
|
+
sourceRouteNames: new Set([getRouteDisplayName(route)]),
|
|
108
|
+
sourceRouteIds: new Set(route.id ? [route.id] : []),
|
|
109
|
+
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
|
|
118
|
+
if (isGeneratedRedirectRoute(route)) return false;
|
|
119
|
+
if (route.enabled === false) return false;
|
|
120
|
+
if (route.action.type !== 'forward') return false;
|
|
121
|
+
if (!route.match.ports) return false;
|
|
122
|
+
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
|
|
123
|
+
if (!route.action.tls) return false;
|
|
124
|
+
if (!route.match.domains) return false;
|
|
125
|
+
if (route.match.transport === 'udp') return false;
|
|
126
|
+
if (route.match.protocol && route.match.protocol !== 'http') return false;
|
|
127
|
+
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
|
132
|
+
if (isGeneratedRedirectRoute(route)) return false;
|
|
133
|
+
if (route.enabled === false) return false;
|
|
134
|
+
if (!route.match.ports) return false;
|
|
135
|
+
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
|
|
136
|
+
if (route.match.transport === 'udp') return false;
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function findHttpConflict(
|
|
141
|
+
candidate: IRedirectCandidate,
|
|
142
|
+
httpRoutes: plugins.smartproxy.IRouteConfig[],
|
|
143
|
+
): IRedirectConflict | undefined {
|
|
144
|
+
for (const route of httpRoutes) {
|
|
145
|
+
if (!httpRouteOverlapsCandidate(route, candidate)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
routeName: getRouteDisplayName(route),
|
|
151
|
+
covers: httpRouteCoversCandidate(route, candidate),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function httpRouteOverlapsCandidate(
|
|
159
|
+
route: plugins.smartproxy.IRouteConfig,
|
|
160
|
+
candidate: IRedirectCandidate,
|
|
161
|
+
): boolean {
|
|
162
|
+
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
|
|
163
|
+
&& pathOverlaps(route.match.path, candidate.pathPattern);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function httpRouteCoversCandidate(
|
|
167
|
+
route: plugins.smartproxy.IRouteConfig,
|
|
168
|
+
candidate: IRedirectCandidate,
|
|
169
|
+
): boolean {
|
|
170
|
+
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return routeDomainCoversCandidate(route, candidate.domainPattern)
|
|
175
|
+
&& pathCovers(route.match.path, candidate.pathPattern);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function routeDomainOverlapsCandidate(
|
|
179
|
+
route: plugins.smartproxy.IRouteConfig,
|
|
180
|
+
candidatePattern: string,
|
|
181
|
+
): boolean {
|
|
182
|
+
const routePatterns = getDomainPatterns(route);
|
|
183
|
+
if (routePatterns.length === 0) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function routeDomainCoversCandidate(
|
|
191
|
+
route: plugins.smartproxy.IRouteConfig,
|
|
192
|
+
candidatePattern: string,
|
|
193
|
+
): boolean {
|
|
194
|
+
const routePatterns = getDomainPatterns(route);
|
|
195
|
+
if (routePatterns.length === 0) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
|
|
203
|
+
if (!route.match.domains) return [];
|
|
204
|
+
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizePattern(pattern: string): string {
|
|
208
|
+
return pattern.trim().toLowerCase().replace(/\.$/, '');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
|
|
212
|
+
const cover = normalizePattern(coverPattern);
|
|
213
|
+
const candidate = normalizePattern(candidatePattern);
|
|
214
|
+
if (cover === candidate) return true;
|
|
215
|
+
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
|
|
216
|
+
|
|
217
|
+
const coverSuffix = getLeadingWildcardSuffix(cover);
|
|
218
|
+
const candidateSuffix = getLeadingWildcardSuffix(candidate);
|
|
219
|
+
if (coverSuffix && candidateSuffix) {
|
|
220
|
+
return candidateSuffix.endsWith(coverSuffix);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
|
|
227
|
+
const first = normalizePattern(firstPattern);
|
|
228
|
+
const second = normalizePattern(secondPattern);
|
|
229
|
+
if (first === second) return true;
|
|
230
|
+
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
|
|
231
|
+
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
|
|
232
|
+
|
|
233
|
+
const firstSuffix = getLeadingWildcardSuffix(first);
|
|
234
|
+
const secondSuffix = getLeadingWildcardSuffix(second);
|
|
235
|
+
if (firstSuffix && secondSuffix) {
|
|
236
|
+
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
|
|
243
|
+
const regex = wildcardPatternToRegex(normalizePattern(pattern));
|
|
244
|
+
return regex.test(normalizePattern(hostname));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function wildcardPatternToRegex(pattern: string): RegExp {
|
|
248
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
249
|
+
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getLeadingWildcardSuffix(pattern: string): string | undefined {
|
|
253
|
+
if (!pattern.startsWith('*')) return undefined;
|
|
254
|
+
if (pattern.slice(1).includes('*')) return undefined;
|
|
255
|
+
return pattern.slice(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
|
|
259
|
+
if (!coverPath) return true;
|
|
260
|
+
if (!candidatePath) return false;
|
|
261
|
+
if (coverPath === candidatePath) return true;
|
|
262
|
+
if (!coverPath.includes('*')) return false;
|
|
263
|
+
const coverPrefix = coverPath.split('*')[0];
|
|
264
|
+
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
|
|
265
|
+
const candidatePrefix = candidatePath.split('*')[0];
|
|
266
|
+
return candidatePrefix.startsWith(coverPrefix);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
|
|
270
|
+
if (!firstPath || !secondPath) return true;
|
|
271
|
+
if (firstPath === secondPath) return true;
|
|
272
|
+
const firstPrefix = firstPath.split('*')[0];
|
|
273
|
+
const secondPrefix = secondPath.split('*')[0];
|
|
274
|
+
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
|
|
278
|
+
return {
|
|
279
|
+
id: candidate.id,
|
|
280
|
+
name: candidate.id,
|
|
281
|
+
description: 'Generated HTTP to HTTPS redirect',
|
|
282
|
+
priority: REDIRECT_PRIORITY,
|
|
283
|
+
tags: ['system', 'redirect', 'auto'],
|
|
284
|
+
match: {
|
|
285
|
+
ports: 80,
|
|
286
|
+
domains: candidate.domainPattern,
|
|
287
|
+
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
|
|
288
|
+
},
|
|
289
|
+
action: {
|
|
290
|
+
type: 'socket-handler',
|
|
291
|
+
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
|
|
292
|
+
},
|
|
293
|
+
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function mergeRemoteIngress(
|
|
298
|
+
current: IRouteRemoteIngress | undefined,
|
|
299
|
+
next: IRouteRemoteIngress | undefined,
|
|
300
|
+
): IRouteRemoteIngress | undefined {
|
|
301
|
+
if (!next?.enabled) return current;
|
|
302
|
+
if (!current?.enabled) {
|
|
303
|
+
return {
|
|
304
|
+
enabled: true,
|
|
305
|
+
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const currentFilter = current.edgeFilter || [];
|
|
310
|
+
const nextFilter = next.edgeFilter || [];
|
|
311
|
+
if (currentFilter.length === 0 || nextFilter.length === 0) {
|
|
312
|
+
return { enabled: true };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
enabled: true,
|
|
317
|
+
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
|
|
322
|
+
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
|
|
326
|
+
const key = createRedirectKey(domainPattern, pathPattern);
|
|
327
|
+
const slug = key
|
|
328
|
+
.replace(/\*/g, 'wildcard')
|
|
329
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
330
|
+
.replace(/^-+|-+$/g, '')
|
|
331
|
+
.slice(0, 48) || 'route';
|
|
332
|
+
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
|
|
333
|
+
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
|
|
337
|
+
return route.name || route.id || 'unnamed-route';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
|
341
|
+
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function createHttpRedirectHandler(
|
|
345
|
+
locationTemplate: string,
|
|
346
|
+
statusCode: number,
|
|
347
|
+
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
|
|
348
|
+
return (socket, context) => {
|
|
349
|
+
const cleanup = () => {
|
|
350
|
+
clearTimeout(timeout);
|
|
351
|
+
socket.removeListener('data', handleData);
|
|
352
|
+
socket.removeListener('error', cleanup);
|
|
353
|
+
socket.removeListener('close', cleanup);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const handleData = (data: string | Uint8Array) => {
|
|
357
|
+
cleanup();
|
|
358
|
+
const request = parseHttpRequest(data);
|
|
359
|
+
if (!request) {
|
|
360
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
|
|
365
|
+
const finalLocation = locationTemplate
|
|
366
|
+
.replace('{domain}', domain)
|
|
367
|
+
.replace('{port}', String(context.port))
|
|
368
|
+
.replace('{path}', request.path || '/')
|
|
369
|
+
.replace('{clientIp}', context.clientIp);
|
|
370
|
+
const message = `Redirecting to ${finalLocation}`;
|
|
371
|
+
const response = [
|
|
372
|
+
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
|
|
373
|
+
`Location: ${finalLocation}`,
|
|
374
|
+
'Content-Type: text/plain',
|
|
375
|
+
`Content-Length: ${message.length}`,
|
|
376
|
+
'Connection: close',
|
|
377
|
+
'',
|
|
378
|
+
message,
|
|
379
|
+
].join('\r\n');
|
|
380
|
+
|
|
381
|
+
socket.end(response);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const timeout = setTimeout(() => {
|
|
385
|
+
cleanup();
|
|
386
|
+
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
|
|
387
|
+
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
388
|
+
timeout.unref?.();
|
|
389
|
+
|
|
390
|
+
socket.once('data', handleData);
|
|
391
|
+
socket.once('error', cleanup);
|
|
392
|
+
socket.once('close', cleanup);
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function parseHttpRequest(data: string | Uint8Array): {
|
|
397
|
+
method: string;
|
|
398
|
+
path: string;
|
|
399
|
+
headers: Record<string, string>;
|
|
400
|
+
} | undefined {
|
|
401
|
+
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
|
402
|
+
const headerEnd = requestText.indexOf('\r\n\r\n');
|
|
403
|
+
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
|
|
404
|
+
const lines = headerText.split('\r\n');
|
|
405
|
+
const [method, rawPath] = (lines[0] || '').split(' ');
|
|
406
|
+
if (!method || !rawPath) return undefined;
|
|
407
|
+
|
|
408
|
+
const headers: Record<string, string> = {};
|
|
409
|
+
for (const line of lines.slice(1)) {
|
|
410
|
+
const colonIndex = line.indexOf(':');
|
|
411
|
+
if (colonIndex <= 0) continue;
|
|
412
|
+
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
|
413
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
414
|
+
headers[key] = value;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
method,
|
|
419
|
+
path: normalizeRequestPath(rawPath),
|
|
420
|
+
headers,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function normalizeRequestPath(rawPath: string): string {
|
|
425
|
+
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
|
|
426
|
+
try {
|
|
427
|
+
const url = new URL(rawPath);
|
|
428
|
+
return `${url.pathname}${url.search}` || '/';
|
|
429
|
+
} catch {
|
|
430
|
+
return '/';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return rawPath.startsWith('/') ? rawPath : '/';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
|
|
438
|
+
if (!hostHeader) return undefined;
|
|
439
|
+
const host = hostHeader.split(',')[0].trim();
|
|
440
|
+
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
|
|
441
|
+
if (host.startsWith('[')) {
|
|
442
|
+
const bracketIndex = host.indexOf(']');
|
|
443
|
+
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return host.replace(/:(80|443)$/, '');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getHttpStatusText(statusCode: number): string {
|
|
450
|
+
switch (statusCode) {
|
|
451
|
+
case 301:
|
|
452
|
+
return 'Moved Permanently';
|
|
453
|
+
case 302:
|
|
454
|
+
return 'Found';
|
|
455
|
+
case 307:
|
|
456
|
+
return 'Temporary Redirect';
|
|
457
|
+
case 308:
|
|
458
|
+
return 'Permanent Redirect';
|
|
459
|
+
default:
|
|
460
|
+
return 'Redirect';
|
|
461
|
+
}
|
|
462
|
+
}
|
package/ts/config/index.ts
CHANGED
|
@@ -5,5 +5,6 @@ export { ApiTokenManager } from './classes.api-token-manager.js';
|
|
|
5
5
|
export { GatewayClientManager } from './classes.gateway-client-manager.js';
|
|
6
6
|
export { ReferenceResolver } from './classes.reference-resolver.js';
|
|
7
7
|
export { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
|
8
|
+
export * from './helpers.http-redirects.js';
|
|
8
9
|
export { DbSeeder } from './classes.db-seeder.js';
|
|
9
10
|
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
|
@@ -42,6 +42,21 @@ export class RouteManagementHandler {
|
|
|
42
42
|
),
|
|
43
43
|
);
|
|
44
44
|
|
|
45
|
+
// Get generated HTTP redirects
|
|
46
|
+
this.typedrouter.addTypedHandler(
|
|
47
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHttpRedirects>(
|
|
48
|
+
'getHttpRedirects',
|
|
49
|
+
async (dataArg) => {
|
|
50
|
+
await this.requireAuth(dataArg, 'routes:read');
|
|
51
|
+
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
|
52
|
+
if (!manager) {
|
|
53
|
+
return { redirects: [] };
|
|
54
|
+
}
|
|
55
|
+
return { redirects: manager.getHttpRedirects() };
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
45
60
|
// Create route
|
|
46
61
|
this.typedrouter.addTypedHandler(
|
|
47
62
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
|
package/ts_web/appstate.ts
CHANGED
|
@@ -290,6 +290,7 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
|
|
|
290
290
|
export interface IRouteManagementState {
|
|
291
291
|
mergedRoutes: interfaces.data.IMergedRoute[];
|
|
292
292
|
warnings: interfaces.data.IRouteWarning[];
|
|
293
|
+
httpRedirects: interfaces.data.IHttpRedirectInfo[];
|
|
293
294
|
apiTokens: interfaces.data.IApiTokenInfo[];
|
|
294
295
|
gatewayClients: interfaces.data.IGatewayClient[];
|
|
295
296
|
isLoading: boolean;
|
|
@@ -302,6 +303,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
|
|
302
303
|
{
|
|
303
304
|
mergedRoutes: [],
|
|
304
305
|
warnings: [],
|
|
306
|
+
httpRedirects: [],
|
|
305
307
|
apiTokens: [],
|
|
306
308
|
gatewayClients: [],
|
|
307
309
|
isLoading: false,
|
|
@@ -2474,6 +2476,36 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
|
|
|
2474
2476
|
}
|
|
2475
2477
|
});
|
|
2476
2478
|
|
|
2479
|
+
export const fetchHttpRedirectsAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
|
|
2480
|
+
const context = getActionContext();
|
|
2481
|
+
const currentState = statePartArg.getState()!;
|
|
2482
|
+
if (!context.identity) return currentState;
|
|
2483
|
+
|
|
2484
|
+
try {
|
|
2485
|
+
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
2486
|
+
interfaces.requests.IReq_GetHttpRedirects
|
|
2487
|
+
>('/typedrequest', 'getHttpRedirects');
|
|
2488
|
+
|
|
2489
|
+
const response = await request.fire({
|
|
2490
|
+
identity: context.identity,
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
return {
|
|
2494
|
+
...currentState,
|
|
2495
|
+
httpRedirects: response.redirects,
|
|
2496
|
+
isLoading: false,
|
|
2497
|
+
error: null,
|
|
2498
|
+
lastUpdated: Date.now(),
|
|
2499
|
+
};
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
return {
|
|
2502
|
+
...currentState,
|
|
2503
|
+
isLoading: false,
|
|
2504
|
+
error: error instanceof Error ? error.message : 'Failed to fetch HTTP redirects',
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2477
2509
|
export const createRouteAction = routeManagementStatePart.createAction<{
|
|
2478
2510
|
route: any;
|
|
2479
2511
|
enabled?: boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './ops-view-network-activity.js';
|
|
2
2
|
export * from './ops-view-routes.js';
|
|
3
|
+
export * from './ops-view-redirects.js';
|
|
3
4
|
export * from './ops-view-sourceprofiles.js';
|
|
4
5
|
export * from './ops-view-networktargets.js';
|
|
5
6
|
export * from './ops-view-targetprofiles.js';
|