@serve.zone/dcrouter 17.7.1 → 17.9.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 +712 -709
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +23 -1
- package/dist_ts/config/classes.db-seeder.js +9 -1
- package/dist_ts/config/classes.gateway-client-manager.js +3 -1
- package/dist_ts/config/helpers.http-redirects.js +28 -1
- package/dist_ts/config/helpers.special-forwards.js +12 -9
- package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
- package/dist_ts/db/documents/classes.cached.email.js +52 -2
- package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
- package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
- package/dist_ts/db/documents/index.d.ts +3 -0
- package/dist_ts/db/documents/index.js +5 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
- package/dist_ts/email/classes.accepted-email-spool.js +28 -3
- package/dist_ts/email/classes.email-route-builder.js +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
- package/dist_ts/opsserver/classes.opsserver.js +5 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -6
- package/dist_ts/opsserver/handlers/index.d.ts +2 -0
- package/dist_ts/opsserver/handlers/index.js +3 -1
- package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
- package/dist_ts/plugins.d.ts +5 -2
- package/dist_ts/plugins.js +6 -3
- package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
- package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
- package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
- package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
- package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
- package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
- package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
- package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
- package/dist_ts/webpush/classes.webpush-transport.js +161 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
- package/dist_ts/webpush/index.d.ts +4 -0
- package/dist_ts/webpush/index.js +5 -0
- package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
- package/dist_ts/webpush/webpush-indexes.js +123 -0
- package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
- package/dist_ts_interfaces/data/route-management.d.ts +2 -0
- package/dist_ts_interfaces/data/route-management.js +1 -1
- package/dist_ts_migrations/index.js +285 -11
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
- package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
- package/package.json +14 -10
- package/readme.md +27 -0
- package/third-party-notices.md +37 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +29 -0
- package/ts/config/classes.db-seeder.ts +8 -0
- package/ts/config/classes.gateway-client-manager.ts +2 -0
- package/ts/config/helpers.http-redirects.ts +31 -1
- package/ts/config/helpers.special-forwards.ts +11 -8
- package/ts/db/documents/classes.cached.email.ts +47 -0
- package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
- package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
- package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
- package/ts/db/documents/index.ts +5 -0
- package/ts/email/classes.accepted-email-spool.ts +41 -2
- package/ts/email/classes.email-route-builder.ts +4 -0
- package/ts/email/classes.workapp-mail-manager.ts +97 -10
- package/ts/index.ts +3 -0
- package/ts/opsserver/classes.opsserver.ts +4 -0
- package/ts/opsserver/handlers/gatewayclient.handler.ts +34 -4
- package/ts/opsserver/handlers/index.ts +2 -0
- package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
- package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
- package/ts/plugins.ts +6 -0
- package/ts/remoteingress/edge-derivation-routes.ts +4 -0
- package/ts/security/classes.route-policy-augmenter.ts +70 -57
- package/ts/webpush/classes.webpush-crypto.ts +306 -0
- package/ts/webpush/classes.webpush-manager.ts +3173 -0
- package/ts/webpush/classes.webpush-transport.ts +241 -0
- package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
- package/ts/webpush/index.ts +4 -0
- package/ts/webpush/webpush-indexes.ts +141 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-routes.ts +38 -0
- package/ts_web/elements/network/special-forward-dialog.ts +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
createPinnedWebPushAgent,
|
|
4
|
+
resolveWebPushEndpoint,
|
|
5
|
+
WebPushEndpointPolicyError,
|
|
6
|
+
type TWebPushDnsLookup,
|
|
7
|
+
} from './helpers.webpush-endpoint.js';
|
|
8
|
+
|
|
9
|
+
export interface IWebPushTransportRequest {
|
|
10
|
+
subscription: plugins.webpush.PushSubscription;
|
|
11
|
+
payload: string;
|
|
12
|
+
ttlSeconds: number;
|
|
13
|
+
urgency: plugins.webpush.Urgency;
|
|
14
|
+
topic?: string;
|
|
15
|
+
vapid: {
|
|
16
|
+
subject: string;
|
|
17
|
+
publicKey: string;
|
|
18
|
+
privateKey: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IWebPushTransportResponse {
|
|
23
|
+
statusCode: number;
|
|
24
|
+
retryAfter?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IWebPushTransport {
|
|
28
|
+
send(requestArg: IWebPushTransportRequest): Promise<IWebPushTransportResponse>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TWebPushTransportErrorCode =
|
|
32
|
+
| 'PUSH_DNS_TIMEOUT'
|
|
33
|
+
| 'PUSH_REQUEST_TIMEOUT'
|
|
34
|
+
| 'PUSH_RESPONSE_TOO_LARGE'
|
|
35
|
+
| 'PUSH_RESPONSE_INVALID'
|
|
36
|
+
| 'PUSH_NETWORK_ERROR';
|
|
37
|
+
|
|
38
|
+
export class WebPushTransportError extends Error {
|
|
39
|
+
public constructor(
|
|
40
|
+
public readonly code: TWebPushTransportErrorCode,
|
|
41
|
+
optionsArg: { cause?: unknown } = {},
|
|
42
|
+
) {
|
|
43
|
+
super(code, optionsArg);
|
|
44
|
+
this.name = 'WebPushTransportError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IWebPushHttpTransportOptions {
|
|
49
|
+
dnsLookup?: TWebPushDnsLookup;
|
|
50
|
+
dnsTimeoutMs?: number;
|
|
51
|
+
connectTimeoutMs?: number;
|
|
52
|
+
requestTimeoutMs?: number;
|
|
53
|
+
maxResponseBytes?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const normalizeRetryAfter = (valueArg: string | string[] | undefined): string | undefined => {
|
|
57
|
+
if (Array.isArray(valueArg)) return valueArg[0]?.slice(0, 128);
|
|
58
|
+
return typeof valueArg === 'string' ? valueArg.slice(0, 128) : undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export class WebPushHttpTransport implements IWebPushTransport {
|
|
62
|
+
private readonly dnsLookup?: TWebPushDnsLookup;
|
|
63
|
+
private readonly dnsTimeoutMs: number;
|
|
64
|
+
private readonly connectTimeoutMs: number;
|
|
65
|
+
private readonly requestTimeoutMs: number;
|
|
66
|
+
private readonly maxResponseBytes: number;
|
|
67
|
+
|
|
68
|
+
public constructor(optionsArg: IWebPushHttpTransportOptions = {}) {
|
|
69
|
+
this.dnsLookup = optionsArg.dnsLookup;
|
|
70
|
+
this.dnsTimeoutMs = this.normalizePositiveInteger(
|
|
71
|
+
optionsArg.dnsTimeoutMs,
|
|
72
|
+
5_000,
|
|
73
|
+
'dnsTimeoutMs',
|
|
74
|
+
);
|
|
75
|
+
this.connectTimeoutMs = this.normalizePositiveInteger(
|
|
76
|
+
optionsArg.connectTimeoutMs,
|
|
77
|
+
10_000,
|
|
78
|
+
'connectTimeoutMs',
|
|
79
|
+
);
|
|
80
|
+
this.requestTimeoutMs = this.normalizePositiveInteger(
|
|
81
|
+
optionsArg.requestTimeoutMs,
|
|
82
|
+
15_000,
|
|
83
|
+
'requestTimeoutMs',
|
|
84
|
+
);
|
|
85
|
+
this.maxResponseBytes = this.normalizePositiveInteger(
|
|
86
|
+
optionsArg.maxResponseBytes,
|
|
87
|
+
16_384,
|
|
88
|
+
'maxResponseBytes',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public async send(requestArg: IWebPushTransportRequest): Promise<IWebPushTransportResponse> {
|
|
93
|
+
const sendDeadline = Date.now() + this.requestTimeoutMs;
|
|
94
|
+
let resolved: Awaited<ReturnType<typeof resolveWebPushEndpoint>>;
|
|
95
|
+
try {
|
|
96
|
+
resolved = await new Promise<Awaited<ReturnType<typeof resolveWebPushEndpoint>>>(
|
|
97
|
+
(resolve, reject) => {
|
|
98
|
+
let settled = false;
|
|
99
|
+
const settle = (
|
|
100
|
+
errorArg: unknown,
|
|
101
|
+
valueArg?: Awaited<ReturnType<typeof resolveWebPushEndpoint>>,
|
|
102
|
+
) => {
|
|
103
|
+
if (settled) return;
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
if (errorArg) reject(errorArg);
|
|
107
|
+
else resolve(valueArg!);
|
|
108
|
+
};
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
settle(new WebPushTransportError('PUSH_DNS_TIMEOUT'));
|
|
111
|
+
}, Math.min(this.dnsTimeoutMs, this.requestTimeoutMs));
|
|
112
|
+
resolveWebPushEndpoint(
|
|
113
|
+
requestArg.subscription.endpoint,
|
|
114
|
+
this.dnsLookup,
|
|
115
|
+
).then(
|
|
116
|
+
(valueArg) => settle(null, valueArg),
|
|
117
|
+
(errorArg) => settle(errorArg),
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
} catch (error: unknown) {
|
|
122
|
+
if (error instanceof WebPushEndpointPolicyError) throw error;
|
|
123
|
+
if (error instanceof WebPushTransportError) throw error;
|
|
124
|
+
throw new WebPushTransportError('PUSH_NETWORK_ERROR', { cause: error });
|
|
125
|
+
}
|
|
126
|
+
const remainingRequestTimeoutMs = sendDeadline - Date.now();
|
|
127
|
+
if (remainingRequestTimeoutMs <= 0) {
|
|
128
|
+
throw new WebPushTransportError('PUSH_REQUEST_TIMEOUT');
|
|
129
|
+
}
|
|
130
|
+
const agent = createPinnedWebPushAgent(resolved);
|
|
131
|
+
try {
|
|
132
|
+
const requestDetails = plugins.webpush.generateRequestDetails(
|
|
133
|
+
requestArg.subscription,
|
|
134
|
+
requestArg.payload,
|
|
135
|
+
{
|
|
136
|
+
TTL: requestArg.ttlSeconds,
|
|
137
|
+
contentEncoding: 'aes128gcm',
|
|
138
|
+
urgency: requestArg.urgency,
|
|
139
|
+
...(requestArg.topic ? { topic: requestArg.topic } : {}),
|
|
140
|
+
vapidDetails: requestArg.vapid,
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
if (
|
|
144
|
+
requestDetails.method !== 'POST'
|
|
145
|
+
|| requestDetails.endpoint !== requestArg.subscription.endpoint
|
|
146
|
+
|| !Buffer.isBuffer(requestDetails.body)
|
|
147
|
+
) {
|
|
148
|
+
throw new WebPushTransportError('PUSH_RESPONSE_INVALID');
|
|
149
|
+
}
|
|
150
|
+
return await new Promise<IWebPushTransportResponse>((resolve, reject) => {
|
|
151
|
+
let settled = false;
|
|
152
|
+
let requestTimer: NodeJS.Timeout | undefined;
|
|
153
|
+
const settle = (
|
|
154
|
+
errorArg: Error | null,
|
|
155
|
+
responseArg?: IWebPushTransportResponse,
|
|
156
|
+
) => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
if (requestTimer) clearTimeout(requestTimer);
|
|
160
|
+
if (errorArg) reject(errorArg);
|
|
161
|
+
else resolve(responseArg!);
|
|
162
|
+
};
|
|
163
|
+
const pushRequest = plugins.https.request({
|
|
164
|
+
protocol: 'https:',
|
|
165
|
+
hostname: resolved.hostname,
|
|
166
|
+
servername: resolved.hostname,
|
|
167
|
+
port: 443,
|
|
168
|
+
path: `${resolved.url.pathname}${resolved.url.search}`,
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: requestDetails.headers,
|
|
171
|
+
agent,
|
|
172
|
+
timeout: Math.min(this.connectTimeoutMs, remainingRequestTimeoutMs),
|
|
173
|
+
maxHeaderSize: 16_384,
|
|
174
|
+
setHost: true,
|
|
175
|
+
}, (pushResponse) => {
|
|
176
|
+
let responseBytes = 0;
|
|
177
|
+
pushResponse.on('data', (chunkArg: Buffer | string) => {
|
|
178
|
+
responseBytes += Buffer.byteLength(chunkArg);
|
|
179
|
+
if (responseBytes > this.maxResponseBytes) {
|
|
180
|
+
const error = new WebPushTransportError('PUSH_RESPONSE_TOO_LARGE');
|
|
181
|
+
settle(error);
|
|
182
|
+
pushResponse.destroy(error);
|
|
183
|
+
pushRequest.destroy(error);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
pushResponse.once('end', () => {
|
|
187
|
+
const statusCode = pushResponse.statusCode;
|
|
188
|
+
if (!Number.isSafeInteger(statusCode) || statusCode! < 100 || statusCode! > 599) {
|
|
189
|
+
settle(new WebPushTransportError('PUSH_RESPONSE_INVALID'));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
settle(null, {
|
|
193
|
+
statusCode: statusCode!,
|
|
194
|
+
retryAfter: normalizeRetryAfter(pushResponse.headers['retry-after']),
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
pushResponse.once('error', (errorArg) => {
|
|
198
|
+
settle(
|
|
199
|
+
errorArg instanceof WebPushTransportError
|
|
200
|
+
? errorArg
|
|
201
|
+
: new WebPushTransportError('PUSH_NETWORK_ERROR', { cause: errorArg }),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
pushRequest.once('timeout', () => {
|
|
206
|
+
const error = new WebPushTransportError('PUSH_REQUEST_TIMEOUT');
|
|
207
|
+
settle(error);
|
|
208
|
+
pushRequest.destroy(error);
|
|
209
|
+
});
|
|
210
|
+
pushRequest.once('error', (errorArg) => {
|
|
211
|
+
settle(
|
|
212
|
+
errorArg instanceof WebPushTransportError
|
|
213
|
+
? errorArg
|
|
214
|
+
: new WebPushTransportError('PUSH_NETWORK_ERROR', { cause: errorArg }),
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
requestTimer = setTimeout(() => {
|
|
218
|
+
const error = new WebPushTransportError('PUSH_REQUEST_TIMEOUT');
|
|
219
|
+
settle(error);
|
|
220
|
+
pushRequest.destroy(error);
|
|
221
|
+
}, remainingRequestTimeoutMs);
|
|
222
|
+
requestTimer.unref();
|
|
223
|
+
pushRequest.end(requestDetails.body);
|
|
224
|
+
});
|
|
225
|
+
} finally {
|
|
226
|
+
agent.destroy();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private normalizePositiveInteger(
|
|
231
|
+
valueArg: number | undefined,
|
|
232
|
+
defaultArg: number,
|
|
233
|
+
labelArg: string,
|
|
234
|
+
): number {
|
|
235
|
+
const value = valueArg ?? defaultArg;
|
|
236
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
237
|
+
throw new Error(`Web Push transport ${labelArg} must be a positive integer`);
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { LookupFunction } from 'node:net';
|
|
2
|
+
import * as plugins from '../plugins.js';
|
|
3
|
+
|
|
4
|
+
export type TWebPushEndpointPolicyErrorCode =
|
|
5
|
+
| 'ENDPOINT_URL_INVALID'
|
|
6
|
+
| 'ENDPOINT_SCHEME_INVALID'
|
|
7
|
+
| 'ENDPOINT_PORT_INVALID'
|
|
8
|
+
| 'ENDPOINT_AUTHORITY_INVALID'
|
|
9
|
+
| 'ENDPOINT_FRAGMENT_INVALID'
|
|
10
|
+
| 'ENDPOINT_IP_LITERAL_FORBIDDEN'
|
|
11
|
+
| 'ENDPOINT_DNS_EMPTY'
|
|
12
|
+
| 'ENDPOINT_DNS_UNSAFE';
|
|
13
|
+
|
|
14
|
+
export class WebPushEndpointPolicyError extends Error {
|
|
15
|
+
public constructor(public readonly code: TWebPushEndpointPolicyErrorCode) {
|
|
16
|
+
super(code);
|
|
17
|
+
this.name = 'WebPushEndpointPolicyError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IWebPushDnsAddress {
|
|
22
|
+
address: string;
|
|
23
|
+
family: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TWebPushDnsLookup = (
|
|
27
|
+
hostnameArg: string,
|
|
28
|
+
) => Promise<IWebPushDnsAddress[]>;
|
|
29
|
+
|
|
30
|
+
export interface IWebPushResolvedEndpoint {
|
|
31
|
+
url: URL;
|
|
32
|
+
hostname: string;
|
|
33
|
+
address: string;
|
|
34
|
+
family: 4 | 6;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultDnsLookup: TWebPushDnsLookup = async (hostnameArg) => {
|
|
38
|
+
return await plugins.dns.promises.lookup(hostnameArg, {
|
|
39
|
+
all: true,
|
|
40
|
+
verbatim: true,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function isGloballyRoutableWebPushAddress(addressArg: string): boolean {
|
|
45
|
+
const family = plugins.net.isIP(addressArg);
|
|
46
|
+
if (family !== 4 && family !== 6) return false;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = plugins.ipaddr.parse(addressArg);
|
|
49
|
+
if (
|
|
50
|
+
parsed.kind() === 'ipv6'
|
|
51
|
+
&& 'isIPv4MappedAddress' in parsed
|
|
52
|
+
&& parsed.isIPv4MappedAddress()
|
|
53
|
+
) return false;
|
|
54
|
+
return parsed.range() === 'unicast';
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseWebPushEndpoint(endpointArg: string): URL {
|
|
61
|
+
if (typeof endpointArg !== 'string' || endpointArg.length === 0 || endpointArg.length > 4096) {
|
|
62
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_URL_INVALID');
|
|
63
|
+
}
|
|
64
|
+
let endpoint: URL;
|
|
65
|
+
try {
|
|
66
|
+
endpoint = new URL(endpointArg);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_URL_INVALID');
|
|
69
|
+
}
|
|
70
|
+
if (endpoint.protocol !== 'https:') {
|
|
71
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_SCHEME_INVALID');
|
|
72
|
+
}
|
|
73
|
+
if (endpoint.port && endpoint.port !== '443') {
|
|
74
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_PORT_INVALID');
|
|
75
|
+
}
|
|
76
|
+
if (endpoint.username || endpoint.password || !endpoint.hostname) {
|
|
77
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_AUTHORITY_INVALID');
|
|
78
|
+
}
|
|
79
|
+
if (endpoint.hash) {
|
|
80
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_FRAGMENT_INVALID');
|
|
81
|
+
}
|
|
82
|
+
const hostname = endpoint.hostname.startsWith('[') && endpoint.hostname.endsWith(']')
|
|
83
|
+
? endpoint.hostname.slice(1, -1)
|
|
84
|
+
: endpoint.hostname;
|
|
85
|
+
if (plugins.net.isIP(hostname) !== 0) {
|
|
86
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_IP_LITERAL_FORBIDDEN');
|
|
87
|
+
}
|
|
88
|
+
return endpoint;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function resolveWebPushEndpoint(
|
|
92
|
+
endpointArg: string,
|
|
93
|
+
lookupArg: TWebPushDnsLookup = defaultDnsLookup,
|
|
94
|
+
): Promise<IWebPushResolvedEndpoint> {
|
|
95
|
+
const url = parseWebPushEndpoint(endpointArg);
|
|
96
|
+
const hostname = url.hostname.toLowerCase();
|
|
97
|
+
const answers = await lookupArg(hostname);
|
|
98
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
99
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_DNS_EMPTY');
|
|
100
|
+
}
|
|
101
|
+
const normalized = answers.map((answerArg) => ({
|
|
102
|
+
address: String(answerArg.address || '').trim(),
|
|
103
|
+
family: plugins.net.isIP(String(answerArg.address || '').trim()),
|
|
104
|
+
}));
|
|
105
|
+
if (
|
|
106
|
+
normalized.some((answerArg) => (
|
|
107
|
+
(answerArg.family !== 4 && answerArg.family !== 6)
|
|
108
|
+
|| !isGloballyRoutableWebPushAddress(answerArg.address)
|
|
109
|
+
))
|
|
110
|
+
) {
|
|
111
|
+
throw new WebPushEndpointPolicyError('ENDPOINT_DNS_UNSAFE');
|
|
112
|
+
}
|
|
113
|
+
const unique = Array.from(
|
|
114
|
+
new Map(normalized.map((answerArg) => [`${answerArg.family}:${answerArg.address}`, answerArg])).values(),
|
|
115
|
+
);
|
|
116
|
+
const selected = unique[0] as { address: string; family: 4 | 6 };
|
|
117
|
+
return {
|
|
118
|
+
url,
|
|
119
|
+
hostname,
|
|
120
|
+
address: selected.address,
|
|
121
|
+
family: selected.family,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createPinnedWebPushAgent(
|
|
126
|
+
resolvedArg: IWebPushResolvedEndpoint,
|
|
127
|
+
): InstanceType<typeof plugins.https.Agent> {
|
|
128
|
+
const lookup: LookupFunction = (hostnameArg, optionsArg, callbackArg) => {
|
|
129
|
+
if (hostnameArg.toLowerCase() !== resolvedArg.hostname) {
|
|
130
|
+
const error = new Error('Pinned Web Push agent hostname mismatch') as NodeJS.ErrnoException;
|
|
131
|
+
error.code = 'EHOSTUNREACH';
|
|
132
|
+
callbackArg(error, '', 0);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (optionsArg.all) {
|
|
136
|
+
callbackArg(null, [{
|
|
137
|
+
address: resolvedArg.address,
|
|
138
|
+
family: resolvedArg.family,
|
|
139
|
+
}]);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
callbackArg(null, resolvedArg.address, resolvedArg.family);
|
|
143
|
+
};
|
|
144
|
+
return new plugins.https.Agent({
|
|
145
|
+
keepAlive: false,
|
|
146
|
+
maxSockets: 1,
|
|
147
|
+
maxFreeSockets: 0,
|
|
148
|
+
lookup,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export type TWebPushIndexCollection =
|
|
2
|
+
| 'WebPushBindingDoc'
|
|
3
|
+
| 'WebPushAdmissionDoc'
|
|
4
|
+
| 'WebPushSpoolDoc';
|
|
5
|
+
|
|
6
|
+
export interface IWebPushIndexSpecification {
|
|
7
|
+
collection: TWebPushIndexCollection;
|
|
8
|
+
name: string;
|
|
9
|
+
key: Record<string, 1 | -1>;
|
|
10
|
+
unique?: true;
|
|
11
|
+
expireAfterSeconds?: number;
|
|
12
|
+
partialFilterExpression?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const webPushIndexSpecifications: IWebPushIndexSpecification[] = [
|
|
16
|
+
{
|
|
17
|
+
collection: 'WebPushBindingDoc',
|
|
18
|
+
name: 'web_push_binding_id_unique',
|
|
19
|
+
key: { id: 1 },
|
|
20
|
+
unique: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
collection: 'WebPushBindingDoc',
|
|
24
|
+
name: 'web_push_binding_owner_unique',
|
|
25
|
+
key: {
|
|
26
|
+
ownerGatewayClientType: 1,
|
|
27
|
+
ownerGatewayClientId: 1,
|
|
28
|
+
ownerAppInstanceId: 1,
|
|
29
|
+
},
|
|
30
|
+
unique: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
collection: 'WebPushBindingDoc',
|
|
34
|
+
name: 'web_push_binding_credential_unique',
|
|
35
|
+
key: { 'credential.id': 1 },
|
|
36
|
+
unique: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
collection: 'WebPushBindingDoc',
|
|
40
|
+
name: 'web_push_binding_previous_credential_lookup',
|
|
41
|
+
key: { 'previousCredentials.id': 1 },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
collection: 'WebPushBindingDoc',
|
|
45
|
+
name: 'web_push_binding_previous_credential_cleanup',
|
|
46
|
+
key: { 'previousCredentials.validUntil': 1 },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
collection: 'WebPushBindingDoc',
|
|
50
|
+
name: 'web_push_binding_rotation_recovery_cleanup',
|
|
51
|
+
key: { 'credentialRotationRecovery.expiresAt': 1 },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
collection: 'WebPushBindingDoc',
|
|
55
|
+
name: 'web_push_binding_vapid_status_cleanup',
|
|
56
|
+
key: { 'vapidKeys.status': 1 },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
collection: 'WebPushBindingDoc',
|
|
60
|
+
name: 'web_push_binding_vapid_retire_after_cleanup',
|
|
61
|
+
key: { 'vapidKeys.retireAfter': 1 },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
collection: 'WebPushBindingDoc',
|
|
65
|
+
name: 'web_push_binding_disabled_cleanup',
|
|
66
|
+
key: { enabled: 1, lifecycleGeneration: 1 },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
collection: 'WebPushBindingDoc',
|
|
70
|
+
name: 'web_push_binding_deleted_cleanup',
|
|
71
|
+
key: { deletedAt: 1, lifecycleGeneration: 1 },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
collection: 'WebPushAdmissionDoc',
|
|
75
|
+
name: 'web_push_admission_binding_unique',
|
|
76
|
+
key: { bindingId: 1, lifecycleGeneration: 1 },
|
|
77
|
+
unique: true,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
collection: 'WebPushAdmissionDoc',
|
|
81
|
+
name: 'web_push_admission_ttl',
|
|
82
|
+
key: { purgeAt: 1 },
|
|
83
|
+
expireAfterSeconds: 0,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
collection: 'WebPushSpoolDoc',
|
|
87
|
+
name: 'web_push_spool_id_unique',
|
|
88
|
+
key: { id: 1 },
|
|
89
|
+
unique: true,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
collection: 'WebPushSpoolDoc',
|
|
93
|
+
name: 'web_push_spool_idempotency_unique',
|
|
94
|
+
key: {
|
|
95
|
+
bindingId: 1,
|
|
96
|
+
lifecycleGeneration: 1,
|
|
97
|
+
credentialId: 1,
|
|
98
|
+
idempotencyKeyDigest: 1,
|
|
99
|
+
},
|
|
100
|
+
unique: true,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
collection: 'WebPushSpoolDoc',
|
|
104
|
+
name: 'web_push_spool_delivery_scan',
|
|
105
|
+
key: { state: 1, nextAttemptAt: 1, leaseExpiresAt: 1 },
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
collection: 'WebPushSpoolDoc',
|
|
109
|
+
name: 'web_push_spool_cancellation_scan',
|
|
110
|
+
key: { bindingId: 1, lifecycleGeneration: 1, subscriptionId: 1, state: 1 },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
collection: 'WebPushSpoolDoc',
|
|
114
|
+
name: 'web_push_spool_cancelled_lease_scan',
|
|
115
|
+
key: { state: 1, cancelRequestedAt: 1, leaseExpiresAt: 1, id: 1 },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
collection: 'WebPushSpoolDoc',
|
|
119
|
+
name: 'web_push_spool_lifecycle_drain_scan',
|
|
120
|
+
key: { bindingId: 1, lifecycleGeneration: 1, state: 1, leaseExpiresAt: 1 },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
collection: 'WebPushSpoolDoc',
|
|
124
|
+
name: 'web_push_spool_vapid_retirement_scan',
|
|
125
|
+
key: { bindingId: 1, lifecycleGeneration: 1, vapidKeyId: 1, state: 1 },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
collection: 'WebPushSpoolDoc',
|
|
129
|
+
name: 'web_push_spool_terminal_ttl',
|
|
130
|
+
key: { purgeAt: 1 },
|
|
131
|
+
expireAfterSeconds: 0,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export function getWebPushIndexSpecifications(
|
|
136
|
+
collectionArg: TWebPushIndexCollection,
|
|
137
|
+
): IWebPushIndexSpecification[] {
|
|
138
|
+
return webPushIndexSpecifications.filter(
|
|
139
|
+
(specificationArg) => specificationArg.collection === collectionArg,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -500,6 +500,8 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
500
500
|
tags.push(mr.origin);
|
|
501
501
|
if (!mr.enabled) tags.push('disabled');
|
|
502
502
|
if (mr.route.vpnOnly) tags.push('vpn-only');
|
|
503
|
+
if (mr.route.ingress?.directHub) tags.push('direct-hub');
|
|
504
|
+
if (mr.route.ingress?.smartVpn && !mr.route.vpnOnly) tags.push('smartvpn');
|
|
503
505
|
|
|
504
506
|
return {
|
|
505
507
|
...mr.route,
|
|
@@ -776,12 +778,18 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
776
778
|
const meta = merged.metadata;
|
|
777
779
|
const isSystemManaged = this.isSystemManagedRoute(merged);
|
|
778
780
|
const sourceBindingSummary = this.describeSourcePolicy(meta);
|
|
781
|
+
const ingressSummary = [
|
|
782
|
+
...(merged.route.ingress?.directHub ? ['Direct hub'] : []),
|
|
783
|
+
...(merged.route.ingress?.smartVpn ? ['SmartVPN'] : []),
|
|
784
|
+
...(merged.route.remoteIngress?.enabled ? ['RemoteIngress'] : []),
|
|
785
|
+
].join(', ') || 'None';
|
|
779
786
|
await DeesModal.createAndShow({
|
|
780
787
|
heading: `Route: ${merged.route.name}`,
|
|
781
788
|
content: html`
|
|
782
789
|
<div style="color: #ccc; padding: 8px 0;">
|
|
783
790
|
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
|
|
784
791
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
|
792
|
+
<p>Ingress: <strong>${ingressSummary}</strong></p>
|
|
785
793
|
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
|
|
786
794
|
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
|
787
795
|
${isSystemManaged ? html`<p>This route is managed by the system, a gateway client, or a specialized workflow.</p>` : ''}
|
|
@@ -908,6 +916,8 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
908
916
|
: '';
|
|
909
917
|
const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
|
|
910
918
|
const currentVpnOnly = route.vpnOnly === true;
|
|
919
|
+
const currentDirectHub = route.ingress?.directHub === true;
|
|
920
|
+
const currentSmartVpn = route.ingress?.smartVpn === true;
|
|
911
921
|
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
|
912
922
|
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
|
|
913
923
|
const sourceProfileOptions = getSourceProfileOptions(profiles);
|
|
@@ -947,6 +957,7 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
947
957
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
|
948
958
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
|
949
959
|
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
|
|
960
|
+
<dees-input-checkbox .key=${'directHub'} .label=${'Listen on Hub directly'} .description=${'Accept direct connections on the gateway hub. Pair private routes with the LOCAL NETWORK source policy.'} .value=${currentDirectHub}></dees-input-checkbox>
|
|
950
961
|
<dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${currentVpnOnly}></dees-input-checkbox>
|
|
951
962
|
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
|
|
952
963
|
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
|
@@ -1002,6 +1013,15 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
1002
1013
|
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
|
1003
1014
|
: [];
|
|
1004
1015
|
const vpnOnly = Boolean(formData.vpnOnly);
|
|
1016
|
+
const directHub = Boolean(formData.directHub);
|
|
1017
|
+
if (vpnOnly && (directHub || remoteIngressEnabled)) {
|
|
1018
|
+
alert('VPN only cannot be combined with direct hub or RemoteIngress.');
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (!directHub && !vpnOnly && !currentSmartVpn && !remoteIngressEnabled) {
|
|
1022
|
+
alert('Enable at least one ingress path.');
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1005
1025
|
|
|
1006
1026
|
const updatedRoute: any = {
|
|
1007
1027
|
name: formData.name,
|
|
@@ -1018,6 +1038,10 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
1018
1038
|
},
|
|
1019
1039
|
],
|
|
1020
1040
|
},
|
|
1041
|
+
ingress: {
|
|
1042
|
+
directHub,
|
|
1043
|
+
smartVpn: vpnOnly || currentSmartVpn,
|
|
1044
|
+
},
|
|
1021
1045
|
vpnOnly: vpnOnly ? true : null,
|
|
1022
1046
|
remoteIngress: remoteIngressEnabled
|
|
1023
1047
|
? {
|
|
@@ -1121,6 +1145,7 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
1121
1145
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
|
1122
1146
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
|
1123
1147
|
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
|
|
1148
|
+
<dees-input-checkbox .key=${'directHub'} .label=${'Listen on Hub directly'} .description=${'Off by default. Pair private routes with the LOCAL NETWORK source policy.'} .value=${false}></dees-input-checkbox>
|
|
1124
1149
|
<dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${false}></dees-input-checkbox>
|
|
1125
1150
|
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
|
|
1126
1151
|
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
|
|
@@ -1176,6 +1201,15 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
1176
1201
|
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
|
1177
1202
|
: [];
|
|
1178
1203
|
const vpnOnly = Boolean(formData.vpnOnly);
|
|
1204
|
+
const directHub = Boolean(formData.directHub);
|
|
1205
|
+
if (vpnOnly && (directHub || remoteIngressEnabled)) {
|
|
1206
|
+
alert('VPN only cannot be combined with direct hub or RemoteIngress.');
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (!directHub && !vpnOnly && !remoteIngressEnabled) {
|
|
1210
|
+
alert('Enable at least one ingress path.');
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1179
1213
|
|
|
1180
1214
|
const route: any = {
|
|
1181
1215
|
name: formData.name,
|
|
@@ -1192,6 +1226,10 @@ export class OpsViewRoutes extends DeesElement {
|
|
|
1192
1226
|
},
|
|
1193
1227
|
],
|
|
1194
1228
|
},
|
|
1229
|
+
ingress: {
|
|
1230
|
+
directHub,
|
|
1231
|
+
smartVpn: vpnOnly,
|
|
1232
|
+
},
|
|
1195
1233
|
...(vpnOnly ? { vpnOnly: true } : {}),
|
|
1196
1234
|
...(remoteIngressEnabled
|
|
1197
1235
|
? {
|
|
@@ -93,7 +93,7 @@ export async function showLetsEncryptHttp01ForwardDialog(options: {
|
|
|
93
93
|
<dees-input-checkbox
|
|
94
94
|
.key=${'remoteIngressEnabled'}
|
|
95
95
|
.label=${'Enable Remote Ingress'}
|
|
96
|
-
.value=${existing
|
|
96
|
+
.value=${existing ? existing.route.remoteIngress?.enabled === true : true}
|
|
97
97
|
></dees-input-checkbox>
|
|
98
98
|
<dees-input-list
|
|
99
99
|
.key=${'remoteIngressEdgeFilter'}
|