@serve.zone/dcrouter 13.7.1 → 13.8.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/dist_serve/bundle.js +707 -563
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/acme/index.d.ts +1 -0
- package/dist_ts/acme/index.js +2 -0
- package/dist_ts/acme/manager.acme-config.d.ts +48 -0
- package/dist_ts/acme/manager.acme-config.js +156 -0
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +58 -20
- package/dist_ts/db/documents/classes.acme-config.doc.d.ts +22 -0
- package/dist_ts/db/documents/classes.acme-config.doc.js +121 -0
- package/dist_ts/db/documents/index.d.ts +1 -0
- package/dist_ts/db/documents/index.js +3 -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/acme-config.handler.d.ts +16 -0
- package/dist_ts/opsserver/handlers/acme-config.handler.js +77 -0
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts_interfaces/data/acme-config.d.ts +25 -0
- package/dist_ts_interfaces/data/acme-config.js +2 -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 +1 -1
- package/dist_ts_interfaces/requests/acme-config.d.ts +42 -0
- package/dist_ts_interfaces/requests/acme-config.js +2 -0
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.d.ts +15 -0
- package/dist_ts_web/appstate.js +60 -1
- package/dist_ts_web/elements/domains/ops-view-certificates.d.ts +3 -0
- package/dist_ts_web/elements/domains/ops-view-certificates.js +208 -4
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/index.ts +1 -0
- package/ts/acme/manager.acme-config.ts +182 -0
- package/ts/classes.dcrouter.ts +72 -25
- package/ts/db/documents/classes.acme-config.doc.ts +49 -0
- package/ts/db/documents/index.ts +3 -0
- package/ts/opsserver/classes.opsserver.ts +2 -0
- package/ts/opsserver/handlers/acme-config.handler.ts +94 -0
- package/ts/opsserver/handlers/index.ts +2 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +88 -0
- package/ts_web/elements/domains/ops-view-certificates.ts +205 -2
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, Targe
|
|
|
28
28
|
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
|
29
29
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
|
30
30
|
import { DnsManager } from './dns/manager.dns.js';
|
|
31
|
+
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
|
31
32
|
|
|
32
33
|
export interface IDcRouterOptions {
|
|
33
34
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
|
@@ -276,6 +277,9 @@ export class DcRouter {
|
|
|
276
277
|
// Domain / DNS management (DB-backed providers, domains, records)
|
|
277
278
|
public dnsManager?: DnsManager;
|
|
278
279
|
|
|
280
|
+
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
|
281
|
+
public acmeConfigManager?: AcmeConfigManager;
|
|
282
|
+
|
|
279
283
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
|
280
284
|
public detectedPublicIp: string | null = null;
|
|
281
285
|
|
|
@@ -412,11 +416,35 @@ export class DcRouter {
|
|
|
412
416
|
);
|
|
413
417
|
}
|
|
414
418
|
|
|
415
|
-
//
|
|
419
|
+
// AcmeConfigManager: optional, depends on DcRouterDb — owns the singleton
|
|
420
|
+
// ACME configuration (accountEmail, useProduction, etc.). Must run before
|
|
421
|
+
// SmartProxy so setupSmartProxy() can read the ACME config from the DB.
|
|
422
|
+
// On first boot, seeds from legacy `tls.contactEmail` / `smartProxyConfig.acme`.
|
|
423
|
+
if (this.options.dbConfig?.enabled !== false) {
|
|
424
|
+
this.serviceManager.addService(
|
|
425
|
+
new plugins.taskbuffer.Service('AcmeConfigManager')
|
|
426
|
+
.optional()
|
|
427
|
+
.dependsOn('DcRouterDb')
|
|
428
|
+
.withStart(async () => {
|
|
429
|
+
this.acmeConfigManager = new AcmeConfigManager(this.options);
|
|
430
|
+
await this.acmeConfigManager.start();
|
|
431
|
+
})
|
|
432
|
+
.withStop(async () => {
|
|
433
|
+
if (this.acmeConfigManager) {
|
|
434
|
+
await this.acmeConfigManager.stop();
|
|
435
|
+
this.acmeConfigManager = undefined;
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
|
416
443
|
const smartProxyDeps: string[] = [];
|
|
417
444
|
if (this.options.dbConfig?.enabled !== false) {
|
|
418
445
|
smartProxyDeps.push('DcRouterDb');
|
|
419
446
|
smartProxyDeps.push('DnsManager');
|
|
447
|
+
smartProxyDeps.push('AcmeConfigManager');
|
|
420
448
|
}
|
|
421
449
|
this.serviceManager.addService(
|
|
422
450
|
new plugins.taskbuffer.Service('SmartProxy')
|
|
@@ -837,45 +865,62 @@ export class DcRouter {
|
|
|
837
865
|
}
|
|
838
866
|
|
|
839
867
|
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
//
|
|
868
|
+
|
|
869
|
+
// If user provides full SmartProxy config, use its routes.
|
|
870
|
+
// NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
|
|
871
|
+
// AcmeConfigManager on first boot. The live ACME config always comes
|
|
872
|
+
// from the DB via `this.acmeConfigManager.getConfig()`.
|
|
843
873
|
if (this.options.smartProxyConfig) {
|
|
844
874
|
routes = this.options.smartProxyConfig.routes || [];
|
|
845
|
-
|
|
846
|
-
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
|
875
|
+
logger.log('info', `Found ${routes.length} routes in config`);
|
|
847
876
|
}
|
|
848
|
-
|
|
877
|
+
|
|
849
878
|
// If email config exists, automatically add email routes
|
|
850
879
|
if (this.options.emailConfig) {
|
|
851
880
|
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
|
852
881
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
|
853
882
|
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
|
854
883
|
}
|
|
855
|
-
|
|
884
|
+
|
|
856
885
|
// If DNS is configured, add DNS routes
|
|
857
886
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
|
858
887
|
const dnsRoutes = this.generateDnsRoutes();
|
|
859
888
|
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
|
860
889
|
routes = [...routes, ...dnsRoutes];
|
|
861
890
|
}
|
|
862
|
-
|
|
863
|
-
//
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
891
|
+
|
|
892
|
+
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
|
893
|
+
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
|
894
|
+
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
|
895
|
+
const dbAcme = this.acmeConfigManager?.getConfig();
|
|
896
|
+
const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
|
|
897
|
+
dbAcme && dbAcme.enabled
|
|
898
|
+
? {
|
|
899
|
+
accountEmail: dbAcme.accountEmail,
|
|
900
|
+
enabled: true,
|
|
901
|
+
useProduction: dbAcme.useProduction,
|
|
902
|
+
autoRenew: dbAcme.autoRenew,
|
|
903
|
+
renewThresholdDays: dbAcme.renewThresholdDays,
|
|
904
|
+
}
|
|
905
|
+
: undefined;
|
|
906
|
+
if (acmeConfig) {
|
|
907
|
+
logger.log(
|
|
908
|
+
'info',
|
|
909
|
+
`ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`,
|
|
910
|
+
);
|
|
911
|
+
} else {
|
|
912
|
+
logger.log('info', 'ACME config: disabled or not yet configured in DB');
|
|
872
913
|
}
|
|
873
|
-
|
|
874
|
-
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB
|
|
875
|
-
// The DnsManager dispatches each challenge to the right
|
|
876
|
-
// based on the FQDN being certificated.
|
|
914
|
+
|
|
915
|
+
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
|
916
|
+
// ACME is enabled. The DnsManager dispatches each challenge to the right
|
|
917
|
+
// provider client based on the FQDN being certificated.
|
|
877
918
|
let challengeHandlers: any[] = [];
|
|
878
|
-
if (
|
|
919
|
+
if (
|
|
920
|
+
acmeConfig &&
|
|
921
|
+
this.dnsManager &&
|
|
922
|
+
(await this.dnsManager.hasAcmeCapableProvider())
|
|
923
|
+
) {
|
|
879
924
|
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
|
|
880
925
|
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
|
881
926
|
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
|
@@ -977,10 +1022,12 @@ export class DcRouter {
|
|
|
977
1022
|
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
|
978
1023
|
);
|
|
979
1024
|
}
|
|
1025
|
+
// Safe non-null: challengeHandlers.length > 0 implies both dnsManager
|
|
1026
|
+
// and acmeConfig exist (enforced above).
|
|
980
1027
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
981
|
-
accountEmail:
|
|
1028
|
+
accountEmail: dbAcme!.accountEmail,
|
|
982
1029
|
certManager: new StorageBackedCertManager(),
|
|
983
|
-
environment: 'production',
|
|
1030
|
+
environment: dbAcme!.useProduction ? 'production' : 'integration',
|
|
984
1031
|
challengeHandlers: challengeHandlers,
|
|
985
1032
|
challengePriority: ['dns-01'],
|
|
986
1033
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
|
+
|
|
4
|
+
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Singleton ACME configuration document. One row per dcrouter instance,
|
|
8
|
+
* keyed on the fixed `configId = 'acme-config'` following the
|
|
9
|
+
* `VpnServerKeysDoc` pattern.
|
|
10
|
+
*
|
|
11
|
+
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
|
12
|
+
* constructor fields. Managed via the OpsServer UI at
|
|
13
|
+
* **Domains > Certificates > Settings**.
|
|
14
|
+
*/
|
|
15
|
+
@plugins.smartdata.Collection(() => getDb())
|
|
16
|
+
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
|
17
|
+
@plugins.smartdata.unI()
|
|
18
|
+
@plugins.smartdata.svDb()
|
|
19
|
+
public configId: string = 'acme-config';
|
|
20
|
+
|
|
21
|
+
@plugins.smartdata.svDb()
|
|
22
|
+
public accountEmail: string = '';
|
|
23
|
+
|
|
24
|
+
@plugins.smartdata.svDb()
|
|
25
|
+
public enabled: boolean = true;
|
|
26
|
+
|
|
27
|
+
@plugins.smartdata.svDb()
|
|
28
|
+
public useProduction: boolean = true;
|
|
29
|
+
|
|
30
|
+
@plugins.smartdata.svDb()
|
|
31
|
+
public autoRenew: boolean = true;
|
|
32
|
+
|
|
33
|
+
@plugins.smartdata.svDb()
|
|
34
|
+
public renewThresholdDays: number = 30;
|
|
35
|
+
|
|
36
|
+
@plugins.smartdata.svDb()
|
|
37
|
+
public updatedAt: number = 0;
|
|
38
|
+
|
|
39
|
+
@plugins.smartdata.svDb()
|
|
40
|
+
public updatedBy: string = '';
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public static async load(): Promise<AcmeConfigDoc | null> {
|
|
47
|
+
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
|
|
48
|
+
}
|
|
49
|
+
}
|
package/ts/db/documents/index.ts
CHANGED
|
@@ -30,3 +30,6 @@ export * from './classes.accounting-session.doc.js';
|
|
|
30
30
|
export * from './classes.dns-provider.doc.js';
|
|
31
31
|
export * from './classes.domain.doc.js';
|
|
32
32
|
export * from './classes.dns-record.doc.js';
|
|
33
|
+
|
|
34
|
+
// ACME configuration (singleton)
|
|
35
|
+
export * from './classes.acme-config.doc.js';
|
|
@@ -36,6 +36,7 @@ export class OpsServer {
|
|
|
36
36
|
private dnsProviderHandler!: handlers.DnsProviderHandler;
|
|
37
37
|
private domainHandler!: handlers.DomainHandler;
|
|
38
38
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
|
39
|
+
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
|
39
40
|
|
|
40
41
|
constructor(dcRouterRefArg: DcRouter) {
|
|
41
42
|
this.dcRouterRef = dcRouterRefArg;
|
|
@@ -102,6 +103,7 @@ export class OpsServer {
|
|
|
102
103
|
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
|
|
103
104
|
this.domainHandler = new handlers.DomainHandler(this);
|
|
104
105
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
|
106
|
+
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
|
105
107
|
|
|
106
108
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
|
107
109
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { OpsServer } from '../classes.opsserver.js';
|
|
3
|
+
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CRUD handler for the singleton `AcmeConfigDoc`.
|
|
7
|
+
*
|
|
8
|
+
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
|
|
9
|
+
* with `acme-config:read` / `acme-config:write` scope.
|
|
10
|
+
*/
|
|
11
|
+
export class AcmeConfigHandler {
|
|
12
|
+
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
13
|
+
|
|
14
|
+
constructor(private opsServerRef: OpsServer) {
|
|
15
|
+
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
16
|
+
this.registerHandlers();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private async requireAuth(
|
|
20
|
+
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
|
21
|
+
requiredScope?: interfaces.data.TApiTokenScope,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
if (request.identity?.jwt) {
|
|
24
|
+
try {
|
|
25
|
+
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
|
26
|
+
identity: request.identity,
|
|
27
|
+
});
|
|
28
|
+
if (isAdmin) return request.identity.userId;
|
|
29
|
+
} catch { /* fall through */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (request.apiToken) {
|
|
33
|
+
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
|
34
|
+
if (tokenManager) {
|
|
35
|
+
const token = await tokenManager.validateToken(request.apiToken);
|
|
36
|
+
if (token) {
|
|
37
|
+
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
|
38
|
+
return token.createdBy;
|
|
39
|
+
}
|
|
40
|
+
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private registerHandlers(): void {
|
|
49
|
+
// Get current ACME config
|
|
50
|
+
this.typedrouter.addTypedHandler(
|
|
51
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
|
|
52
|
+
'getAcmeConfig',
|
|
53
|
+
async (dataArg) => {
|
|
54
|
+
await this.requireAuth(dataArg, 'acme-config:read');
|
|
55
|
+
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
|
56
|
+
if (!mgr) return { config: null };
|
|
57
|
+
return { config: mgr.getConfig() };
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Update (upsert) the ACME config
|
|
63
|
+
this.typedrouter.addTypedHandler(
|
|
64
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
|
|
65
|
+
'updateAcmeConfig',
|
|
66
|
+
async (dataArg) => {
|
|
67
|
+
const userId = await this.requireAuth(dataArg, 'acme-config:write');
|
|
68
|
+
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
|
69
|
+
if (!mgr) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
message: 'AcmeConfigManager not initialized (DB disabled?)',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const updated = await mgr.updateConfig(
|
|
77
|
+
{
|
|
78
|
+
accountEmail: dataArg.accountEmail,
|
|
79
|
+
enabled: dataArg.enabled,
|
|
80
|
+
useProduction: dataArg.useProduction,
|
|
81
|
+
autoRenew: dataArg.autoRenew,
|
|
82
|
+
renewThresholdDays: dataArg.renewThresholdDays,
|
|
83
|
+
},
|
|
84
|
+
userId,
|
|
85
|
+
);
|
|
86
|
+
return { success: true, config: updated };
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
return { success: false, message: (err as Error).message };
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -16,4 +16,5 @@ export * from './network-target.handler.js';
|
|
|
16
16
|
export * from './users.handler.js';
|
|
17
17
|
export * from './dns-provider.handler.js';
|
|
18
18
|
export * from './domain.handler.js';
|
|
19
|
-
export * from './dns-record.handler.js';
|
|
19
|
+
export * from './dns-record.handler.js';
|
|
20
|
+
export * from './acme-config.handler.js';
|
package/ts_web/appstate.ts
CHANGED
|
@@ -197,6 +197,28 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
|
|
|
197
197
|
'soft'
|
|
198
198
|
);
|
|
199
199
|
|
|
200
|
+
// ============================================================================
|
|
201
|
+
// ACME Config State (DB-backed singleton, managed via Domains > Certificates)
|
|
202
|
+
// ============================================================================
|
|
203
|
+
|
|
204
|
+
export interface IAcmeConfigState {
|
|
205
|
+
config: interfaces.data.IAcmeConfig | null;
|
|
206
|
+
isLoading: boolean;
|
|
207
|
+
error: string | null;
|
|
208
|
+
lastUpdated: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const acmeConfigStatePart = await appState.getStatePart<IAcmeConfigState>(
|
|
212
|
+
'acmeConfig',
|
|
213
|
+
{
|
|
214
|
+
config: null,
|
|
215
|
+
isLoading: false,
|
|
216
|
+
error: null,
|
|
217
|
+
lastUpdated: 0,
|
|
218
|
+
},
|
|
219
|
+
'soft',
|
|
220
|
+
);
|
|
221
|
+
|
|
200
222
|
// ============================================================================
|
|
201
223
|
// Remote Ingress State
|
|
202
224
|
// ============================================================================
|
|
@@ -1953,6 +1975,72 @@ export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string;
|
|
|
1953
1975
|
},
|
|
1954
1976
|
);
|
|
1955
1977
|
|
|
1978
|
+
// ============================================================================
|
|
1979
|
+
// ACME Config Actions
|
|
1980
|
+
// ============================================================================
|
|
1981
|
+
|
|
1982
|
+
export const fetchAcmeConfigAction = acmeConfigStatePart.createAction(
|
|
1983
|
+
async (statePartArg): Promise<IAcmeConfigState> => {
|
|
1984
|
+
const context = getActionContext();
|
|
1985
|
+
const currentState = statePartArg.getState()!;
|
|
1986
|
+
if (!context.identity) return currentState;
|
|
1987
|
+
|
|
1988
|
+
try {
|
|
1989
|
+
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
1990
|
+
interfaces.requests.IReq_GetAcmeConfig
|
|
1991
|
+
>('/typedrequest', 'getAcmeConfig');
|
|
1992
|
+
const response = await request.fire({ identity: context.identity });
|
|
1993
|
+
return {
|
|
1994
|
+
config: response.config,
|
|
1995
|
+
isLoading: false,
|
|
1996
|
+
error: null,
|
|
1997
|
+
lastUpdated: Date.now(),
|
|
1998
|
+
};
|
|
1999
|
+
} catch (error: unknown) {
|
|
2000
|
+
return {
|
|
2001
|
+
...currentState,
|
|
2002
|
+
isLoading: false,
|
|
2003
|
+
error: error instanceof Error ? error.message : 'Failed to fetch ACME config',
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
);
|
|
2008
|
+
|
|
2009
|
+
export const updateAcmeConfigAction = acmeConfigStatePart.createAction<{
|
|
2010
|
+
accountEmail?: string;
|
|
2011
|
+
enabled?: boolean;
|
|
2012
|
+
useProduction?: boolean;
|
|
2013
|
+
autoRenew?: boolean;
|
|
2014
|
+
renewThresholdDays?: number;
|
|
2015
|
+
}>(async (statePartArg, dataArg, actionContext): Promise<IAcmeConfigState> => {
|
|
2016
|
+
const context = getActionContext();
|
|
2017
|
+
try {
|
|
2018
|
+
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
2019
|
+
interfaces.requests.IReq_UpdateAcmeConfig
|
|
2020
|
+
>('/typedrequest', 'updateAcmeConfig');
|
|
2021
|
+
const response = await request.fire({
|
|
2022
|
+
identity: context.identity!,
|
|
2023
|
+
accountEmail: dataArg.accountEmail,
|
|
2024
|
+
enabled: dataArg.enabled,
|
|
2025
|
+
useProduction: dataArg.useProduction,
|
|
2026
|
+
autoRenew: dataArg.autoRenew,
|
|
2027
|
+
renewThresholdDays: dataArg.renewThresholdDays,
|
|
2028
|
+
});
|
|
2029
|
+
if (!response.success) {
|
|
2030
|
+
return {
|
|
2031
|
+
...statePartArg.getState()!,
|
|
2032
|
+
error: response.message || 'Failed to update ACME config',
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
return await actionContext!.dispatch(fetchAcmeConfigAction, null);
|
|
2036
|
+
} catch (error: unknown) {
|
|
2037
|
+
return {
|
|
2038
|
+
...statePartArg.getState()!,
|
|
2039
|
+
error: error instanceof Error ? error.message : 'Failed to update ACME config',
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
});
|
|
2043
|
+
|
|
1956
2044
|
// ============================================================================
|
|
1957
2045
|
// Route Management Actions
|
|
1958
2046
|
// ============================================================================
|
|
@@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
23
23
|
@state()
|
|
24
24
|
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
|
|
25
25
|
|
|
26
|
+
@state()
|
|
27
|
+
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
|
|
28
|
+
|
|
26
29
|
constructor() {
|
|
27
30
|
super();
|
|
28
|
-
const
|
|
31
|
+
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
|
|
29
32
|
this.certState = newState;
|
|
30
33
|
});
|
|
31
|
-
this.rxSubscriptions.push(
|
|
34
|
+
this.rxSubscriptions.push(certSub);
|
|
35
|
+
const acmeSub = appstate.acmeConfigStatePart.select().subscribe((newState) => {
|
|
36
|
+
this.acmeState = newState;
|
|
37
|
+
});
|
|
38
|
+
this.rxSubscriptions.push(acmeSub);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
async connectedCallback() {
|
|
35
42
|
await super.connectedCallback();
|
|
36
43
|
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
|
44
|
+
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
public static styles = [
|
|
@@ -46,6 +54,62 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
46
54
|
gap: 24px;
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
.acmeCard {
|
|
58
|
+
padding: 16px 20px;
|
|
59
|
+
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
|
60
|
+
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
|
61
|
+
border-radius: 8px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.acmeCard.acmeCardEmpty {
|
|
65
|
+
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
|
66
|
+
border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.acmeCardHeader {
|
|
70
|
+
display: flex;
|
|
71
|
+
justify-content: space-between;
|
|
72
|
+
align-items: center;
|
|
73
|
+
margin-bottom: 12px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.acmeCardTitle {
|
|
77
|
+
font-size: 14px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.acmeGrid {
|
|
83
|
+
display: grid;
|
|
84
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
85
|
+
gap: 12px 24px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.acmeField {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
gap: 2px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.acmeLabel {
|
|
95
|
+
font-size: 11px;
|
|
96
|
+
text-transform: uppercase;
|
|
97
|
+
letter-spacing: 0.03em;
|
|
98
|
+
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.acmeValue {
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.acmeEmptyHint {
|
|
107
|
+
margin: 0;
|
|
108
|
+
font-size: 13px;
|
|
109
|
+
line-height: 1.5;
|
|
110
|
+
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
|
|
111
|
+
}
|
|
112
|
+
|
|
49
113
|
.statusBadge {
|
|
50
114
|
display: inline-flex;
|
|
51
115
|
align-items: center;
|
|
@@ -162,12 +226,151 @@ export class OpsViewCertificates extends DeesElement {
|
|
|
162
226
|
<dees-heading level="3">Certificates</dees-heading>
|
|
163
227
|
|
|
164
228
|
<div class="certificatesContainer">
|
|
229
|
+
${this.renderAcmeSettingsCard()}
|
|
165
230
|
${this.renderStatsTiles(summary)}
|
|
166
231
|
${this.renderCertificateTable()}
|
|
167
232
|
</div>
|
|
168
233
|
`;
|
|
169
234
|
}
|
|
170
235
|
|
|
236
|
+
private renderAcmeSettingsCard(): TemplateResult {
|
|
237
|
+
const config = this.acmeState.config;
|
|
238
|
+
|
|
239
|
+
if (!config) {
|
|
240
|
+
return html`
|
|
241
|
+
<div class="acmeCard acmeCardEmpty">
|
|
242
|
+
<div class="acmeCardHeader">
|
|
243
|
+
<span class="acmeCardTitle">ACME Settings</span>
|
|
244
|
+
<dees-button
|
|
245
|
+
eventName="edit-acme"
|
|
246
|
+
@click=${() => this.showEditAcmeDialog()}
|
|
247
|
+
.type=${'highlighted'}
|
|
248
|
+
>Configure</dees-button>
|
|
249
|
+
</div>
|
|
250
|
+
<p class="acmeEmptyHint">
|
|
251
|
+
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
|
|
252
|
+
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
|
|
253
|
+
under <strong>Domains > Providers</strong>.
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return html`
|
|
260
|
+
<div class="acmeCard">
|
|
261
|
+
<div class="acmeCardHeader">
|
|
262
|
+
<span class="acmeCardTitle">ACME Settings</span>
|
|
263
|
+
<dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="acmeGrid">
|
|
266
|
+
<div class="acmeField">
|
|
267
|
+
<span class="acmeLabel">Account email</span>
|
|
268
|
+
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="acmeField">
|
|
271
|
+
<span class="acmeLabel">Status</span>
|
|
272
|
+
<span class="acmeValue">
|
|
273
|
+
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
|
|
274
|
+
${config.enabled ? 'enabled' : 'disabled'}
|
|
275
|
+
</span>
|
|
276
|
+
</span>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="acmeField">
|
|
279
|
+
<span class="acmeLabel">Mode</span>
|
|
280
|
+
<span class="acmeValue">
|
|
281
|
+
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
|
|
282
|
+
${config.useProduction ? 'production' : 'staging'}
|
|
283
|
+
</span>
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="acmeField">
|
|
287
|
+
<span class="acmeLabel">Auto-renew</span>
|
|
288
|
+
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="acmeField">
|
|
291
|
+
<span class="acmeLabel">Renewal threshold</span>
|
|
292
|
+
<span class="acmeValue">${config.renewThresholdDays} days</span>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async showEditAcmeDialog() {
|
|
300
|
+
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
|
301
|
+
const current = this.acmeState.config;
|
|
302
|
+
|
|
303
|
+
DeesModal.createAndShow({
|
|
304
|
+
heading: current ? 'Edit ACME Settings' : 'Configure ACME',
|
|
305
|
+
content: html`
|
|
306
|
+
<dees-form>
|
|
307
|
+
<dees-input-text
|
|
308
|
+
.key=${'accountEmail'}
|
|
309
|
+
.label=${'Account email'}
|
|
310
|
+
.value=${current?.accountEmail ?? ''}
|
|
311
|
+
.required=${true}
|
|
312
|
+
></dees-input-text>
|
|
313
|
+
<dees-input-checkbox
|
|
314
|
+
.key=${'enabled'}
|
|
315
|
+
.label=${'Enabled'}
|
|
316
|
+
.value=${current?.enabled ?? true}
|
|
317
|
+
></dees-input-checkbox>
|
|
318
|
+
<dees-input-checkbox
|
|
319
|
+
.key=${'useProduction'}
|
|
320
|
+
.label=${"Use Let's Encrypt production (uncheck for staging)"}
|
|
321
|
+
.value=${current?.useProduction ?? true}
|
|
322
|
+
></dees-input-checkbox>
|
|
323
|
+
<dees-input-checkbox
|
|
324
|
+
.key=${'autoRenew'}
|
|
325
|
+
.label=${'Auto-renew certificates'}
|
|
326
|
+
.value=${current?.autoRenew ?? true}
|
|
327
|
+
></dees-input-checkbox>
|
|
328
|
+
<dees-input-text
|
|
329
|
+
.key=${'renewThresholdDays'}
|
|
330
|
+
.label=${'Renewal threshold (days)'}
|
|
331
|
+
.value=${String(current?.renewThresholdDays ?? 30)}
|
|
332
|
+
></dees-input-text>
|
|
333
|
+
</dees-form>
|
|
334
|
+
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
|
335
|
+
Most fields take effect on the next dcrouter restart (SmartAcme is instantiated once at
|
|
336
|
+
startup). Changing the account email creates a new Let's Encrypt account — only do this
|
|
337
|
+
if you know what you're doing.
|
|
338
|
+
</p>
|
|
339
|
+
`,
|
|
340
|
+
menuOptions: [
|
|
341
|
+
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
342
|
+
{
|
|
343
|
+
name: 'Save',
|
|
344
|
+
action: async (modalArg: any) => {
|
|
345
|
+
const form = modalArg.shadowRoot
|
|
346
|
+
?.querySelector('.content')
|
|
347
|
+
?.querySelector('dees-form');
|
|
348
|
+
if (!form) return;
|
|
349
|
+
const data = await form.collectFormData();
|
|
350
|
+
const email = String(data.accountEmail ?? '').trim();
|
|
351
|
+
if (!email) {
|
|
352
|
+
DeesToast.show({
|
|
353
|
+
message: 'Account email is required',
|
|
354
|
+
type: 'warning',
|
|
355
|
+
duration: 2500,
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const threshold = parseInt(String(data.renewThresholdDays ?? '30'), 10);
|
|
360
|
+
await appstate.acmeConfigStatePart.dispatchAction(appstate.updateAcmeConfigAction, {
|
|
361
|
+
accountEmail: email,
|
|
362
|
+
enabled: Boolean(data.enabled),
|
|
363
|
+
useProduction: Boolean(data.useProduction),
|
|
364
|
+
autoRenew: Boolean(data.autoRenew),
|
|
365
|
+
renewThresholdDays: Number.isFinite(threshold) ? threshold : 30,
|
|
366
|
+
});
|
|
367
|
+
modalArg.destroy();
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
171
374
|
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
|
|
172
375
|
const tiles: IStatsTile[] = [
|
|
173
376
|
{
|