@serve.zone/dcrouter 17.2.4 → 17.4.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 +2024 -1758
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +11 -1
- package/dist_ts/classes.dcrouter.js +63 -3
- package/dist_ts/config/classes.api-token-manager.d.ts +20 -2
- package/dist_ts/config/classes.api-token-manager.js +201 -6
- package/dist_ts/config/classes.gateway-client-manager.d.ts +36 -2
- package/dist_ts/config/classes.gateway-client-manager.js +391 -27
- package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
- package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
- package/dist_ts/db/documents/classes.cached.email.d.ts +10 -1
- package/dist_ts/db/documents/classes.cached.email.js +27 -2
- package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +25 -0
- package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
- package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
- package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
- package/dist_ts/db/documents/index.d.ts +1 -0
- package/dist_ts/db/documents/index.js +2 -1
- package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
- package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
- package/dist_ts/dns/index.d.ts +1 -0
- package/dist_ts/dns/index.js +2 -1
- package/dist_ts/dns/manager.dns.d.ts +19 -0
- package/dist_ts/dns/manager.dns.js +25 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +8 -1
- package/dist_ts/email/classes.accepted-email-spool.js +93 -1
- package/dist_ts/email/classes.smartmta-storage-manager.d.ts +7 -1
- package/dist_ts/email/classes.smartmta-storage-manager.js +46 -1
- package/dist_ts/email/smartmta-storage-policy.js +3 -1
- package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
- package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
- package/dist_ts/monitoring/index.d.ts +1 -0
- package/dist_ts/monitoring/index.js +2 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
- package/dist_ts/opsserver/classes.opsserver.js +3 -1
- package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
- package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
- package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
- package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
- package/dist_ts/opsserver/handlers/email-ops.handler.js +40 -9
- package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
- package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
- package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
- package/dist_ts_interfaces/data/config-events.d.ts +34 -0
- package/dist_ts_interfaces/data/config-events.js +8 -0
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/route-management.d.ts +28 -1
- package/dist_ts_interfaces/data/route-management.js +3 -1
- package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
- package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
- package/dist_ts_interfaces/requests/config-events.js +4 -0
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
- package/dist_ts_interfaces/requests/realtime.js +2 -1
- package/dist_ts_migrations/index.js +148 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/config-events.d.ts +19 -0
- package/dist_ts_web/appstate/config-events.js +68 -0
- package/dist_ts_web/appstate/runtime.js +7 -1
- package/dist_ts_web/appstate.d.ts +1 -0
- package/dist_ts_web/appstate.js +2 -1
- package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
- package/dist_ts_web/elements/ops-platform-events.js +361 -0
- package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
- package/dist_ts_web/elements/ops-view-logs.js +96 -9
- package/package.json +4 -4
- package/readme.plan.md +22 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +72 -2
- package/ts/config/classes.api-token-manager.ts +237 -4
- package/ts/config/classes.gateway-client-manager.ts +463 -19
- package/ts/db/documents/classes.api-token.doc.ts +8 -1
- package/ts/db/documents/classes.cached.email.ts +21 -1
- package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
- package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
- package/ts/db/documents/index.ts +1 -0
- package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
- package/ts/dns/index.ts +1 -0
- package/ts/dns/manager.dns.ts +36 -0
- package/ts/email/classes.accepted-email-spool.ts +111 -0
- package/ts/email/classes.smartmta-storage-manager.ts +57 -1
- package/ts/email/smartmta-storage-policy.ts +2 -0
- package/ts/monitoring/classes.ops-event-manager.ts +99 -0
- package/ts/monitoring/index.ts +2 -1
- package/ts/opsserver/classes.opsserver.ts +2 -0
- package/ts/opsserver/handlers/certificate.handler.ts +51 -4
- package/ts/opsserver/handlers/config-event.handler.ts +62 -0
- package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
- package/ts/opsserver/handlers/email-ops.handler.ts +42 -8
- package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
- package/ts/opsserver/handlers/index.ts +1 -0
- package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
- package/ts/plugins.ts +2 -0
- package/ts_apiclient/classes.gatewayclient.ts +45 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/config-events.ts +98 -0
- package/ts_web/appstate/runtime.ts +8 -1
- package/ts_web/appstate.ts +1 -0
- package/ts_web/elements/ops-platform-events.ts +315 -0
- package/ts_web/elements/ops-view-logs.ts +84 -7
|
@@ -2,12 +2,15 @@ import * as plugins from '../../plugins.js';
|
|
|
2
2
|
import type { OpsServer } from '../classes.opsserver.js';
|
|
3
3
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
4
4
|
import { requireOpsAuth } from '../helpers/auth.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import { CachedEmail } from '../../db/index.js';
|
|
6
|
+
import {
|
|
7
|
+
type IGatewayMachineAuthContext as TAuthContext,
|
|
8
|
+
getGatewayCredentialState,
|
|
9
|
+
matchesGatewayHostnamePatterns,
|
|
10
|
+
normalizeGatewayHostname,
|
|
11
|
+
normalizeGatewayTargetHost,
|
|
12
|
+
requireGatewayMachineAuth,
|
|
13
|
+
} from '../helpers/gateway-client-auth.js';
|
|
11
14
|
|
|
12
15
|
type TResolvedGatewayClientOwnership = Required<Pick<plugins.servezoneInterfaces.data.IGatewayClientOwnership,
|
|
13
16
|
'gatewayClientType' | 'gatewayClientId' | 'appId'
|
|
@@ -18,6 +21,8 @@ type TResolvedGatewayClientOwnership = Required<Pick<plugins.servezoneInterfaces
|
|
|
18
21
|
|
|
19
22
|
export class GatewayClientHandler {
|
|
20
23
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
24
|
+
private routeSyncChains = new Map<string, Promise<void>>();
|
|
25
|
+
private gatewayClientCredentialChains = new Map<string, Promise<void>>();
|
|
21
26
|
|
|
22
27
|
constructor(private opsServerRef: OpsServer) {
|
|
23
28
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
@@ -27,12 +32,14 @@ export class GatewayClientHandler {
|
|
|
27
32
|
private async requireAuth(
|
|
28
33
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
|
29
34
|
requiredScope?: interfaces.data.TApiTokenScope,
|
|
35
|
+
options: { allowCandidate?: boolean } = {},
|
|
30
36
|
): Promise<TAuthContext> {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
return await requireGatewayMachineAuth(
|
|
38
|
+
this.opsServerRef,
|
|
39
|
+
request,
|
|
40
|
+
requiredScope === 'gateway-clients:write' ? 'gateway-clients:write' : 'gateway-clients:read',
|
|
41
|
+
options,
|
|
42
|
+
);
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
private async requireAdmin(
|
|
@@ -58,11 +65,150 @@ export class GatewayClientHandler {
|
|
|
58
65
|
),
|
|
59
66
|
);
|
|
60
67
|
|
|
68
|
+
this.typedrouter.addTypedHandler(
|
|
69
|
+
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_ProvisionGatewayClientCredential>(
|
|
70
|
+
'provisionGatewayClientCredential',
|
|
71
|
+
async (dataArg) => {
|
|
72
|
+
try {
|
|
73
|
+
const userId = await this.requireAdmin(dataArg, 'tokens:manage');
|
|
74
|
+
const clientManager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
75
|
+
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
76
|
+
if (!clientManager || !tokenManager) {
|
|
77
|
+
return { success: false, message: 'Gateway client or token management not initialized' };
|
|
78
|
+
}
|
|
79
|
+
const lockKey = this.normalizeGatewayClientLockKey(dataArg.provisioning.id);
|
|
80
|
+
return await this.withGatewayClientCredentialLock(lockKey, async () => {
|
|
81
|
+
if (dataArg.provisioning.enabled === false) {
|
|
82
|
+
return { success: false, message: 'Cannot provision a credential for a disabled gateway client' };
|
|
83
|
+
}
|
|
84
|
+
const provisioned = await clientManager.stageClientProvisioning(
|
|
85
|
+
dataArg.provisioning,
|
|
86
|
+
userId,
|
|
87
|
+
);
|
|
88
|
+
if (!provisioned.gatewayClient.enabled) {
|
|
89
|
+
return { success: false, message: 'Cannot provision a credential for a disabled gateway client' };
|
|
90
|
+
}
|
|
91
|
+
const policy = this.buildGatewayClientTokenPolicy(provisioned.gatewayClient);
|
|
92
|
+
const issuedAt = Date.now();
|
|
93
|
+
const credential = await tokenManager.createProvisionedGatewayCredentialCandidate(
|
|
94
|
+
dataArg.credentialName?.trim() || `${provisioned.gatewayClient.name} Credential`,
|
|
95
|
+
['gateway-clients:read', 'gateway-clients:write'],
|
|
96
|
+
dataArg.expiresInDays ?? null,
|
|
97
|
+
userId,
|
|
98
|
+
policy,
|
|
99
|
+
provisioned.credentialSequence,
|
|
100
|
+
provisioned.policyDigest,
|
|
101
|
+
);
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
action: provisioned.action,
|
|
105
|
+
gatewayClient: provisioned.gatewayClient,
|
|
106
|
+
credential: {
|
|
107
|
+
tokenId: credential.id,
|
|
108
|
+
tokenValue: credential.rawToken,
|
|
109
|
+
issuedAt,
|
|
110
|
+
state: 'candidate' as const,
|
|
111
|
+
finalizationRequired: true as const,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { success: false, message: (error as Error).message };
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
this.typedrouter.addTypedHandler(
|
|
123
|
+
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_FinalizeGatewayClientCredential>(
|
|
124
|
+
'finalizeGatewayClientCredential',
|
|
125
|
+
async (dataArg) => {
|
|
126
|
+
try {
|
|
127
|
+
if ('identity' in dataArg) {
|
|
128
|
+
return { success: false, message: 'Identity authentication cannot finalize gateway credentials' };
|
|
129
|
+
}
|
|
130
|
+
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
131
|
+
const clientManager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
132
|
+
if (!tokenManager || !clientManager) {
|
|
133
|
+
return { success: false, message: 'Gateway client or token management not initialized' };
|
|
134
|
+
}
|
|
135
|
+
const initialToken = await tokenManager.validateToken(dataArg.apiToken);
|
|
136
|
+
if (!initialToken) {
|
|
137
|
+
return { success: false, message: 'Candidate gateway credential is invalid or revoked' };
|
|
138
|
+
}
|
|
139
|
+
const gatewayClientId = this.requireFinalizableGatewayCredential(initialToken, dataArg.tokenId);
|
|
140
|
+
return await this.withGatewayClientCredentialLock(gatewayClientId, async () => {
|
|
141
|
+
const lockedToken = await tokenManager.validateToken(dataArg.apiToken);
|
|
142
|
+
if (!lockedToken) {
|
|
143
|
+
return { success: false, message: 'Candidate gateway credential is invalid or revoked' };
|
|
144
|
+
}
|
|
145
|
+
const lockedGatewayClientId = this.requireFinalizableGatewayCredential(
|
|
146
|
+
lockedToken,
|
|
147
|
+
dataArg.tokenId,
|
|
148
|
+
);
|
|
149
|
+
if (lockedGatewayClientId !== gatewayClientId) {
|
|
150
|
+
return { success: false, message: 'Gateway credential ownership changed during finalization' };
|
|
151
|
+
}
|
|
152
|
+
const lifecycle = lockedToken.gatewayCredentialLifecycle!;
|
|
153
|
+
if (lifecycle.cleanupCompletedAt !== null) {
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
gatewayClientId,
|
|
157
|
+
tokenId: dataArg.tokenId,
|
|
158
|
+
revokedCredentialCount: lifecycle.revokedCredentialCount!,
|
|
159
|
+
finalizedAt: lifecycle.finalizedAt!,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const tokenBinding = lockedToken.policy!.gatewayClient!;
|
|
163
|
+
if (lifecycle.state === 'candidate') {
|
|
164
|
+
const stagedClient = await clientManager.getClientForProvisionedCredential(
|
|
165
|
+
gatewayClientId,
|
|
166
|
+
tokenBinding.type,
|
|
167
|
+
lifecycle,
|
|
168
|
+
);
|
|
169
|
+
if (!stagedClient) {
|
|
170
|
+
return { success: false, message: 'Gateway credential policy was superseded before finalization' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const activation = await tokenManager.activateProvisionedGatewayCredential(
|
|
174
|
+
dataArg.tokenId,
|
|
175
|
+
gatewayClientId,
|
|
176
|
+
);
|
|
177
|
+
await clientManager.promoteProvisionedPolicy(
|
|
178
|
+
gatewayClientId,
|
|
179
|
+
tokenBinding.type,
|
|
180
|
+
activation.lifecycle,
|
|
181
|
+
);
|
|
182
|
+
const revokedCredentialCount = await tokenManager.revokeOlderProvisionedGatewayCredentials(
|
|
183
|
+
gatewayClientId,
|
|
184
|
+
activation.lifecycle.sequence,
|
|
185
|
+
dataArg.tokenId,
|
|
186
|
+
);
|
|
187
|
+
const completed = await tokenManager.completeProvisionedGatewayCredentialHandover(
|
|
188
|
+
dataArg.tokenId,
|
|
189
|
+
gatewayClientId,
|
|
190
|
+
revokedCredentialCount,
|
|
191
|
+
);
|
|
192
|
+
return {
|
|
193
|
+
success: true,
|
|
194
|
+
gatewayClientId,
|
|
195
|
+
tokenId: dataArg.tokenId,
|
|
196
|
+
revokedCredentialCount: completed.revokedCredentialCount!,
|
|
197
|
+
finalizedAt: completed.finalizedAt!,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { success: false, message: (error as Error).message };
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
|
|
61
207
|
this.typedrouter.addTypedHandler(
|
|
62
208
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientContext>(
|
|
63
209
|
'getGatewayClientContext',
|
|
64
210
|
async (dataArg) => {
|
|
65
|
-
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
|
211
|
+
const auth = await this.requireAuth(dataArg, 'gateway-clients:read', { allowCandidate: true });
|
|
66
212
|
return {
|
|
67
213
|
context: this.getGatewayClientContext(auth),
|
|
68
214
|
capabilities: this.getGatewayCapabilities(),
|
|
@@ -89,17 +235,22 @@ export class GatewayClientHandler {
|
|
|
89
235
|
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
90
236
|
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
|
|
91
237
|
try {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
238
|
+
const create = async () => {
|
|
239
|
+
const gatewayClient = await manager.createClient({
|
|
240
|
+
id: dataArg.id,
|
|
241
|
+
type: dataArg.type,
|
|
242
|
+
name: dataArg.name,
|
|
243
|
+
description: dataArg.description,
|
|
244
|
+
hostnamePatterns: dataArg.hostnamePatterns,
|
|
245
|
+
allowedRouteTargets: dataArg.allowedRouteTargets,
|
|
246
|
+
capabilities: dataArg.capabilities,
|
|
247
|
+
createdBy: userId,
|
|
248
|
+
});
|
|
249
|
+
return { success: true, gatewayClient };
|
|
250
|
+
};
|
|
251
|
+
return dataArg.id
|
|
252
|
+
? await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), create)
|
|
253
|
+
: await create();
|
|
103
254
|
} catch (error) {
|
|
104
255
|
return { success: false, message: (error as Error).message };
|
|
105
256
|
}
|
|
@@ -114,17 +265,23 @@ export class GatewayClientHandler {
|
|
|
114
265
|
await this.requireAdmin(dataArg);
|
|
115
266
|
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
116
267
|
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
268
|
+
try {
|
|
269
|
+
return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), async () => {
|
|
270
|
+
const gatewayClient = await manager.updateClient(dataArg.id, {
|
|
271
|
+
name: dataArg.name,
|
|
272
|
+
description: dataArg.description,
|
|
273
|
+
hostnamePatterns: dataArg.hostnamePatterns,
|
|
274
|
+
allowedRouteTargets: dataArg.allowedRouteTargets,
|
|
275
|
+
capabilities: dataArg.capabilities,
|
|
276
|
+
enabled: dataArg.enabled,
|
|
277
|
+
});
|
|
278
|
+
return gatewayClient
|
|
279
|
+
? { success: true, gatewayClient }
|
|
280
|
+
: { success: false, message: 'Gateway client not found' };
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return { success: false, message: (error as Error).message };
|
|
284
|
+
}
|
|
128
285
|
},
|
|
129
286
|
),
|
|
130
287
|
);
|
|
@@ -135,9 +292,24 @@ export class GatewayClientHandler {
|
|
|
135
292
|
async (dataArg) => {
|
|
136
293
|
await this.requireAdmin(dataArg);
|
|
137
294
|
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
295
|
+
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
296
|
+
if (!manager || !tokenManager) return { success: false, message: 'Gateway client or token management not initialized' };
|
|
297
|
+
try {
|
|
298
|
+
return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), async () => {
|
|
299
|
+
const existing = await manager.getClient(dataArg.id);
|
|
300
|
+
if (!existing) return { success: false, message: 'Gateway client not found' };
|
|
301
|
+
await tokenManager.revokeGatewayClientCredentials(dataArg.id);
|
|
302
|
+
const success = await manager.deleteClient(dataArg.id);
|
|
303
|
+
return {
|
|
304
|
+
success,
|
|
305
|
+
message: success
|
|
306
|
+
? undefined
|
|
307
|
+
: 'Gateway client changed during credential cleanup; retry deletion',
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { success: false, message: (error as Error).message };
|
|
312
|
+
}
|
|
141
313
|
},
|
|
142
314
|
),
|
|
143
315
|
);
|
|
@@ -147,29 +319,34 @@ export class GatewayClientHandler {
|
|
|
147
319
|
'createGatewayClientToken',
|
|
148
320
|
async (dataArg) => {
|
|
149
321
|
const userId = await this.requireAdmin(dataArg, 'tokens:manage');
|
|
150
|
-
const
|
|
322
|
+
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
151
323
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
152
|
-
if (!
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
324
|
+
if (!manager || !tokenManager) return { success: false, message: 'Gateway client or token management not initialized' };
|
|
325
|
+
return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.gatewayClientId), async () => {
|
|
326
|
+
const gatewayClient = await manager.getClient(dataArg.gatewayClientId);
|
|
327
|
+
if (!gatewayClient || !gatewayClient.enabled) {
|
|
328
|
+
return { success: false, message: 'Gateway client not found or disabled' };
|
|
329
|
+
}
|
|
330
|
+
const result = await tokenManager.createToken(
|
|
331
|
+
dataArg.name?.trim() || `${gatewayClient.name} Token`,
|
|
332
|
+
['gateway-clients:read', 'gateway-clients:write'],
|
|
333
|
+
dataArg.expiresInDays ?? null,
|
|
334
|
+
userId,
|
|
335
|
+
this.buildGatewayClientTokenPolicy(gatewayClient),
|
|
336
|
+
);
|
|
337
|
+
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
this.typedrouter.addTypedHandler(
|
|
344
|
+
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientMailOverview>(
|
|
345
|
+
'getGatewayClientMailOverview',
|
|
346
|
+
async (dataArg) => {
|
|
347
|
+
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
|
348
|
+
this.assertCapability(auth, 'readMail');
|
|
349
|
+
return await this.getGatewayClientMailOverview(auth, dataArg.limit);
|
|
173
350
|
},
|
|
174
351
|
),
|
|
175
352
|
);
|
|
@@ -201,7 +378,7 @@ export class GatewayClientHandler {
|
|
|
201
378
|
'getGatewayClientRoutes',
|
|
202
379
|
async (dataArg) => {
|
|
203
380
|
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
|
204
|
-
this.assertCapability(auth, '
|
|
381
|
+
this.assertCapability(auth, 'readRoutes');
|
|
205
382
|
return { routes: this.listGatewayClientRoutes(auth, dataArg.gatewayClientId) };
|
|
206
383
|
},
|
|
207
384
|
),
|
|
@@ -214,7 +391,7 @@ export class GatewayClientHandler {
|
|
|
214
391
|
async (dataArg) => {
|
|
215
392
|
const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
|
|
216
393
|
this.assertCapability(auth, 'syncRoutes');
|
|
217
|
-
return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete, dataArg.sourceProfileRef);
|
|
394
|
+
return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete, dataArg.sourceProfileRef, dataArg.dnsMode);
|
|
218
395
|
},
|
|
219
396
|
),
|
|
220
397
|
);
|
|
@@ -227,6 +404,7 @@ export class GatewayClientHandler {
|
|
|
227
404
|
'listMailAddressBindings',
|
|
228
405
|
async (dataArg) => {
|
|
229
406
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
|
|
407
|
+
this.assertCapability(auth, 'readMail');
|
|
230
408
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
231
409
|
if (!manager) return { bindings: [] };
|
|
232
410
|
const bindings = await manager.listMailAddressBindings({
|
|
@@ -246,7 +424,7 @@ export class GatewayClientHandler {
|
|
|
246
424
|
'syncMailAddressBinding',
|
|
247
425
|
async (dataArg) => {
|
|
248
426
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
|
|
249
|
-
this.assertCapability(auth, '
|
|
427
|
+
this.assertCapability(auth, 'manageMail');
|
|
250
428
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
251
429
|
if (!manager) {
|
|
252
430
|
return { success: false, message: 'WorkApp mail manager not initialized' };
|
|
@@ -271,12 +449,12 @@ export class GatewayClientHandler {
|
|
|
271
449
|
'deleteMailAddressBinding',
|
|
272
450
|
async (dataArg) => {
|
|
273
451
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
|
|
274
|
-
this.assertCapability(auth, '
|
|
452
|
+
this.assertCapability(auth, 'manageMail');
|
|
275
453
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
276
454
|
if (!manager) {
|
|
277
455
|
return { success: false, message: 'WorkApp mail manager not initialized' };
|
|
278
456
|
}
|
|
279
|
-
if (auth.
|
|
457
|
+
if (auth.policy?.role === 'gatewayClient') {
|
|
280
458
|
const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({
|
|
281
459
|
owner: this.resolveMailOwnerFilter(auth),
|
|
282
460
|
}));
|
|
@@ -294,6 +472,7 @@ export class GatewayClientHandler {
|
|
|
294
472
|
'listWorkAppMailBindings',
|
|
295
473
|
async (dataArg) => {
|
|
296
474
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
|
|
475
|
+
this.assertCapability(auth, 'readMail');
|
|
297
476
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
298
477
|
if (!manager) return { bindings: [] };
|
|
299
478
|
const owner = this.resolveMailOwnerFilter(auth, dataArg.owner);
|
|
@@ -308,13 +487,13 @@ export class GatewayClientHandler {
|
|
|
308
487
|
'rotateMailCredential',
|
|
309
488
|
async (dataArg) => {
|
|
310
489
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
|
|
311
|
-
this.assertCapability(auth, '
|
|
490
|
+
this.assertCapability(auth, 'manageMail');
|
|
312
491
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
313
492
|
if (!manager) {
|
|
314
493
|
return { success: false, message: 'WorkApp mail manager not initialized' };
|
|
315
494
|
}
|
|
316
495
|
const owner = this.resolveMailOwnerFilter(auth);
|
|
317
|
-
if (auth.
|
|
496
|
+
if (auth.policy?.role === 'gatewayClient') {
|
|
318
497
|
const credentialId = dataArg.credentialId?.trim();
|
|
319
498
|
const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({ owner }));
|
|
320
499
|
const binding = bindings.find((bindingArg) => bindingArg.outboundIdentityId === credentialId
|
|
@@ -369,6 +548,7 @@ export class GatewayClientHandler {
|
|
|
369
548
|
}
|
|
370
549
|
|
|
371
550
|
const auth = await this.requireAuth(authData, 'gateway-clients:read');
|
|
551
|
+
this.assertCapability(auth, 'readMail');
|
|
372
552
|
const status = await manager.getMailDeliveryStatus(dataArg.spoolItemId);
|
|
373
553
|
if (!this.isMailDeliveryStatusAllowed(auth, status)) {
|
|
374
554
|
return { attempts: [] };
|
|
@@ -433,18 +613,118 @@ export class GatewayClientHandler {
|
|
|
433
613
|
}
|
|
434
614
|
|
|
435
615
|
private getGatewayClientContext(auth: TAuthContext): plugins.servezoneInterfaces.data.IGatewayClientContext {
|
|
436
|
-
const policy = auth.
|
|
437
|
-
|
|
616
|
+
const policy = auth.policy;
|
|
617
|
+
if (auth.gatewayClient && auth.credentialId) {
|
|
618
|
+
return {
|
|
619
|
+
role: 'gatewayClient',
|
|
620
|
+
credentialState: getGatewayCredentialState(auth.token!),
|
|
621
|
+
credentialId: auth.credentialId,
|
|
622
|
+
scopes: auth.token?.scopes || [],
|
|
623
|
+
gatewayClient: {
|
|
624
|
+
type: auth.gatewayClient.type,
|
|
625
|
+
id: auth.gatewayClient.id,
|
|
626
|
+
policyGeneration: auth.gatewayClient.policyGeneration,
|
|
627
|
+
},
|
|
628
|
+
hostnamePatterns: policy?.hostnamePatterns || [],
|
|
629
|
+
allowedRouteTargets: policy?.allowedRouteTargets || [],
|
|
630
|
+
capabilities: policy?.capabilities || {},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
438
633
|
return {
|
|
439
|
-
role,
|
|
634
|
+
role: 'admin',
|
|
440
635
|
scopes: auth.token?.scopes || ['*'],
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
capabilities: policy?.capabilities || {},
|
|
636
|
+
hostnamePatterns: [],
|
|
637
|
+
allowedRouteTargets: [],
|
|
638
|
+
capabilities: {},
|
|
445
639
|
};
|
|
446
640
|
}
|
|
447
641
|
|
|
642
|
+
private buildGatewayClientTokenPolicy(
|
|
643
|
+
gatewayClientArg: interfaces.data.IGatewayClient,
|
|
644
|
+
): interfaces.data.IApiTokenPolicy {
|
|
645
|
+
return {
|
|
646
|
+
role: 'gatewayClient',
|
|
647
|
+
scopes: ['gateway-clients:read', 'gateway-clients:write'],
|
|
648
|
+
gatewayClient: {
|
|
649
|
+
type: gatewayClientArg.type,
|
|
650
|
+
id: gatewayClientArg.id,
|
|
651
|
+
policyGeneration: gatewayClientArg.policyGeneration,
|
|
652
|
+
},
|
|
653
|
+
hostnamePatterns: gatewayClientArg.hostnamePatterns,
|
|
654
|
+
allowedRouteTargets: gatewayClientArg.allowedRouteTargets,
|
|
655
|
+
capabilities: gatewayClientArg.capabilities,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private requireFinalizableGatewayCredential(
|
|
660
|
+
tokenArg: interfaces.data.IStoredApiToken,
|
|
661
|
+
expectedTokenIdArg: string,
|
|
662
|
+
): string {
|
|
663
|
+
if (tokenArg.id !== expectedTokenIdArg) {
|
|
664
|
+
throw new Error('tokenId does not match the authenticating gateway credential');
|
|
665
|
+
}
|
|
666
|
+
if (tokenArg.policy?.role !== 'gatewayClient' || !tokenArg.policy.gatewayClient?.id) {
|
|
667
|
+
throw new Error('A bound gateway-client credential is required for finalization');
|
|
668
|
+
}
|
|
669
|
+
const lifecycle = tokenArg.gatewayCredentialLifecycle;
|
|
670
|
+
const validCandidate = lifecycle?.source === 'provisioned'
|
|
671
|
+
&& lifecycle.state === 'candidate'
|
|
672
|
+
&& lifecycle.finalizedAt === null;
|
|
673
|
+
const validActive = lifecycle?.source === 'provisioned'
|
|
674
|
+
&& lifecycle.state === 'active'
|
|
675
|
+
&& Number.isSafeInteger(lifecycle.finalizedAt)
|
|
676
|
+
&& Number(lifecycle.finalizedAt) > 0;
|
|
677
|
+
if (!validCandidate && !validActive) {
|
|
678
|
+
throw new Error('Only provisioned candidate or active gateway credentials can be finalized');
|
|
679
|
+
}
|
|
680
|
+
return this.normalizeGatewayClientLockKey(tokenArg.policy.gatewayClient.id);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private async getGatewayClientMailOverview(
|
|
684
|
+
auth: TAuthContext,
|
|
685
|
+
limitArg?: number,
|
|
686
|
+
): Promise<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientMailOverview['response']> {
|
|
687
|
+
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
688
|
+
if (!manager) return { domains: [], emails: [] };
|
|
689
|
+
const owner = this.resolveMailOwnerFilter(auth);
|
|
690
|
+
const bindings = this.filterMailBindingsAllowed(
|
|
691
|
+
auth,
|
|
692
|
+
await manager.listMailAddressBindings({ owner }),
|
|
693
|
+
);
|
|
694
|
+
const ownedAddresses = new Set(bindings.map((bindingArg) => bindingArg.address.toLowerCase()));
|
|
695
|
+
const ownedDomains = Array.from(new Set(bindings.map((bindingArg) => bindingArg.domain)))
|
|
696
|
+
.sort((left, right) => left.localeCompare(right));
|
|
697
|
+
const domains = await Promise.all(ownedDomains.map(async (domainArg) => {
|
|
698
|
+
const configured = await this.opsServerRef.dcRouterRef.emailDomainManager?.getByDomain(domainArg);
|
|
699
|
+
return {
|
|
700
|
+
domain: domainArg,
|
|
701
|
+
dnsMode: configured ? 'managed' : undefined,
|
|
702
|
+
status: configured?.reconciliation?.lifecycleStatus,
|
|
703
|
+
};
|
|
704
|
+
}));
|
|
705
|
+
const limit = Math.min(Math.max(Math.floor(limitArg ?? 100), 1), 500);
|
|
706
|
+
const recent = this.opsServerRef.dcRouterRef.dcRouterDb?.isReady()
|
|
707
|
+
? await CachedEmail.findRecent(Math.max(limit * 5, 100))
|
|
708
|
+
: [];
|
|
709
|
+
const emails = recent
|
|
710
|
+
.filter((emailArg) => {
|
|
711
|
+
const from = String(emailArg.from || '').trim().toLowerCase();
|
|
712
|
+
const recipients = (emailArg.to || []).map((addressArg) => addressArg.trim().toLowerCase());
|
|
713
|
+
return ownedAddresses.has(from) || recipients.some((addressArg) => ownedAddresses.has(addressArg));
|
|
714
|
+
})
|
|
715
|
+
.slice(0, limit)
|
|
716
|
+
.map((emailArg) => ({
|
|
717
|
+
id: emailArg.id,
|
|
718
|
+
direction: emailArg.direction,
|
|
719
|
+
from: emailArg.from,
|
|
720
|
+
to: emailArg.to?.[0],
|
|
721
|
+
subject: emailArg.subject,
|
|
722
|
+
status: emailArg.status,
|
|
723
|
+
timestamp: emailArg.acceptedAt,
|
|
724
|
+
}));
|
|
725
|
+
return { domains, emails };
|
|
726
|
+
}
|
|
727
|
+
|
|
448
728
|
private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
|
|
449
729
|
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
|
|
450
730
|
if (!manager) return [];
|
|
@@ -462,8 +742,10 @@ export class GatewayClientHandler {
|
|
|
462
742
|
capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
|
|
463
743
|
): void {
|
|
464
744
|
if (auth.isAdmin) return;
|
|
465
|
-
const policy = auth.
|
|
466
|
-
if (!policy || policy.role !== 'gatewayClient')
|
|
745
|
+
const policy = auth.policy;
|
|
746
|
+
if (!policy || policy.role !== 'gatewayClient') {
|
|
747
|
+
throw new plugins.typedrequest.TypedResponseError('gateway-client credential required');
|
|
748
|
+
}
|
|
467
749
|
if (policy.capabilities?.[capability] === true) return;
|
|
468
750
|
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`);
|
|
469
751
|
}
|
|
@@ -472,7 +754,7 @@ export class GatewayClientHandler {
|
|
|
472
754
|
auth: TAuthContext,
|
|
473
755
|
requestedId?: string,
|
|
474
756
|
): { gatewayClientId?: string; gatewayClientType?: string } {
|
|
475
|
-
const policy = auth.
|
|
757
|
+
const policy = auth.policy;
|
|
476
758
|
if (policy?.role !== 'gatewayClient') return { gatewayClientId: requestedId };
|
|
477
759
|
const policyClient = policy.gatewayClient;
|
|
478
760
|
if (!policyClient) {
|
|
@@ -491,7 +773,7 @@ export class GatewayClientHandler {
|
|
|
491
773
|
auth: TAuthContext,
|
|
492
774
|
ownership: plugins.servezoneInterfaces.data.IGatewayClientOwnership,
|
|
493
775
|
): TResolvedGatewayClientOwnership {
|
|
494
|
-
const policy = auth.
|
|
776
|
+
const policy = auth.policy;
|
|
495
777
|
if (!ownership.appId?.trim()) {
|
|
496
778
|
throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing appId');
|
|
497
779
|
}
|
|
@@ -512,7 +794,7 @@ export class GatewayClientHandler {
|
|
|
512
794
|
gatewayClientType: policy.gatewayClient.type,
|
|
513
795
|
gatewayClientId: policy.gatewayClient.id,
|
|
514
796
|
appId: ownership.appId,
|
|
515
|
-
hostname: ownership.hostname,
|
|
797
|
+
hostname: ownership.hostname ? normalizeGatewayHostname(ownership.hostname) : undefined,
|
|
516
798
|
routeRef: ownership.routeRef,
|
|
517
799
|
};
|
|
518
800
|
}
|
|
@@ -527,7 +809,7 @@ export class GatewayClientHandler {
|
|
|
527
809
|
auth: TAuthContext,
|
|
528
810
|
owner?: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner>,
|
|
529
811
|
): Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined {
|
|
530
|
-
const policy = auth.
|
|
812
|
+
const policy = auth.policy;
|
|
531
813
|
if (policy?.role !== 'gatewayClient') return owner;
|
|
532
814
|
if (!policy.gatewayClient) {
|
|
533
815
|
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
|
|
@@ -557,27 +839,34 @@ export class GatewayClientHandler {
|
|
|
557
839
|
}
|
|
558
840
|
|
|
559
841
|
private assertGatewayClientOwnership(auth: TAuthContext, ownership: TResolvedGatewayClientOwnership): void {
|
|
560
|
-
const policy = auth.
|
|
842
|
+
const policy = auth.policy;
|
|
561
843
|
if (!policy || policy.role !== 'gatewayClient') return;
|
|
562
844
|
if (!ownership.hostname) return;
|
|
563
|
-
if (!
|
|
845
|
+
if (!matchesGatewayHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [], auth.managedZoneNames)) {
|
|
564
846
|
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
|
|
565
847
|
}
|
|
566
848
|
}
|
|
567
849
|
|
|
568
|
-
private assertRouteTargetsAllowed(
|
|
569
|
-
|
|
850
|
+
private assertRouteTargetsAllowed(
|
|
851
|
+
auth: TAuthContext,
|
|
852
|
+
route?: plugins.servezoneInterfaces.data.IGatewayRouteConfig,
|
|
853
|
+
): void {
|
|
854
|
+
const policy = auth.policy;
|
|
570
855
|
if (!policy || policy.role !== 'gatewayClient' || !route) return;
|
|
571
856
|
const allowedTargets = policy.allowedRouteTargets || [];
|
|
572
857
|
if (allowedTargets.length === 0) {
|
|
573
858
|
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
|
|
574
859
|
}
|
|
575
|
-
const targets =
|
|
860
|
+
const targets = route.action.targets;
|
|
576
861
|
for (const target of targets) {
|
|
577
|
-
|
|
862
|
+
if (typeof target.host !== 'string') {
|
|
863
|
+
throw new plugins.typedrequest.TypedResponseError('gateway route target host must be a string');
|
|
864
|
+
}
|
|
865
|
+
const host = normalizeGatewayTargetHost(target.host);
|
|
578
866
|
const port = Number(target.port);
|
|
579
867
|
const allowed = allowedTargets.some((allowedTarget) => {
|
|
580
|
-
return allowedTarget.host
|
|
868
|
+
return normalizeGatewayTargetHost(allowedTarget.host) === host
|
|
869
|
+
&& (allowedTarget.allowAnyPort === true || allowedTarget.ports.includes(port));
|
|
581
870
|
});
|
|
582
871
|
if (!allowed) {
|
|
583
872
|
throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
|
|
@@ -593,7 +882,7 @@ export class GatewayClientHandler {
|
|
|
593
882
|
const targetDescription = target?.smtpForward
|
|
594
883
|
? `${target.smtpForward.host.trim().toLowerCase()}:${Number(target.smtpForward.port)}`
|
|
595
884
|
: 'unknown';
|
|
596
|
-
if ((auth.
|
|
885
|
+
if ((auth.policy?.allowedRouteTargets || []).length === 0) {
|
|
597
886
|
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
|
|
598
887
|
}
|
|
599
888
|
throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${targetDescription}`);
|
|
@@ -603,14 +892,15 @@ export class GatewayClientHandler {
|
|
|
603
892
|
auth: TAuthContext,
|
|
604
893
|
target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
|
|
605
894
|
): boolean {
|
|
606
|
-
const policy = auth.
|
|
895
|
+
const policy = auth.policy;
|
|
607
896
|
if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return true;
|
|
608
897
|
const allowedTargets = policy.allowedRouteTargets || [];
|
|
609
898
|
if (allowedTargets.length === 0) return false;
|
|
610
|
-
const host = target.smtpForward.host
|
|
899
|
+
const host = normalizeGatewayTargetHost(target.smtpForward.host);
|
|
611
900
|
const port = Number(target.smtpForward.port);
|
|
612
901
|
return allowedTargets.some((allowedTarget) => {
|
|
613
|
-
return allowedTarget.host
|
|
902
|
+
return normalizeGatewayTargetHost(allowedTarget.host) === host
|
|
903
|
+
&& (allowedTarget.allowAnyPort === true || allowedTarget.ports.includes(port));
|
|
614
904
|
});
|
|
615
905
|
}
|
|
616
906
|
|
|
@@ -641,7 +931,7 @@ export class GatewayClientHandler {
|
|
|
641
931
|
auth: TAuthContext,
|
|
642
932
|
statusArg: plugins.servezoneInterfaces.data.IMailDeliveryStatus,
|
|
643
933
|
): boolean {
|
|
644
|
-
const policy = auth.
|
|
934
|
+
const policy = auth.policy;
|
|
645
935
|
if (!policy || policy.role !== 'gatewayClient') {
|
|
646
936
|
return true;
|
|
647
937
|
}
|
|
@@ -659,7 +949,7 @@ export class GatewayClientHandler {
|
|
|
659
949
|
ownerArg: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined,
|
|
660
950
|
bindingsArg: plugins.servezoneInterfaces.data.IWorkAppMailBinding[],
|
|
661
951
|
): Promise<plugins.servezoneInterfaces.data.IWorkAppMailBinding[]> {
|
|
662
|
-
if (auth.
|
|
952
|
+
if (auth.policy?.role !== 'gatewayClient') return bindingsArg;
|
|
663
953
|
const allowedAddressBindings = this.filterMailBindingsAllowed(
|
|
664
954
|
auth,
|
|
665
955
|
await managerArg.listMailAddressBindings({ owner: ownerArg }),
|
|
@@ -690,42 +980,24 @@ export class GatewayClientHandler {
|
|
|
690
980
|
addressArg: string,
|
|
691
981
|
domainArg: string,
|
|
692
982
|
): boolean {
|
|
693
|
-
const policy = auth.
|
|
983
|
+
const policy = auth.policy;
|
|
694
984
|
if (!policy || policy.role !== 'gatewayClient') return true;
|
|
695
|
-
const normalizedDomain = domainArg
|
|
985
|
+
const normalizedDomain = normalizeGatewayHostname(domainArg);
|
|
696
986
|
const normalizedAddress = addressArg.trim().toLowerCase();
|
|
697
987
|
if (!normalizedAddress.endsWith(`@${normalizedDomain}`)) {
|
|
698
988
|
throw new plugins.typedrequest.TypedResponseError('mail address does not match domain');
|
|
699
989
|
}
|
|
700
990
|
const patterns = policy.hostnamePatterns || [];
|
|
701
|
-
return
|
|
702
|
-
|| patterns.some((patternArg) => patternArg.trim().toLowerCase() === `*.${normalizedDomain}`);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
|
|
706
|
-
const normalizedHostname = hostname.trim().toLowerCase();
|
|
707
|
-
if (!normalizedHostname) return false;
|
|
708
|
-
for (const pattern of patterns) {
|
|
709
|
-
const normalizedPattern = pattern.trim().toLowerCase();
|
|
710
|
-
if (!normalizedPattern) continue;
|
|
711
|
-
if (normalizedPattern === normalizedHostname) return true;
|
|
712
|
-
if (normalizedPattern.startsWith('*.')) {
|
|
713
|
-
const suffix = normalizedPattern.slice(2);
|
|
714
|
-
if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
|
|
715
|
-
const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
|
|
716
|
-
if (prefix && !prefix.includes('.')) return true;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
return false;
|
|
991
|
+
return matchesGatewayHostnamePatterns(normalizedDomain, patterns, auth.managedZoneNames);
|
|
720
992
|
}
|
|
721
993
|
|
|
722
994
|
private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
|
|
723
995
|
const domains = (route.match as any)?.domains;
|
|
724
996
|
if (Array.isArray(domains)) {
|
|
725
|
-
return domains.map((domain) => String(domain)
|
|
997
|
+
return domains.map((domain) => normalizeGatewayHostname(String(domain)));
|
|
726
998
|
}
|
|
727
999
|
if (typeof domains === 'string') {
|
|
728
|
-
return domains.split(',').map((domain) => domain
|
|
1000
|
+
return domains.split(',').map((domain) => normalizeGatewayHostname(domain));
|
|
729
1001
|
}
|
|
730
1002
|
return [];
|
|
731
1003
|
}
|
|
@@ -752,9 +1024,9 @@ export class GatewayClientHandler {
|
|
|
752
1024
|
.map((routeArg) => this.toGatewayClientRoute(routeArg))
|
|
753
1025
|
.filter((routeArg): routeArg is plugins.servezoneInterfaces.data.IGatewayClientRoute => {
|
|
754
1026
|
if (!routeArg) return false;
|
|
755
|
-
if (auth.
|
|
1027
|
+
if (auth.policy?.role !== 'gatewayClient') return true;
|
|
756
1028
|
return !routeArg.ownership.hostname
|
|
757
|
-
||
|
|
1029
|
+
|| matchesGatewayHostnamePatterns(routeArg.ownership.hostname, auth.policy.hostnamePatterns || [], auth.managedZoneNames);
|
|
758
1030
|
});
|
|
759
1031
|
}
|
|
760
1032
|
|
|
@@ -821,7 +1093,7 @@ export class GatewayClientHandler {
|
|
|
821
1093
|
|
|
822
1094
|
return docs
|
|
823
1095
|
.filter((domainDoc) => {
|
|
824
|
-
if (!auth.
|
|
1096
|
+
if (!auth.policy || auth.policy.role !== 'gatewayClient') return true;
|
|
825
1097
|
return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
|
|
826
1098
|
})
|
|
827
1099
|
.map((domainDoc) => {
|
|
@@ -861,12 +1133,18 @@ export class GatewayClientHandler {
|
|
|
861
1133
|
const appId = metadata.gatewayClientAppId || '';
|
|
862
1134
|
|
|
863
1135
|
for (const hostname of this.getRouteHostnames(route.route)) {
|
|
864
|
-
if (auth.
|
|
1136
|
+
if (auth.policy?.role === 'gatewayClient' && !matchesGatewayHostnamePatterns(hostname, auth.policy.hostnamePatterns || [], auth.managedZoneNames)) {
|
|
865
1137
|
continue;
|
|
866
1138
|
}
|
|
867
1139
|
const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
|
|
868
1140
|
const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
|
|
869
|
-
const matchingRecords = domainRecords.filter((record) =>
|
|
1141
|
+
const matchingRecords = domainRecords.filter((record) => {
|
|
1142
|
+
try {
|
|
1143
|
+
return normalizeGatewayHostname(record.name) === hostname;
|
|
1144
|
+
} catch {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
870
1148
|
if (matchingRecords.length === 0) {
|
|
871
1149
|
records.push({
|
|
872
1150
|
id: `missing:${hostname}`,
|
|
@@ -911,8 +1189,8 @@ export class GatewayClientHandler {
|
|
|
911
1189
|
}
|
|
912
1190
|
|
|
913
1191
|
private isHostnameInDomain(hostname: string, domainName: string): boolean {
|
|
914
|
-
const normalizedHostname = hostname
|
|
915
|
-
const normalizedDomainName = domainName
|
|
1192
|
+
const normalizedHostname = normalizeGatewayHostname(hostname);
|
|
1193
|
+
const normalizedDomainName = normalizeGatewayHostname(domainName);
|
|
916
1194
|
return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
|
|
917
1195
|
}
|
|
918
1196
|
|
|
@@ -923,31 +1201,144 @@ export class GatewayClientHandler {
|
|
|
923
1201
|
enabled?: boolean,
|
|
924
1202
|
deleteRoute?: boolean,
|
|
925
1203
|
sourceProfileRef?: string,
|
|
1204
|
+
dnsModeArg?: plugins.servezoneInterfaces.data.TGatewayRouteDnsMode,
|
|
926
1205
|
): Promise<plugins.servezoneInterfaces.data.IGatewayRouteSyncResult> {
|
|
1206
|
+
const validatedRoute = route === undefined ? undefined : this.validateGatewayRouteConfig(route);
|
|
927
1207
|
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
|
|
928
1208
|
this.assertGatewayClientOwnership(auth, resolvedOwnership);
|
|
929
|
-
this.assertGatewayClientRouteOwnership(resolvedOwnership,
|
|
930
|
-
this.assertRouteTargetsAllowed(auth,
|
|
1209
|
+
this.assertGatewayClientRouteOwnership(resolvedOwnership, validatedRoute);
|
|
1210
|
+
this.assertRouteTargetsAllowed(auth, validatedRoute);
|
|
931
1211
|
|
|
1212
|
+
if (dnsModeArg === 'observe') this.assertCapability(auth, 'readDnsRecords');
|
|
1213
|
+
if (dnsModeArg === 'reconcile') this.assertCapability(auth, 'syncDnsRecords');
|
|
932
1214
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
|
933
1215
|
if (!manager) {
|
|
934
1216
|
return { success: false, message: 'Route management not initialized' };
|
|
935
1217
|
}
|
|
936
1218
|
|
|
937
1219
|
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
|
|
1220
|
+
return await this.withRouteSyncLock(externalKey, async () => {
|
|
938
1221
|
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
|
|
1222
|
+
const dnsMode = dnsModeArg || existingRoute?.metadata?.gatewayDnsMode || 'skip';
|
|
1223
|
+
const includeDns = dnsModeArg !== undefined || dnsMode !== 'skip';
|
|
1224
|
+
if (dnsMode === 'observe') this.assertCapability(auth, 'readDnsRecords');
|
|
1225
|
+
if (dnsMode === 'reconcile') this.assertCapability(auth, 'syncDnsRecords');
|
|
1226
|
+
const reconcileDnsFor = async (
|
|
1227
|
+
hostnameArg: string | undefined,
|
|
1228
|
+
gatewayClientIdArg: string,
|
|
1229
|
+
externalKeyArg: string,
|
|
1230
|
+
deleteArg = false,
|
|
1231
|
+
effectiveModeArg = dnsMode,
|
|
1232
|
+
) => {
|
|
1233
|
+
const reconciler = this.opsServerRef.dcRouterRef.gatewayRouteDnsReconciler;
|
|
1234
|
+
if (!reconciler) {
|
|
1235
|
+
return {
|
|
1236
|
+
success: effectiveModeArg === 'skip',
|
|
1237
|
+
retryable: effectiveModeArg !== 'skip',
|
|
1238
|
+
mode: effectiveModeArg,
|
|
1239
|
+
status: effectiveModeArg === 'skip' ? 'skipped' : 'zone-unavailable',
|
|
1240
|
+
hostname: hostnameArg,
|
|
1241
|
+
checkedAt: Date.now(),
|
|
1242
|
+
message: effectiveModeArg === 'skip' ? undefined : 'Gateway route DNS reconciliation is not initialized',
|
|
1243
|
+
} satisfies plugins.servezoneInterfaces.data.IGatewayRouteDnsResult;
|
|
1244
|
+
}
|
|
1245
|
+
try {
|
|
1246
|
+
return await reconciler.reconcileRoute({
|
|
1247
|
+
externalKey: externalKeyArg,
|
|
1248
|
+
hostname: hostnameArg,
|
|
1249
|
+
mode: effectiveModeArg,
|
|
1250
|
+
gatewayClientId: gatewayClientIdArg,
|
|
1251
|
+
delete: deleteArg,
|
|
1252
|
+
});
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
return {
|
|
1255
|
+
success: false,
|
|
1256
|
+
retryable: true,
|
|
1257
|
+
mode: effectiveModeArg,
|
|
1258
|
+
status: 'mutation-failed',
|
|
1259
|
+
hostname: hostnameArg,
|
|
1260
|
+
checkedAt: Date.now(),
|
|
1261
|
+
message: `Gateway route DNS reconciliation failed: ${(error as Error).message}`,
|
|
1262
|
+
} satisfies plugins.servezoneInterfaces.data.IGatewayRouteDnsResult;
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
const reconcileDns = async (deleteArg = false, effectiveModeArg = dnsMode) => {
|
|
1266
|
+
return await reconcileDnsFor(
|
|
1267
|
+
resolvedOwnership.hostname,
|
|
1268
|
+
resolvedOwnership.gatewayClientId,
|
|
1269
|
+
externalKey,
|
|
1270
|
+
deleteArg,
|
|
1271
|
+
effectiveModeArg,
|
|
1272
|
+
);
|
|
1273
|
+
};
|
|
939
1274
|
|
|
940
1275
|
if (deleteRoute) {
|
|
941
1276
|
if (!existingRoute) {
|
|
942
|
-
|
|
1277
|
+
const dns = await reconcileDns(true, dnsMode);
|
|
1278
|
+
return dns.success
|
|
1279
|
+
? { success: true, action: 'unchanged', ...(includeDns ? { dns } : {}) }
|
|
1280
|
+
: { success: false, dns, message: dns.message };
|
|
1281
|
+
}
|
|
1282
|
+
const snapshot = structuredClone(existingRoute);
|
|
1283
|
+
const existingDnsMode = snapshot.metadata?.gatewayDnsMode || dnsMode;
|
|
1284
|
+
const disableResult = await manager.updateManagedRoute(existingRoute.id, {
|
|
1285
|
+
enabled: false,
|
|
1286
|
+
});
|
|
1287
|
+
if (!disableResult.success) {
|
|
1288
|
+
return { success: false, routeId: existingRoute.id, message: disableResult.message };
|
|
1289
|
+
}
|
|
1290
|
+
const dns = await reconcileDnsFor(
|
|
1291
|
+
this.getRouteHostnames(snapshot.route)[0] || resolvedOwnership.hostname,
|
|
1292
|
+
snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId,
|
|
1293
|
+
snapshot.metadata?.externalKey || externalKey,
|
|
1294
|
+
true,
|
|
1295
|
+
existingDnsMode,
|
|
1296
|
+
);
|
|
1297
|
+
if (!dns.success) {
|
|
1298
|
+
const rollback = await manager.updateManagedRoute(existingRoute.id, {
|
|
1299
|
+
route: snapshot.route,
|
|
1300
|
+
enabled: snapshot.enabled,
|
|
1301
|
+
metadata: snapshot.metadata,
|
|
1302
|
+
}, { replaceMetadata: true, replaceRoute: true });
|
|
1303
|
+
if (!rollback.success) {
|
|
1304
|
+
dns.status = 'compensation-failed';
|
|
1305
|
+
dns.message = `${dns.message || 'DNS cleanup failed'}; route rollback failed: ${rollback.message || 'unknown error'}`;
|
|
1306
|
+
}
|
|
1307
|
+
return { success: false, routeId: existingRoute.id, dns, message: dns.message };
|
|
943
1308
|
}
|
|
944
1309
|
const result = await manager.deleteManagedRoute(existingRoute.id);
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1310
|
+
if (result.success) {
|
|
1311
|
+
return { success: true, action: 'deleted', routeId: existingRoute.id, ...(includeDns ? { dns } : {}) };
|
|
1312
|
+
}
|
|
1313
|
+
const rollback = await manager.updateManagedRoute(existingRoute.id, {
|
|
1314
|
+
route: snapshot.route,
|
|
1315
|
+
enabled: snapshot.enabled,
|
|
1316
|
+
metadata: snapshot.metadata,
|
|
1317
|
+
}, { replaceMetadata: true, replaceRoute: true });
|
|
1318
|
+
let restoredDns = dns;
|
|
1319
|
+
if (rollback.success && snapshot.enabled && existingDnsMode === 'reconcile') {
|
|
1320
|
+
restoredDns = await reconcileDnsFor(
|
|
1321
|
+
this.getRouteHostnames(snapshot.route)[0] || resolvedOwnership.hostname,
|
|
1322
|
+
snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId,
|
|
1323
|
+
snapshot.metadata?.externalKey || externalKey,
|
|
1324
|
+
false,
|
|
1325
|
+
'reconcile',
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
success: false,
|
|
1330
|
+
dns: rollback.success && restoredDns.success ? dns : {
|
|
1331
|
+
...restoredDns,
|
|
1332
|
+
status: 'compensation-failed',
|
|
1333
|
+
message: !rollback.success
|
|
1334
|
+
? `${result.message || 'Route deletion failed'}; route rollback failed: ${rollback.message || 'unknown error'}`
|
|
1335
|
+
: `${result.message || 'Route deletion failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`,
|
|
1336
|
+
},
|
|
1337
|
+
message: result.message,
|
|
1338
|
+
};
|
|
948
1339
|
}
|
|
949
1340
|
|
|
950
|
-
if (!
|
|
1341
|
+
if (!validatedRoute) {
|
|
951
1342
|
return { success: false, message: 'route is required unless delete=true' };
|
|
952
1343
|
}
|
|
953
1344
|
|
|
@@ -968,10 +1359,22 @@ export class GatewayClientHandler {
|
|
|
968
1359
|
gatewayClientId: resolvedOwnership.gatewayClientId,
|
|
969
1360
|
gatewayClientAppId: resolvedOwnership.appId,
|
|
970
1361
|
externalKey,
|
|
1362
|
+
gatewayDnsMode: dnsMode,
|
|
971
1363
|
};
|
|
972
|
-
const normalizedRoute = this.normalizeGatewayClientRoute(
|
|
1364
|
+
const normalizedRoute = this.normalizeGatewayClientRoute(validatedRoute, resolvedOwnership, externalKey);
|
|
973
1365
|
|
|
974
1366
|
if (existingRoute) {
|
|
1367
|
+
const snapshot = structuredClone(existingRoute);
|
|
1368
|
+
const snapshotHostname = this.getRouteHostnames(snapshot.route)[0];
|
|
1369
|
+
const snapshotDnsMode = snapshot.metadata?.gatewayDnsMode || 'skip';
|
|
1370
|
+
const snapshotGatewayClientId = snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId;
|
|
1371
|
+
const snapshotExternalKey = snapshot.metadata?.externalKey || externalKey;
|
|
1372
|
+
const shouldCleanPreviousDns = snapshotDnsMode === 'reconcile'
|
|
1373
|
+
&& Boolean(snapshotHostname)
|
|
1374
|
+
&& (
|
|
1375
|
+
snapshotHostname !== resolvedOwnership.hostname
|
|
1376
|
+
|| dnsMode !== 'reconcile'
|
|
1377
|
+
);
|
|
975
1378
|
const result = await manager.updateManagedRoute(existingRoute.id, {
|
|
976
1379
|
route: normalizedRoute,
|
|
977
1380
|
enabled: enabled ?? true,
|
|
@@ -980,17 +1383,125 @@ export class GatewayClientHandler {
|
|
|
980
1383
|
replaceMetadata: true,
|
|
981
1384
|
replaceRoute: true,
|
|
982
1385
|
});
|
|
983
|
-
return result.
|
|
984
|
-
|
|
985
|
-
|
|
1386
|
+
if (!result.success) return { success: false, message: result.message };
|
|
1387
|
+
if (shouldCleanPreviousDns) {
|
|
1388
|
+
const cleanupDns = await reconcileDnsFor(
|
|
1389
|
+
snapshotHostname,
|
|
1390
|
+
snapshotGatewayClientId,
|
|
1391
|
+
snapshotExternalKey,
|
|
1392
|
+
true,
|
|
1393
|
+
'reconcile',
|
|
1394
|
+
);
|
|
1395
|
+
if (!cleanupDns.success) {
|
|
1396
|
+
const compensation = await manager.updateManagedRoute(existingRoute.id, {
|
|
1397
|
+
route: snapshot.route,
|
|
1398
|
+
enabled: snapshot.enabled,
|
|
1399
|
+
metadata: snapshot.metadata,
|
|
1400
|
+
}, { replaceMetadata: true, replaceRoute: true });
|
|
1401
|
+
if (compensation.success && snapshot.enabled) {
|
|
1402
|
+
const restoredDns = await reconcileDnsFor(
|
|
1403
|
+
snapshotHostname,
|
|
1404
|
+
snapshotGatewayClientId,
|
|
1405
|
+
snapshotExternalKey,
|
|
1406
|
+
false,
|
|
1407
|
+
'reconcile',
|
|
1408
|
+
);
|
|
1409
|
+
if (!restoredDns.success) {
|
|
1410
|
+
cleanupDns.status = 'compensation-failed';
|
|
1411
|
+
cleanupDns.message = `${cleanupDns.message || 'DNS cleanup failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`;
|
|
1412
|
+
}
|
|
1413
|
+
} else if (!compensation.success) {
|
|
1414
|
+
cleanupDns.status = 'compensation-failed';
|
|
1415
|
+
cleanupDns.message = `${cleanupDns.message || 'DNS cleanup failed'}; route rollback failed: ${compensation.message || 'unknown error'}`;
|
|
1416
|
+
}
|
|
1417
|
+
return { success: false, routeId: existingRoute.id, dns: cleanupDns, message: cleanupDns.message };
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const dns = await reconcileDns((enabled ?? true) === false);
|
|
1421
|
+
if (dns.success) return { success: true, action: 'updated', routeId: existingRoute.id, ...(includeDns ? { dns } : {}) };
|
|
1422
|
+
const compensation = await manager.updateManagedRoute(existingRoute.id, {
|
|
1423
|
+
route: snapshot.route,
|
|
1424
|
+
enabled: snapshot.enabled,
|
|
1425
|
+
metadata: snapshot.metadata,
|
|
1426
|
+
}, { replaceMetadata: true, replaceRoute: true });
|
|
1427
|
+
if (compensation.success && shouldCleanPreviousDns && snapshot.enabled) {
|
|
1428
|
+
const restoredDns = await reconcileDnsFor(
|
|
1429
|
+
snapshotHostname,
|
|
1430
|
+
snapshotGatewayClientId,
|
|
1431
|
+
snapshotExternalKey,
|
|
1432
|
+
false,
|
|
1433
|
+
'reconcile',
|
|
1434
|
+
);
|
|
1435
|
+
if (!restoredDns.success) {
|
|
1436
|
+
dns.status = 'compensation-failed';
|
|
1437
|
+
dns.message = `${dns.message || 'DNS reconciliation failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`;
|
|
1438
|
+
}
|
|
1439
|
+
} else if (!compensation.success) {
|
|
1440
|
+
dns.status = 'compensation-failed';
|
|
1441
|
+
dns.message = `${dns.message || 'DNS reconciliation failed'}; route rollback failed: ${compensation.message || 'unknown error'}`;
|
|
1442
|
+
}
|
|
1443
|
+
return { success: false, routeId: existingRoute.id, dns, message: dns.message };
|
|
986
1444
|
}
|
|
987
1445
|
|
|
988
1446
|
const routeId = await manager.createManagedRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
|
|
989
|
-
|
|
1447
|
+
const dns = await reconcileDns((enabled ?? true) === false);
|
|
1448
|
+
if (dns.success) return { success: true, action: 'created', routeId, ...(includeDns ? { dns } : {}) };
|
|
1449
|
+
const compensation = await manager.deleteManagedRoute(routeId);
|
|
1450
|
+
if (!compensation.success) {
|
|
1451
|
+
dns.status = 'compensation-failed';
|
|
1452
|
+
dns.message = `${dns.message || 'DNS reconciliation failed'}; route rollback failed: ${compensation.message || 'unknown error'}`;
|
|
1453
|
+
}
|
|
1454
|
+
return { success: false, routeId, dns, message: dns.message };
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
private normalizeGatewayClientLockKey(idArg: string): string {
|
|
1459
|
+
const normalized = idArg.trim().toLowerCase()
|
|
1460
|
+
.replace(/[^a-z0-9._-]/g, '-')
|
|
1461
|
+
.replace(/-+/g, '-')
|
|
1462
|
+
.replace(/^-|-$/g, '');
|
|
1463
|
+
if (!normalized) throw new Error('gateway client id is required');
|
|
1464
|
+
return normalized;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
private async withGatewayClientCredentialLock<T>(
|
|
1468
|
+
keyArg: string,
|
|
1469
|
+
taskArg: () => Promise<T>,
|
|
1470
|
+
): Promise<T> {
|
|
1471
|
+
const previous = this.gatewayClientCredentialChains.get(keyArg) || Promise.resolve();
|
|
1472
|
+
const run = previous.catch(() => undefined).then(taskArg);
|
|
1473
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
1474
|
+
this.gatewayClientCredentialChains.set(keyArg, settled);
|
|
1475
|
+
try {
|
|
1476
|
+
return await run;
|
|
1477
|
+
} finally {
|
|
1478
|
+
if (this.gatewayClientCredentialChains.get(keyArg) === settled) {
|
|
1479
|
+
this.gatewayClientCredentialChains.delete(keyArg);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
private async withRouteSyncLock<T>(keyArg: string, taskArg: () => Promise<T>): Promise<T> {
|
|
1485
|
+
const previous = this.routeSyncChains.get(keyArg) || Promise.resolve();
|
|
1486
|
+
const run = previous
|
|
1487
|
+
.catch(() => undefined)
|
|
1488
|
+
.then(taskArg);
|
|
1489
|
+
const settled = run.then(
|
|
1490
|
+
() => undefined,
|
|
1491
|
+
() => undefined,
|
|
1492
|
+
);
|
|
1493
|
+
this.routeSyncChains.set(keyArg, settled);
|
|
1494
|
+
try {
|
|
1495
|
+
return await run;
|
|
1496
|
+
} finally {
|
|
1497
|
+
if (this.routeSyncChains.get(keyArg) === settled) {
|
|
1498
|
+
this.routeSyncChains.delete(keyArg);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
990
1501
|
}
|
|
991
1502
|
|
|
992
1503
|
private buildGatewayClientExternalKey(ownership: TResolvedGatewayClientOwnership): string {
|
|
993
|
-
const hostname = ownership.hostname
|
|
1504
|
+
const hostname = ownership.hostname ? normalizeGatewayHostname(ownership.hostname) : undefined;
|
|
994
1505
|
const routeRef = ownership.routeRef?.trim();
|
|
995
1506
|
let routeKey: string;
|
|
996
1507
|
if (hostname && routeRef) {
|
|
@@ -1004,7 +1515,7 @@ export class GatewayClientHandler {
|
|
|
1004
1515
|
return [
|
|
1005
1516
|
ownership.gatewayClientType,
|
|
1006
1517
|
ownership.gatewayClientId,
|
|
1007
|
-
ownership.appId,
|
|
1518
|
+
ownership.appId.trim(),
|
|
1008
1519
|
routeKey,
|
|
1009
1520
|
].map((part) => part.trim()).join(':');
|
|
1010
1521
|
}
|
|
@@ -1038,6 +1549,148 @@ export class GatewayClientHandler {
|
|
|
1038
1549
|
}
|
|
1039
1550
|
}
|
|
1040
1551
|
|
|
1552
|
+
private validateGatewayRouteConfig(
|
|
1553
|
+
routeArg: unknown,
|
|
1554
|
+
): plugins.servezoneInterfaces.data.IGatewayRouteConfig {
|
|
1555
|
+
const fail = (messageArg: string): never => {
|
|
1556
|
+
throw new plugins.typedrequest.TypedResponseError(`invalid gateway route: ${messageArg}`);
|
|
1557
|
+
};
|
|
1558
|
+
const requireObject = (valueArg: unknown, labelArg: string): Record<string, unknown> => {
|
|
1559
|
+
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
|
|
1560
|
+
return fail(`${labelArg} must be an object`);
|
|
1561
|
+
}
|
|
1562
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
1563
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1564
|
+
return fail(`${labelArg} must be a plain object`);
|
|
1565
|
+
}
|
|
1566
|
+
return valueArg as Record<string, unknown>;
|
|
1567
|
+
};
|
|
1568
|
+
const requireOnlyKeys = (
|
|
1569
|
+
valueArg: Record<string, unknown>,
|
|
1570
|
+
allowedArg: readonly string[],
|
|
1571
|
+
labelArg: string,
|
|
1572
|
+
): void => {
|
|
1573
|
+
const unknownKeys = Object.keys(valueArg).filter((keyArg) => !allowedArg.includes(keyArg));
|
|
1574
|
+
if (unknownKeys.length > 0) fail(`${labelArg} contains unsupported field(s): ${unknownKeys.join(', ')}`);
|
|
1575
|
+
};
|
|
1576
|
+
const requireString = (valueArg: unknown, labelArg: string): string => {
|
|
1577
|
+
if (typeof valueArg !== 'string' || !valueArg.trim()) return fail(`${labelArg} must be a non-empty string`);
|
|
1578
|
+
return valueArg.trim();
|
|
1579
|
+
};
|
|
1580
|
+
const requireBoolean = (valueArg: unknown, labelArg: string): boolean => {
|
|
1581
|
+
if (typeof valueArg !== 'boolean') return fail(`${labelArg} must be a boolean`);
|
|
1582
|
+
return valueArg;
|
|
1583
|
+
};
|
|
1584
|
+
const requirePort = (valueArg: unknown, labelArg: string): number => {
|
|
1585
|
+
if (!Number.isSafeInteger(valueArg) || Number(valueArg) < 1 || Number(valueArg) > 65535) {
|
|
1586
|
+
return fail(`${labelArg} must be an integer from 1 through 65535`);
|
|
1587
|
+
}
|
|
1588
|
+
return Number(valueArg);
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
const route = requireObject(routeArg, 'route');
|
|
1592
|
+
requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'remoteIngress'], 'route');
|
|
1593
|
+
const name = requireString(route.name, 'route.name');
|
|
1594
|
+
|
|
1595
|
+
const match = requireObject(route.match, 'route.match');
|
|
1596
|
+
requireOnlyKeys(match, ['ports', 'domains', 'transport', 'path'], 'route.match');
|
|
1597
|
+
const portValues = match.ports;
|
|
1598
|
+
if (!Array.isArray(portValues) || portValues.length === 0) {
|
|
1599
|
+
fail('route.match.ports must be a non-empty array');
|
|
1600
|
+
}
|
|
1601
|
+
const ports = (portValues as unknown[]).map((portArg, indexArg) => requirePort(portArg, `route.match.ports[${indexArg}]`));
|
|
1602
|
+
let domains: string[] | undefined;
|
|
1603
|
+
if (match.domains !== undefined) {
|
|
1604
|
+
const domainValues = match.domains;
|
|
1605
|
+
if (!Array.isArray(domainValues) || domainValues.length === 0) {
|
|
1606
|
+
fail('route.match.domains must be a non-empty array when present');
|
|
1607
|
+
}
|
|
1608
|
+
domains = (domainValues as unknown[]).map((domainArg, indexArg) => {
|
|
1609
|
+
return normalizeGatewayHostname(requireString(domainArg, `route.match.domains[${indexArg}]`));
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
let transport: 'tcp' | 'udp' | 'all' | undefined;
|
|
1613
|
+
if (match.transport !== undefined) {
|
|
1614
|
+
if (match.transport !== 'tcp' && match.transport !== 'udp' && match.transport !== 'all') {
|
|
1615
|
+
fail('route.match.transport is unsupported');
|
|
1616
|
+
}
|
|
1617
|
+
transport = match.transport as 'tcp' | 'udp' | 'all';
|
|
1618
|
+
}
|
|
1619
|
+
const path = match.path === undefined ? undefined : requireString(match.path, 'route.match.path');
|
|
1620
|
+
|
|
1621
|
+
const action = requireObject(route.action, 'route.action');
|
|
1622
|
+
requireOnlyKeys(action, ['type', 'targets', 'tls', 'websocket'], 'route.action');
|
|
1623
|
+
if (action.type !== 'forward') fail('route.action.type must be forward');
|
|
1624
|
+
const targetValues = action.targets;
|
|
1625
|
+
if (!Array.isArray(targetValues) || targetValues.length === 0) {
|
|
1626
|
+
fail('route.action.targets must be a non-empty array');
|
|
1627
|
+
}
|
|
1628
|
+
const targets = (targetValues as unknown[]).map((targetArg, indexArg) => {
|
|
1629
|
+
const target = requireObject(targetArg, `route.action.targets[${indexArg}]`);
|
|
1630
|
+
requireOnlyKeys(target, ['host', 'port'], `route.action.targets[${indexArg}]`);
|
|
1631
|
+
return {
|
|
1632
|
+
host: normalizeGatewayTargetHost(requireString(target.host, `route.action.targets[${indexArg}].host`)),
|
|
1633
|
+
port: requirePort(target.port, `route.action.targets[${indexArg}].port`),
|
|
1634
|
+
};
|
|
1635
|
+
});
|
|
1636
|
+
let tls: plugins.servezoneInterfaces.data.IGatewayRouteTls | undefined;
|
|
1637
|
+
if (action.tls !== undefined) {
|
|
1638
|
+
const tlsValue = requireObject(action.tls, 'route.action.tls');
|
|
1639
|
+
requireOnlyKeys(tlsValue, ['mode', 'certificate'], 'route.action.tls');
|
|
1640
|
+
if (tlsValue.mode !== 'terminate' && tlsValue.mode !== 'passthrough'
|
|
1641
|
+
&& tlsValue.mode !== 'terminate-and-reencrypt') fail('route.action.tls.mode is unsupported');
|
|
1642
|
+
if (tlsValue.certificate !== undefined && tlsValue.certificate !== 'auto') {
|
|
1643
|
+
fail('route.action.tls.certificate must be auto when present');
|
|
1644
|
+
}
|
|
1645
|
+
tls = {
|
|
1646
|
+
mode: tlsValue.mode as 'terminate' | 'passthrough' | 'terminate-and-reencrypt',
|
|
1647
|
+
...(tlsValue.certificate === 'auto' ? { certificate: 'auto' as const } : {}),
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
let websocket: { enabled: boolean } | undefined;
|
|
1651
|
+
if (action.websocket !== undefined) {
|
|
1652
|
+
const websocketValue = requireObject(action.websocket, 'route.action.websocket');
|
|
1653
|
+
requireOnlyKeys(websocketValue, ['enabled'], 'route.action.websocket');
|
|
1654
|
+
websocket = { enabled: requireBoolean(websocketValue.enabled, 'route.action.websocket.enabled') };
|
|
1655
|
+
}
|
|
1656
|
+
let priority: number | undefined;
|
|
1657
|
+
if (route.priority !== undefined) {
|
|
1658
|
+
if (!Number.isSafeInteger(route.priority)) fail('route.priority must be a safe integer');
|
|
1659
|
+
priority = Number(route.priority);
|
|
1660
|
+
}
|
|
1661
|
+
let managedRouteKind: 'letsencrypt-http01-forward' | undefined;
|
|
1662
|
+
if (route.managedRouteKind !== undefined) {
|
|
1663
|
+
if (route.managedRouteKind !== 'letsencrypt-http01-forward') fail('route.managedRouteKind is unsupported');
|
|
1664
|
+
managedRouteKind = route.managedRouteKind as 'letsencrypt-http01-forward';
|
|
1665
|
+
}
|
|
1666
|
+
let remoteIngress: { enabled: boolean; edgeFilter?: string[] } | undefined;
|
|
1667
|
+
if (route.remoteIngress !== undefined) {
|
|
1668
|
+
const remoteValue = requireObject(route.remoteIngress, 'route.remoteIngress');
|
|
1669
|
+
requireOnlyKeys(remoteValue, ['enabled', 'edgeFilter'], 'route.remoteIngress');
|
|
1670
|
+
let edgeFilter: string[] | undefined;
|
|
1671
|
+
if (remoteValue.edgeFilter !== undefined) {
|
|
1672
|
+
const edgeFilterValues = remoteValue.edgeFilter;
|
|
1673
|
+
if (!Array.isArray(edgeFilterValues)) fail('route.remoteIngress.edgeFilter must be an array');
|
|
1674
|
+
edgeFilter = (edgeFilterValues as unknown[]).map((entryArg, indexArg) => {
|
|
1675
|
+
return requireString(entryArg, `route.remoteIngress.edgeFilter[${indexArg}]`);
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
remoteIngress = {
|
|
1679
|
+
enabled: requireBoolean(remoteValue.enabled, 'route.remoteIngress.enabled'),
|
|
1680
|
+
...(edgeFilter ? { edgeFilter } : {}),
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
return {
|
|
1685
|
+
name,
|
|
1686
|
+
match: { ports, ...(domains ? { domains } : {}), ...(transport ? { transport } : {}), ...(path ? { path } : {}) },
|
|
1687
|
+
action: { type: 'forward', targets, ...(tls ? { tls } : {}), ...(websocket ? { websocket } : {}) },
|
|
1688
|
+
...(priority !== undefined ? { priority } : {}),
|
|
1689
|
+
...(managedRouteKind ? { managedRouteKind } : {}),
|
|
1690
|
+
...(remoteIngress ? { remoteIngress } : {}),
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1041
1694
|
|
|
1042
1695
|
private normalizeGatewayClientRoute(
|
|
1043
1696
|
route: interfaces.data.IDcRouterRouteConfig,
|
|
@@ -1046,6 +1699,18 @@ export class GatewayClientHandler {
|
|
|
1046
1699
|
): interfaces.data.IDcRouterRouteConfig {
|
|
1047
1700
|
const normalizedRoute = structuredClone(route);
|
|
1048
1701
|
delete normalizedRoute.security;
|
|
1702
|
+
normalizedRoute.match = {
|
|
1703
|
+
...normalizedRoute.match,
|
|
1704
|
+
...(this.getRouteHostnames(normalizedRoute).length
|
|
1705
|
+
? { domains: this.getRouteHostnames(normalizedRoute) }
|
|
1706
|
+
: {}),
|
|
1707
|
+
};
|
|
1708
|
+
if (normalizedRoute.action?.targets) {
|
|
1709
|
+
normalizedRoute.action.targets = normalizedRoute.action.targets.map((targetArg) => ({
|
|
1710
|
+
...targetArg,
|
|
1711
|
+
host: normalizeGatewayTargetHost(targetArg.host as string),
|
|
1712
|
+
}));
|
|
1713
|
+
}
|
|
1049
1714
|
if (ownership.hostname && this.getRouteHostnames(normalizedRoute).length === 0) {
|
|
1050
1715
|
normalizedRoute.match = {
|
|
1051
1716
|
...normalizedRoute.match,
|
|
@@ -1069,6 +1734,9 @@ export class GatewayClientHandler {
|
|
|
1069
1734
|
throw new plugins.typedrequest.TypedResponseError('routeRef ownership cannot include domains');
|
|
1070
1735
|
}
|
|
1071
1736
|
const ownedHostname = ownership.hostname.trim().toLowerCase();
|
|
1737
|
+
if (routeDomains.length !== 1) {
|
|
1738
|
+
throw new plugins.typedrequest.TypedResponseError('hostname ownership requires exactly one route domain');
|
|
1739
|
+
}
|
|
1072
1740
|
if (routeDomains.some((domainArg) => domainArg.toLowerCase() !== ownedHostname)) {
|
|
1073
1741
|
throw new plugins.typedrequest.TypedResponseError('route domains must match ownership hostname');
|
|
1074
1742
|
}
|