Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a97c4963d6 | |||
| 62271c1819 | |||
| e6b3625256 | |||
| 103680a3a0 |
+18
-1
@@ -1,7 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
## 2026-06-05 - 14.1.0
|
||||
|
||||
### Features
|
||||
|
||||
- add shared WorkApp mail address binding APIs (workapp-mail)
|
||||
- Adds list, sync, and delete support for shared mail address bindings.
|
||||
- Maps shared mail address bindings to stored WorkApp mail identities and grouped WorkApp mail bindings.
|
||||
- Enforces gateway client ownership and allowed mail forward targets for gateway-scoped tokens.
|
||||
- Updates interface dependencies for shared mail binding request types.
|
||||
|
||||
## 2026-06-05 - 14.0.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- apply inbound PROXY protocol policies per listener (proxy-protocol)
|
||||
- Apply inbound PROXY protocol policies across prepared and runtime routes that share the same listener.
|
||||
- Require PROXY protocol for remote ingress SMTP and submission ports while using optional mode for other remote ingress and VPN listeners.
|
||||
- Trust localhost for remote ingress and VPN forwarding without globally enabling PROXY protocol.
|
||||
- Bump @push.rocks/smartproxy to ^27.12.8.
|
||||
|
||||
## 2026-06-04 - 14.0.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "14.0.0",
|
||||
"version": "14.1.0",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "14.0.0",
|
||||
"version": "14.1.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -29,7 +29,7 @@
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdeno": "^1.5.0",
|
||||
"@git.zone/tsdocker": "^2.4.2",
|
||||
"@git.zone/tsdocker": "^2.4.3",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.12.7",
|
||||
"@push.rocks/smartproxy": "^27.12.8",
|
||||
"@push.rocks/smartradius": "^1.3.0",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.13.0",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@serve.zone/interfaces": "^6.3.0",
|
||||
"@serve.zone/remoteingress": "^4.23.0",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
Generated
+15
-15
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.12.7
|
||||
version: 27.12.7
|
||||
specifier: ^27.12.8
|
||||
version: 27.12.8
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
@@ -111,8 +111,8 @@ importers:
|
||||
specifier: ^2.13.0
|
||||
version: 2.13.0(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
'@serve.zone/remoteingress':
|
||||
specifier: ^4.23.0
|
||||
version: 4.23.0
|
||||
@@ -142,8 +142,8 @@ importers:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
'@git.zone/tsdocker':
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
'@git.zone/tsrun':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
@@ -733,8 +733,8 @@ packages:
|
||||
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsdocker@2.4.2':
|
||||
resolution: {integrity: sha512-cLG/o1TL7P62td/MUiJgnKYm0HjOFuPzh1YvVo8PwC+754QuW/bFGFnWQvbmoulzfkpzVoP2wwQwk4ECe+XBlQ==}
|
||||
'@git.zone/tsdocker@2.4.3':
|
||||
resolution: {integrity: sha512-ufLp7lyMj4tCpERd/xIE+z9II6o7qp+T7OZUexbLkIB8ZHzBGj/+zB9r2hFfMiUXcznd1vwAF3VNwADYwWut3Q==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tspublish@1.11.6':
|
||||
@@ -1429,8 +1429,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.4':
|
||||
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.7':
|
||||
resolution: {integrity: sha512-5QHQLNUqLn7wrMEP+X361aQSvc4p8RabgV9jPnx4G6DgR8a25Z4kN2PAgtsg75U9QyQbQicE2lyPqIPaSTQ+uQ==}
|
||||
'@push.rocks/smartproxy@27.12.8':
|
||||
resolution: {integrity: sha512-d1sbo2avzFO9PUXpb2FuBKwSDoackxNPFOHvR8q0DBMMoAmxRVf0mmhVxWrvqbGMk2N9rtORve2g3TsMJRTZYQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.6':
|
||||
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
|
||||
@@ -1692,8 +1692,8 @@ packages:
|
||||
'@serve.zone/catalog@2.13.0':
|
||||
resolution: {integrity: sha512-w2zfbcbJLR1jbwJQkeLNCOW/WB71FyMBfVb+uiIO5XTVK+7zTD0cFozySjBDOrueCFDcL7GcoO8Ohgs9jCfuhQ==}
|
||||
|
||||
'@serve.zone/interfaces@6.2.1':
|
||||
resolution: {integrity: sha512-t2wrpBmd8zDdnyeeY/LG2hfjCXdm/uTHB6oovJ/xHgOws1E2VimYJPFiN7zqs1aEJAmFukfgOq79+eZeq3hfWw==}
|
||||
'@serve.zone/interfaces@6.3.0':
|
||||
resolution: {integrity: sha512-TjpOLCejOcwyMvkBoqwjdptVGwaNMAru+6jC3Lw2Em3+7Z0xVNU/du0DQtoLbaLqTb0vHbuxx6urqw12uR9/2A==}
|
||||
|
||||
'@serve.zone/remoteingress@4.23.0':
|
||||
resolution: {integrity: sha512-ddF7k3ZfgpPn9rwfprDMWZR2CNzwQlmiETpST2obJPN5VrAZMLj2aT7yQYGiqLgVqUQBSXeSz9St2ygYyQ8PSQ==}
|
||||
@@ -5155,7 +5155,7 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@git.zone/tsdocker@2.4.2':
|
||||
'@git.zone/tsdocker@2.4.3':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/projectinfo': 5.1.0
|
||||
@@ -6581,7 +6581,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.4': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.7':
|
||||
'@push.rocks/smartproxy@27.12.8':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6928,7 +6928,7 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@serve.zone/interfaces@6.2.1':
|
||||
'@serve.zone/interfaces@6.3.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
|
||||
@@ -205,7 +205,7 @@ tap.test('DcRouter class - Generated plaintext email routes hydrate to server-fi
|
||||
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
|
||||
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
|
||||
|
||||
const hydrate = (route: any, origin = 'email') => (router as any)['hydrateStoredRouteForRuntime']({
|
||||
const hydrate = (routerArg: DcRouter, route: any, origin = 'email') => (routerArg as any)['hydrateStoredRouteForRuntime']({
|
||||
id: `${origin}-${route.name}`,
|
||||
route,
|
||||
enabled: true,
|
||||
@@ -216,16 +216,77 @@ tap.test('DcRouter class - Generated plaintext email routes hydrate to server-fi
|
||||
systemKey: `${origin}:${route.name}`,
|
||||
});
|
||||
|
||||
const runtimeSmtpRoute = hydrate(smtpRoute);
|
||||
const runtimeSmtpRoute = hydrate(router, smtpRoute);
|
||||
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
const runtimeSubmissionRoute = hydrate(submissionRoute);
|
||||
const runtimeSubmissionRoute = hydrate(router, submissionRoute);
|
||||
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
|
||||
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
|
||||
|
||||
expect(hydrate(smtpsRoute)).toBeUndefined();
|
||||
expect(hydrate(smtpRoute, 'api')).toBeUndefined();
|
||||
expect(hydrate(router, smtpsRoute)).toBeUndefined();
|
||||
expect(hydrate(router, smtpRoute, 'api')).toBeUndefined();
|
||||
|
||||
const remoteIngressRouter = new DcRouter({
|
||||
emailConfig,
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
const staleSmtpRoute = {
|
||||
...smtpRoute,
|
||||
match: {
|
||||
...smtpRoute.match,
|
||||
inboundProxyProtocol: undefined,
|
||||
},
|
||||
};
|
||||
const runtimeRemoteSmtpRoute = hydrate(remoteIngressRouter, staleSmtpRoute);
|
||||
expect(runtimeRemoteSmtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Inbound PROXY policies are applied per listener', async () => {
|
||||
const router = new DcRouter({
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
const routes = (router as any)['applyInboundProxyProtocolPolicies']([{
|
||||
name: 'remote-route',
|
||||
match: { ports: [443], domains: ['remote.example.com'] },
|
||||
remoteIngress: { enabled: true },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
}, {
|
||||
name: 'same-listener-direct-route',
|
||||
match: { ports: [443], domains: ['direct.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9443 }],
|
||||
},
|
||||
}]);
|
||||
|
||||
expect(routes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
expect(routes[1].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
|
||||
const vpnRouter = new DcRouter({
|
||||
vpnConfig: { enabled: true },
|
||||
});
|
||||
const vpnRoutes = (vpnRouter as any)['applyInboundProxyProtocolPolicies']([{
|
||||
name: 'vpn-route',
|
||||
match: { ports: [9443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 9443 }],
|
||||
},
|
||||
}]);
|
||||
|
||||
expect(vpnRoutes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
|
||||
@@ -297,6 +358,15 @@ tap.test('DcRouter class - Email routes are exposed through RemoteIngress when e
|
||||
for (const route of routes) {
|
||||
expect(route.remoteIngress).toEqual({ enabled: true });
|
||||
}
|
||||
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
|
||||
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
|
||||
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
|
||||
expect(smtpRoute?.match.transport).toEqual('tcp');
|
||||
expect(smtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
expect(submissionRoute?.match.transport).toEqual('tcp');
|
||||
expect(submissionRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
|
||||
expect(smtpsRoute?.action.type).toEqual('forward');
|
||||
expect(smtpsRoute?.match.inboundProxyProtocol).toEqual({ mode: 'optional' });
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
|
||||
@@ -172,4 +172,58 @@ tap.test('WorkAppMailManager applies persisted identities to startup email confi
|
||||
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkAppMailManager maps shared mail address bindings to WorkApp identities', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
|
||||
const syncResult = await manager.syncMailAddressBinding({
|
||||
owner: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
appInstanceId: 'app-1',
|
||||
},
|
||||
address: 'hello@example.com',
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
enabled: true,
|
||||
inboundTarget: {
|
||||
type: 'smtpForward',
|
||||
smtpForward: {
|
||||
host: '10.0.0.4',
|
||||
port: 2527,
|
||||
},
|
||||
},
|
||||
}, 'tester');
|
||||
|
||||
expect(syncResult.success).toEqual(true);
|
||||
expect(syncResult.binding?.owner).toEqual({
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
appInstanceId: 'app-1',
|
||||
});
|
||||
expect(syncResult.binding?.inboundTarget?.smtpForward?.host).toEqual('10.0.0.4');
|
||||
expect(syncResult.binding?.outboundIdentityId?.startsWith('workapp-')).toEqual(true);
|
||||
|
||||
const addressBindings = await manager.listMailAddressBindings({
|
||||
owner: { appInstanceId: 'app-1' },
|
||||
domain: 'example.com',
|
||||
});
|
||||
expect(addressBindings.length).toEqual(1);
|
||||
expect(addressBindings[0].address).toEqual('hello@example.com');
|
||||
expect(addressBindings[0].recipientPolicy?.staticRecipients).toEqual(['hello@example.com']);
|
||||
|
||||
const workAppBindings = await manager.listWorkAppMailBindings({
|
||||
gatewayClientId: 'box-1',
|
||||
});
|
||||
expect(workAppBindings.length).toEqual(1);
|
||||
expect(workAppBindings[0].addressBindingIds).toEqual([syncResult.binding!.id]);
|
||||
|
||||
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||
expect(generatedRoute.action.forward.host).toEqual('10.0.0.4');
|
||||
|
||||
const deleteResult = await manager.deleteMailAddressBinding(syncResult.binding!.id, 'tester');
|
||||
expect(deleteResult.success).toEqual(true);
|
||||
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -587,4 +587,251 @@ tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write'
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler exposes shared mail address binding handlers', async () => {
|
||||
const syncedRequests: Array<{ binding: any; userId: string }> = [];
|
||||
const deletedRequests: Array<{ id: string; userId: string }> = [];
|
||||
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
|
||||
id: 'mail-1',
|
||||
owner: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
appInstanceId: 'app-1',
|
||||
},
|
||||
address: 'hello@example.com',
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
enabled: true,
|
||||
status: 'active',
|
||||
inboundTarget: {
|
||||
type: 'smtpForward',
|
||||
smtpForward: {
|
||||
host: '10.0.0.2',
|
||||
port: 2525,
|
||||
},
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'token-user',
|
||||
};
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:read', 'gateway-clients:write'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
listMailAddressBindings: async (filter: any) => filter.owner?.appInstanceId === 'app-1' ? [binding] : [],
|
||||
syncMailAddressBinding: async (data: any, userId: string) => {
|
||||
syncedRequests.push({ binding: data, userId });
|
||||
return { success: true, binding };
|
||||
},
|
||||
deleteMailAddressBinding: async (id: string, userId: string) => {
|
||||
deletedRequests.push({ id, userId });
|
||||
return { success: true };
|
||||
},
|
||||
listWorkAppMailBindings: async () => [{
|
||||
id: 'workapp-mail-1',
|
||||
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
|
||||
enabled: true,
|
||||
status: 'active' as const,
|
||||
addressBindingIds: [binding.id],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'token-user',
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
owner: { appInstanceId: 'app-1' },
|
||||
});
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.bindings).toEqual([binding]);
|
||||
|
||||
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
binding,
|
||||
});
|
||||
expect(syncResult.error).toBeUndefined();
|
||||
expect(syncResult.response.success).toEqual(true);
|
||||
expect(syncedRequests[0].userId).toEqual('token-user');
|
||||
|
||||
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
owner: { appInstanceId: 'app-1' },
|
||||
});
|
||||
expect(workAppListResult.error).toBeUndefined();
|
||||
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-1']);
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
id: binding.id,
|
||||
});
|
||||
expect(deleteResult.error).toBeUndefined();
|
||||
expect(deleteResult.response.success).toEqual(true);
|
||||
expect(deletedRequests[0]).toEqual({ id: 'mail-1', userId: 'token-user' });
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler scopes shared mail handlers to gateway client token policy', async () => {
|
||||
const listFilters: any[] = [];
|
||||
const workAppFilters: any[] = [];
|
||||
const syncedRequests: Array<{ binding: any; userId: string }> = [];
|
||||
const deletedRequests: Array<{ id: string; userId: string }> = [];
|
||||
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
|
||||
id: 'mail-owned',
|
||||
owner: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-policy',
|
||||
appInstanceId: 'app-1',
|
||||
},
|
||||
address: 'hello@example.com',
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
enabled: true,
|
||||
status: 'active',
|
||||
inboundTarget: {
|
||||
type: 'smtpForward',
|
||||
smtpForward: {
|
||||
host: '10.0.0.2',
|
||||
port: 2525,
|
||||
},
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'token-user',
|
||||
};
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:read', 'gateway-clients:write'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [2525] }],
|
||||
capabilities: { syncRoutes: true },
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
listMailAddressBindings: async (filter: any) => {
|
||||
listFilters.push(filter);
|
||||
return filter.owner?.gatewayClientId === 'box-policy' ? [binding] : [];
|
||||
},
|
||||
syncMailAddressBinding: async (data: any, userId: string) => {
|
||||
syncedRequests.push({ binding: data, userId });
|
||||
return { success: true, binding: data };
|
||||
},
|
||||
deleteMailAddressBinding: async (id: string, userId: string) => {
|
||||
deletedRequests.push({ id, userId });
|
||||
return { success: true };
|
||||
},
|
||||
listWorkAppMailBindings: async (owner: any) => {
|
||||
workAppFilters.push(owner);
|
||||
return owner?.gatewayClientId === 'box-policy' ? [{
|
||||
id: 'workapp-mail-1',
|
||||
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
|
||||
enabled: true,
|
||||
status: 'active' as const,
|
||||
addressBindingIds: [binding.id],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'token-user',
|
||||
}] : [];
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
});
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.bindings).toEqual([binding]);
|
||||
expect(listFilters[0].owner.gatewayClientId).toEqual('box-policy');
|
||||
|
||||
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
owner: { appInstanceId: 'app-1' },
|
||||
});
|
||||
expect(workAppListResult.error).toBeUndefined();
|
||||
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-owned']);
|
||||
expect(workAppFilters[0].gatewayClientId).toEqual('box-policy');
|
||||
|
||||
const spoofResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
binding: {
|
||||
...binding,
|
||||
owner: { ...binding.owner, gatewayClientId: 'other-box' },
|
||||
},
|
||||
});
|
||||
expect(spoofResult.error).toBeUndefined();
|
||||
expect(spoofResult.response.success).toEqual(false);
|
||||
expect(spoofResult.response.message).toEqual('gateway client token cannot act for this ownership');
|
||||
|
||||
const blockedTargetResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
binding: {
|
||||
...binding,
|
||||
inboundTarget: {
|
||||
type: 'smtpForward',
|
||||
smtpForward: { host: '10.0.0.9', port: 2525 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(blockedTargetResult.error).toBeUndefined();
|
||||
expect(blockedTargetResult.response.success).toEqual(false);
|
||||
expect(blockedTargetResult.response.message).toEqual('mail target is outside token policy: 10.0.0.9:2525');
|
||||
|
||||
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
binding,
|
||||
});
|
||||
expect(syncResult.error).toBeUndefined();
|
||||
expect(syncResult.response.success).toEqual(true);
|
||||
expect(syncedRequests[0].binding.owner.gatewayClientId).toEqual('box-policy');
|
||||
|
||||
const skippedDeleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
id: 'mail-other',
|
||||
});
|
||||
expect(skippedDeleteResult.error).toBeUndefined();
|
||||
expect(skippedDeleteResult.response.success).toEqual(true);
|
||||
expect(deletedRequests.length).toEqual(0);
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
id: binding.id,
|
||||
});
|
||||
expect(deleteResult.error).toBeUndefined();
|
||||
expect(deleteResult.response.success).toEqual(true);
|
||||
expect(deletedRequests[0]).toEqual({ id: 'mail-owned', userId: 'token-user' });
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler rejects shared mail sync without gateway-clients:write', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:read'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
syncMailAddressBinding: async () => ({ success: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
|
||||
auth: { apiToken: 'valid-token' },
|
||||
binding: {
|
||||
owner: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
appInstanceId: 'app-1',
|
||||
},
|
||||
address: 'hello@example.com',
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '14.0.0',
|
||||
version: '14.1.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+127
-22
@@ -37,6 +37,8 @@ import type { IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed,
|
||||
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
baseDir?: string;
|
||||
@@ -647,6 +649,7 @@ export class DcRouter {
|
||||
},
|
||||
(preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
|
||||
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
|
||||
(routes) => this.applyInboundProxyProtocolPolicies(routes),
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
await this.apiTokenManager.initialize();
|
||||
@@ -1220,6 +1223,7 @@ export class DcRouter {
|
||||
routes = augmentRoutesWithHttp3(routes, http3Config);
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
routes = this.applyInboundProxyProtocolPolicies(routes);
|
||||
|
||||
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
@@ -1379,27 +1383,12 @@ export class DcRouter {
|
||||
};
|
||||
}
|
||||
|
||||
// When remoteIngress is enabled, the hub binary forwards tunneled connections
|
||||
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
||||
if (this.isRemoteIngressHubEnabled()) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
}
|
||||
|
||||
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
// RemoteIngress and VPN forward through localhost with PROXY protocol.
|
||||
// SmartProxy only uses this as a trust list; routes still opt in per listener.
|
||||
if (this.isRemoteIngressHubEnabled() || this.options.vpnConfig?.enabled) {
|
||||
const trustedProxyIPs = new Set(smartProxyConfig.trustedProxyIPs || []);
|
||||
trustedProxyIPs.add('127.0.0.1');
|
||||
smartProxyConfig.trustedProxyIPs = [...trustedProxyIPs];
|
||||
}
|
||||
|
||||
// Create SmartProxy instance
|
||||
@@ -1576,6 +1565,101 @@ export class DcRouter {
|
||||
|
||||
|
||||
|
||||
private applyInboundProxyProtocolPolicies(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
const policiesByListener = new Map<string, TInboundProxyProtocolPolicy>();
|
||||
|
||||
for (const route of routes) {
|
||||
const policy = route.match?.inboundProxyProtocol || this.getDesiredInboundProxyProtocolPolicy(route);
|
||||
if (!policy) {
|
||||
continue;
|
||||
}
|
||||
for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
|
||||
const mergedPolicy = this.mergeInboundProxyProtocolPolicies(
|
||||
policiesByListener.get(listenerKey),
|
||||
policy,
|
||||
);
|
||||
if (mergedPolicy) {
|
||||
policiesByListener.set(listenerKey, mergedPolicy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (policiesByListener.size === 0) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
return routes.map((route) => {
|
||||
if (route.match?.inboundProxyProtocol) {
|
||||
return route;
|
||||
}
|
||||
let listenerPolicy: TInboundProxyProtocolPolicy | undefined;
|
||||
for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
|
||||
listenerPolicy = this.mergeInboundProxyProtocolPolicies(
|
||||
listenerPolicy,
|
||||
policiesByListener.get(listenerKey),
|
||||
);
|
||||
}
|
||||
if (!listenerPolicy) {
|
||||
return route;
|
||||
}
|
||||
return {
|
||||
...route,
|
||||
match: {
|
||||
...route.match,
|
||||
inboundProxyProtocol: listenerPolicy,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getDesiredInboundProxyProtocolPolicy(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
): TInboundProxyProtocolPolicy | undefined {
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (this.isRemoteIngressHubEnabled() && dcRoute.remoteIngress?.enabled) {
|
||||
const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
|
||||
if (ports.some((port) => port === 25 || port === 587)) {
|
||||
return { mode: 'required' };
|
||||
}
|
||||
return { mode: 'optional' };
|
||||
}
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
return { mode: 'optional' };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getInboundProxyListenerKeys(route: plugins.smartproxy.IRouteConfig): string[] {
|
||||
const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
|
||||
const transports = route.match.transport === 'udp'
|
||||
? ['udp']
|
||||
: route.match.transport === 'all'
|
||||
? ['tcp', 'udp']
|
||||
: ['tcp'];
|
||||
const keys: string[] = [];
|
||||
for (const port of ports) {
|
||||
for (const transport of transports) {
|
||||
keys.push(`${transport}:${port}`);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private mergeInboundProxyProtocolPolicies(
|
||||
current?: TInboundProxyProtocolPolicy,
|
||||
next?: TInboundProxyProtocolPolicy,
|
||||
): TInboundProxyProtocolPolicy | undefined {
|
||||
if (!current) return next;
|
||||
if (!next) return current;
|
||||
if (current.mode === 'required') return current;
|
||||
if (next.mode === 'required') return next;
|
||||
if (current.mode === 'optional') return current;
|
||||
if (next.mode === 'optional') return next;
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SmartProxy routes for email configuration
|
||||
*/
|
||||
@@ -1657,13 +1741,18 @@ export class DcRouter {
|
||||
const routeConfig: IDcRouterRouteConfig = {
|
||||
name: routeName,
|
||||
match: {
|
||||
ports: [port]
|
||||
ports: [port],
|
||||
transport: 'tcp',
|
||||
},
|
||||
action: action
|
||||
};
|
||||
|
||||
if (this.isRemoteIngressHubEnabled()) {
|
||||
routeConfig.remoteIngress = { enabled: true };
|
||||
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
|
||||
if (inboundProxyProtocol) {
|
||||
routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the route to our list
|
||||
@@ -1764,8 +1853,15 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
const targetHost = target.host === 'localhost' ? '127.0.0.1' : target.host;
|
||||
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(routePorts[0]);
|
||||
return {
|
||||
...route,
|
||||
match: {
|
||||
...route.match,
|
||||
...(inboundProxyProtocol
|
||||
? { inboundProxyProtocol }
|
||||
: {}),
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
|
||||
@@ -1773,6 +1869,15 @@ export class DcRouter {
|
||||
};
|
||||
}
|
||||
|
||||
private getRemoteIngressEmailInboundProxyPolicy(
|
||||
port: number,
|
||||
): TInboundProxyProtocolPolicy | undefined {
|
||||
if (!this.isRemoteIngressHubEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
return { mode: port === 25 || port === 587 ? 'required' : 'optional' };
|
||||
}
|
||||
|
||||
private createEmailSocketProxyHandler(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
|
||||
@@ -68,6 +68,7 @@ export class RouteConfigManager {
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
) {}
|
||||
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
@@ -714,12 +715,15 @@ export class RouteConfigManager {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
let enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
if (this.applyInboundProxyPolicies) {
|
||||
enabledRoutes = this.applyInboundProxyPolicies(enabledRoutes);
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ import * as plugins from '../plugins.js';
|
||||
import type * as interfaces from '../../ts_interfaces/index.js';
|
||||
|
||||
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
|
||||
type TMailResourceOwner = plugins.servezoneInterfaces.data.IMailResourceOwner;
|
||||
type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
|
||||
type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
|
||||
type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
|
||||
type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
|
||||
type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
|
||||
|
||||
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
|
||||
smtpPassword: string;
|
||||
@@ -109,6 +115,89 @@ export class WorkAppMailManager {
|
||||
return response;
|
||||
}
|
||||
|
||||
public async listMailAddressBindings(options: {
|
||||
owner?: Partial<TMailResourceOwner>;
|
||||
domain?: string;
|
||||
address?: string;
|
||||
} = {}): Promise<TMailAddressBinding[]> {
|
||||
const domain = options.domain ? this.normalizeDomain(options.domain) : undefined;
|
||||
const address = options.address ? this.normalizeAddress(options.address) : undefined;
|
||||
const identities = await this.readStoredIdentities();
|
||||
|
||||
return identities
|
||||
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), options.owner))
|
||||
.filter((identity) => domain ? identity.domain === domain : true)
|
||||
.filter((identity) => address ? identity.address === address : true)
|
||||
.map((identity) => this.toMailAddressBinding(identity));
|
||||
}
|
||||
|
||||
public async listWorkAppMailBindings(
|
||||
owner?: Partial<TMailResourceOwner>,
|
||||
): Promise<TWorkAppMailBinding[]> {
|
||||
const identities = (await this.readStoredIdentities())
|
||||
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), owner));
|
||||
const groups = new Map<string, IStoredWorkAppMailIdentity[]>();
|
||||
|
||||
for (const identity of identities) {
|
||||
const ownerKey = this.buildMailOwnerKey(this.toMailOwner(identity.ownership));
|
||||
const group = groups.get(ownerKey) || [];
|
||||
group.push(identity);
|
||||
groups.set(ownerKey, group);
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).map((group) => this.toWorkAppMailBinding(group));
|
||||
}
|
||||
|
||||
public async syncMailAddressBinding(
|
||||
binding: TMailAddressBindingSync,
|
||||
createdBy: string,
|
||||
): Promise<TMailAddressBindingSyncResponse> {
|
||||
const ownership = this.normalizeMailResourceOwner(binding.owner);
|
||||
const { localPart, domain } = this.normalizeMailAddressParts(binding);
|
||||
const syncRequest: TSyncRequest = {
|
||||
ownership,
|
||||
localPart,
|
||||
domain,
|
||||
inbound: this.toLegacyInboundRoute(binding.inboundTarget),
|
||||
enabled: binding.enabled,
|
||||
};
|
||||
|
||||
if (binding.outboundIdentityId !== undefined) {
|
||||
syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
|
||||
}
|
||||
|
||||
const result = await this.syncMailIdentity(syncRequest, createdBy);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
binding: result.identity ? this.toMailAddressBinding(result.identity) : undefined,
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteMailAddressBinding(
|
||||
id: string,
|
||||
createdBy: string,
|
||||
): Promise<TMailAddressBindingDeleteResponse> {
|
||||
const identities = await this.readStoredIdentities();
|
||||
const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
|
||||
if (!identity) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const result = await this.syncMailIdentity({
|
||||
ownership: identity.ownership,
|
||||
localPart: identity.localPart,
|
||||
domain: identity.domain,
|
||||
delete: true,
|
||||
}, createdBy);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
|
||||
emailConfig: TConfig,
|
||||
): Promise<TConfig> {
|
||||
@@ -251,6 +340,63 @@ export class WorkAppMailManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeAddress(address: string): string {
|
||||
const normalized = address?.trim().toLowerCase();
|
||||
const [localPart, domain, extra] = normalized?.split('@') || [];
|
||||
if (!localPart || !domain || extra) {
|
||||
throw new Error(`Invalid email address: ${address}`);
|
||||
}
|
||||
return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
|
||||
}
|
||||
|
||||
private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
|
||||
const gatewayClientType = owner.gatewayClientType;
|
||||
const gatewayClientId = owner.gatewayClientId?.trim();
|
||||
const appInstanceId = owner.appInstanceId?.trim();
|
||||
|
||||
if (gatewayClientType !== 'onebox' && gatewayClientType !== 'cloudly' && gatewayClientType !== 'custom') {
|
||||
throw new Error(`Invalid gateway client type: ${gatewayClientType}`);
|
||||
}
|
||||
if (!gatewayClientId) throw new Error('gatewayClientId is required');
|
||||
if (!appInstanceId) throw new Error('appInstanceId is required');
|
||||
|
||||
return {
|
||||
workHosterType: gatewayClientType as interfaces.data.TGatewayClientType,
|
||||
workHosterId: gatewayClientId,
|
||||
workAppId: appInstanceId,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeMailAddressParts(binding: TMailAddressBindingSync): {
|
||||
localPart: string;
|
||||
domain: string;
|
||||
} {
|
||||
const localPart = this.normalizeLocalPart(binding.localPart);
|
||||
const domain = this.normalizeDomain(binding.domain);
|
||||
const address = this.normalizeAddress(binding.address);
|
||||
if (address !== `${localPart}@${domain}`) {
|
||||
throw new Error('mail address, localPart, and domain do not match');
|
||||
}
|
||||
return { localPart, domain };
|
||||
}
|
||||
|
||||
private toLegacyInboundRoute(
|
||||
inboundTarget?: TMailAddressBinding['inboundTarget'],
|
||||
): interfaces.data.IWorkAppMailInboundRoute | undefined {
|
||||
if (!inboundTarget) return undefined;
|
||||
if (inboundTarget.type !== 'smtpForward' || !inboundTarget.smtpForward) {
|
||||
throw new Error(`Unsupported WorkApp mail inbound target: ${inboundTarget.type}`);
|
||||
}
|
||||
|
||||
return this.normalizeInboundRoute({
|
||||
enabled: true,
|
||||
targetHost: inboundTarget.smtpForward.host,
|
||||
targetPort: inboundTarget.smtpForward.port,
|
||||
preserveHeaders: inboundTarget.smtpForward.preserveHeaders,
|
||||
addHeaders: inboundTarget.smtpForward.addHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeInboundRoute(
|
||||
inbound?: interfaces.data.IWorkAppMailInboundRoute,
|
||||
): interfaces.data.IWorkAppMailInboundRoute | undefined {
|
||||
@@ -282,6 +428,17 @@ export class WorkAppMailManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private matchesMailOwner(
|
||||
owner: TMailResourceOwner,
|
||||
filter?: Partial<TMailResourceOwner>,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.gatewayClientType && filter.gatewayClientType !== owner.gatewayClientType) return false;
|
||||
if (filter.gatewayClientId && filter.gatewayClientId !== owner.gatewayClientId) return false;
|
||||
if (filter.appInstanceId && filter.appInstanceId !== owner.appInstanceId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private buildExternalKey(
|
||||
ownership: interfaces.data.IWorkAppMailOwnership,
|
||||
address: string,
|
||||
@@ -298,6 +455,14 @@ export class WorkAppMailManager {
|
||||
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
|
||||
}
|
||||
|
||||
private buildMailOwnerKey(owner: TMailResourceOwner): string {
|
||||
return [
|
||||
owner.gatewayClientType,
|
||||
owner.gatewayClientId,
|
||||
owner.appInstanceId,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
private buildRouteName(externalKey: string): string {
|
||||
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
||||
}
|
||||
@@ -334,6 +499,75 @@ export class WorkAppMailManager {
|
||||
};
|
||||
}
|
||||
|
||||
private toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
|
||||
return {
|
||||
gatewayClientType: ownership.workHosterType,
|
||||
gatewayClientId: ownership.workHosterId,
|
||||
appInstanceId: ownership.workAppId,
|
||||
};
|
||||
}
|
||||
|
||||
private toMailInboundTarget(
|
||||
inbound?: interfaces.data.IWorkAppMailInboundRoute,
|
||||
): TMailAddressBinding['inboundTarget'] {
|
||||
if (!inbound?.enabled) return undefined;
|
||||
return {
|
||||
type: 'smtpForward',
|
||||
smtpForward: {
|
||||
host: inbound.targetHost,
|
||||
port: inbound.targetPort,
|
||||
preserveHeaders: inbound.preserveHeaders,
|
||||
addHeaders: inbound.addHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toMailAddressBinding(
|
||||
identity: interfaces.data.IWorkAppMailIdentity,
|
||||
): TMailAddressBinding {
|
||||
return {
|
||||
id: identity.id,
|
||||
owner: this.toMailOwner(identity.ownership),
|
||||
address: identity.address,
|
||||
localPart: identity.localPart,
|
||||
domain: identity.domain,
|
||||
enabled: identity.enabled,
|
||||
status: identity.enabled ? 'active' : 'disabled',
|
||||
inboundTarget: this.toMailInboundTarget(identity.inbound),
|
||||
outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
|
||||
recipientPolicy: {
|
||||
mode: 'staticList',
|
||||
staticRecipients: [identity.address],
|
||||
},
|
||||
createdAt: identity.createdAt,
|
||||
updatedAt: identity.updatedAt,
|
||||
createdBy: identity.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
private toWorkAppMailBinding(
|
||||
identities: IStoredWorkAppMailIdentity[],
|
||||
): TWorkAppMailBinding {
|
||||
const [firstIdentity] = identities;
|
||||
const owner = this.toMailOwner(firstIdentity.ownership);
|
||||
const enabledIdentities = identities.filter((identity) => identity.enabled);
|
||||
const smtpIdentities = identities.filter((identity) => identity.smtp.enabled);
|
||||
|
||||
return {
|
||||
id: `workapp-mail-${this.hashExternalKey(this.buildMailOwnerKey(owner)).slice(0, 32)}`,
|
||||
owner,
|
||||
enabled: enabledIdentities.length > 0,
|
||||
status: enabledIdentities.length > 0 ? 'active' : 'disabled',
|
||||
addressBindingIds: identities.map((identity) => identity.id),
|
||||
outboundIdentityIds: smtpIdentities.map((identity) => identity.smtp.username),
|
||||
defaultFrom: enabledIdentities[0]?.address || firstIdentity.address,
|
||||
inboundTarget: identities.length === 1 ? this.toMailInboundTarget(firstIdentity.inbound) : undefined,
|
||||
createdAt: Math.min(...identities.map((identity) => identity.createdAt)),
|
||||
updatedAt: Math.max(...identities.map((identity) => identity.updatedAt)),
|
||||
createdBy: firstIdentity.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicIdentity(
|
||||
identity: IStoredWorkAppMailIdentity,
|
||||
): interfaces.data.IWorkAppMailIdentity {
|
||||
|
||||
@@ -39,14 +39,7 @@ export class ConfigHandler {
|
||||
? 'custom'
|
||||
: 'filesystem';
|
||||
|
||||
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||
let proxyIps = opts.proxyIps || [];
|
||||
if (proxyIps.length === 0 && dcRouter.smartProxy) {
|
||||
const spSettings = (dcRouter.smartProxy as any).settings;
|
||||
if (spSettings?.proxyIPs?.length > 0) {
|
||||
proxyIps = spSettings.proxyIPs;
|
||||
}
|
||||
}
|
||||
const proxyIps = opts.proxyIps || [];
|
||||
|
||||
const system: interfaces.requests.IConfigData['system'] = {
|
||||
baseDir: resolvedPaths.dcrouterHomeDir,
|
||||
|
||||
@@ -257,6 +257,83 @@ export class WorkHosterHandler {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListMailAddressBindings>(
|
||||
'listMailAddressBindings',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||
if (!manager) return { bindings: [] };
|
||||
return {
|
||||
bindings: await manager.listMailAddressBindings({
|
||||
owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
|
||||
domain: dataArg.domain,
|
||||
address: dataArg.address,
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding>(
|
||||
'syncMailAddressBinding',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
|
||||
this.assertCapability(auth, 'syncRoutes');
|
||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'WorkApp mail manager not initialized' };
|
||||
}
|
||||
try {
|
||||
const binding = {
|
||||
...dataArg.binding,
|
||||
owner: this.resolveMailOwner(auth, dataArg.binding.owner),
|
||||
};
|
||||
this.assertMailForwardTargetAllowed(auth, binding.inboundTarget);
|
||||
return await manager.syncMailAddressBinding(binding, auth.userId);
|
||||
} catch (error) {
|
||||
return { success: false, message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding>(
|
||||
'deleteMailAddressBinding',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
|
||||
this.assertCapability(auth, 'syncRoutes');
|
||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'WorkApp mail manager not initialized' };
|
||||
}
|
||||
if (auth.token?.policy?.role === 'gatewayClient') {
|
||||
const bindings = await manager.listMailAddressBindings({
|
||||
owner: this.resolveMailOwnerFilter(auth),
|
||||
});
|
||||
const binding = bindings.find((candidate) => candidate.id === dataArg.id);
|
||||
if (!binding) return { success: true };
|
||||
return await manager.deleteMailAddressBinding(binding.id, auth.userId);
|
||||
}
|
||||
return await manager.deleteMailAddressBinding(dataArg.id, auth.userId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListWorkAppMailBindings>(
|
||||
'listWorkAppMailBindings',
|
||||
async (dataArg) => {
|
||||
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||
if (!manager) return { bindings: [] };
|
||||
return { bindings: await manager.listWorkAppMailBindings(this.resolveMailOwnerFilter(auth, dataArg.owner)) };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
|
||||
@@ -335,7 +412,7 @@ export class WorkHosterHandler {
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient') return;
|
||||
if (policy.capabilities?.[capability] === true) return;
|
||||
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
|
||||
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`);
|
||||
}
|
||||
|
||||
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
|
||||
@@ -376,6 +453,39 @@ export class WorkHosterHandler {
|
||||
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
|
||||
}
|
||||
|
||||
private resolveMailOwnerFilter(
|
||||
auth: TAuthContext,
|
||||
owner?: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner>,
|
||||
): Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined {
|
||||
const policy = auth.token?.policy;
|
||||
if (policy?.role !== 'gatewayClient') return owner;
|
||||
if (!policy.gatewayClient) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
|
||||
}
|
||||
if (owner?.gatewayClientType && owner.gatewayClientType !== policy.gatewayClient.type) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
|
||||
}
|
||||
if (owner?.gatewayClientId && owner.gatewayClientId !== policy.gatewayClient.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
|
||||
}
|
||||
return {
|
||||
...owner,
|
||||
gatewayClientType: policy.gatewayClient.type,
|
||||
gatewayClientId: policy.gatewayClient.id,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveMailOwner(
|
||||
auth: TAuthContext,
|
||||
owner: plugins.servezoneInterfaces.data.IMailResourceOwner,
|
||||
): plugins.servezoneInterfaces.data.IMailResourceOwner {
|
||||
const resolvedOwner = this.resolveMailOwnerFilter(auth, owner);
|
||||
if (!resolvedOwner?.gatewayClientType || !resolvedOwner.gatewayClientId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('mail owner is missing gateway client type or id');
|
||||
}
|
||||
return resolvedOwner as plugins.servezoneInterfaces.data.IMailResourceOwner;
|
||||
}
|
||||
|
||||
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient') return;
|
||||
@@ -404,6 +514,26 @@ export class WorkHosterHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private assertMailForwardTargetAllowed(
|
||||
auth: TAuthContext,
|
||||
target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
|
||||
): void {
|
||||
const policy = auth.token?.policy;
|
||||
if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return;
|
||||
const allowedTargets = policy.allowedRouteTargets || [];
|
||||
if (allowedTargets.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
|
||||
}
|
||||
const host = target.smtpForward.host.trim().toLowerCase();
|
||||
const port = Number(target.smtpForward.port);
|
||||
const allowed = allowedTargets.some((allowedTarget) => {
|
||||
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
|
||||
});
|
||||
if (!allowed) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${host}:${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
|
||||
const normalizedHostname = hostname.trim().toLowerCase();
|
||||
if (!normalizedHostname) return false;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '14.0.0',
|
||||
version: '14.1.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user