feat(config): add reusable security profiles and network targets with route reference resolution

This commit is contained in:
2026-04-02 15:44:36 +00:00
parent 6344c2deae
commit 55699f6618
31 changed files with 2845 additions and 12 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.1.0',
version: '12.2.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -110,7 +110,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
// Determine initial view from URL path
const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'];
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'securityprofiles', 'networktargets'];
const segments = path.split('/').filter(Boolean);
const view = segments[0];
return validViews.includes(view) ? view : 'overview';
@@ -444,6 +444,13 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
}, 100);
}
// If switching to security profiles or network targets views, fetch profiles/targets data
if ((viewName === 'securityprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
setTimeout(() => {
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
}, 100);
}
return {
...currentState,
activeView: viewName,
@@ -1133,6 +1140,241 @@ export const clearNewClientConfigAction = vpnStatePart.createAction(
},
);
// ============================================================================
// Security Profiles & Network Targets State
// ============================================================================
export interface IProfilesTargetsState {
profiles: interfaces.data.ISecurityProfile[];
targets: interfaces.data.INetworkTarget[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const profilesTargetsStatePart = await appState.getStatePart<IProfilesTargetsState>(
'profilesTargets',
{
profiles: [],
targets: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft'
);
// ============================================================================
// Security Profiles & Network Targets Actions
// ============================================================================
export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction(
async (statePartArg): Promise<IProfilesTargetsState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecurityProfiles
>('/typedrequest', 'getSecurityProfiles');
const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkTargets
>('/typedrequest', 'getNetworkTargets');
const [profilesResponse, targetsResponse] = await Promise.all([
profilesRequest.fire({ identity: context.identity }),
targetsRequest.fire({ identity: context.identity }),
]);
return {
profiles: profilesResponse.profiles,
targets: targetsResponse.targets,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch profiles/targets',
};
}
}
);
export const createProfileAction = profilesTargetsStatePart.createAction<{
name: string;
description?: string;
security: any;
extendsProfiles?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateSecurityProfile
>('/typedrequest', 'createSecurityProfile');
await request.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
security: dataArg.security,
extendsProfiles: dataArg.extendsProfiles,
});
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create profile',
};
}
});
export const updateProfileAction = profilesTargetsStatePart.createAction<{
id: string;
name?: string;
description?: string;
security?: any;
extendsProfiles?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSecurityProfile
>('/typedrequest', 'updateSecurityProfile');
await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
description: dataArg.description,
security: dataArg.security,
extendsProfiles: dataArg.extendsProfiles,
});
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update profile',
};
}
});
export const deleteProfileAction = profilesTargetsStatePart.createAction<{
id: string;
force?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteSecurityProfile
>('/typedrequest', 'deleteSecurityProfile');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
force: dataArg.force,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to delete profile',
};
}
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete profile',
};
}
});
export const createTargetAction = profilesTargetsStatePart.createAction<{
name: string;
description?: string;
host: string | string[];
port: number;
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateNetworkTarget
>('/typedrequest', 'createNetworkTarget');
await request.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
host: dataArg.host,
port: dataArg.port,
});
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create target',
};
}
});
export const updateTargetAction = profilesTargetsStatePart.createAction<{
id: string;
name?: string;
description?: string;
host?: string | string[];
port?: number;
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateNetworkTarget
>('/typedrequest', 'updateNetworkTarget');
await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
description: dataArg.description,
host: dataArg.host,
port: dataArg.port,
});
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update target',
};
}
});
export const deleteTargetAction = profilesTargetsStatePart.createAction<{
id: string;
force?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteNetworkTarget
>('/typedrequest', 'deleteNetworkTarget');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
force: dataArg.force,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to delete target',
};
}
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete target',
};
}
});
// ============================================================================
// Route Management Actions
// ============================================================================

View File

@@ -10,4 +10,6 @@ export * from './ops-view-security.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './ops-view-securityprofiles.js';
export * from './ops-view-networktargets.js';
export * from './shared/index.js';

View File

@@ -25,6 +25,8 @@ import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
import { OpsViewVpn } from './ops-view-vpn.js';
import { OpsViewSecurityProfiles } from './ops-view-securityprofiles.js';
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -73,6 +75,16 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:route',
element: OpsViewRoutes,
},
{
name: 'SecurityProfiles',
iconName: 'lucide:shieldCheck',
element: OpsViewSecurityProfiles,
},
{
name: 'NetworkTargets',
iconName: 'lucide:server',
element: OpsViewNetworkTargets,
},
{
name: 'ApiTokens',
iconName: 'lucide:key',

View File

@@ -0,0 +1,214 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-networktargets': OpsViewNetworkTargets;
}
}
@customElement('ops-view-networktargets')
export class OpsViewNetworkTargets extends DeesElement {
@state()
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
constructor() {
super();
const sub = appstate.profilesTargetsStatePart.select().subscribe((newState) => {
this.profilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.targetsContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
public render(): TemplateResult {
const targets = this.profilesState.targets;
const statsTiles: IStatsTile[] = [
{
id: 'totalTargets',
title: 'Total Targets',
type: 'number',
value: targets.length,
icon: 'lucide:server',
description: 'Reusable network targets',
color: '#8b5cf6',
},
];
return html`
<div class="targetsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading=${'Network Targets'}
.data=${targets}
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
Name: target.name,
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
Port: target.port,
Description: target.description || '-',
})}
.dataActions=${[
{
name: 'Create Target',
iconName: 'lucide:plus',
type: ['header' as const],
action: async (_: any, table: any) => {
await this.showCreateTargetDialog(table);
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
action: async () => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['contextmenu' as const],
action: async (target: interfaces.data.INetworkTarget, table: any) => {
await this.showEditTargetDialog(target, table);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['contextmenu' as const],
action: async (target: interfaces.data.INetworkTarget) => {
await this.deleteTarget(target);
},
},
]}
></dees-table>
</div>
`;
}
private async showCreateTargetDialog(table: any) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create Network Target',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'host'} .label=${'Host'} .required=${true}></dees-input-text>
<dees-input-text .key=${'port'} .label=${'Port'} .required=${true} .value=${'443'}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot!.querySelector('dees-form');
const data = await form.collectFormData();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createTargetAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
host: String(data.host),
port: parseInt(String(data.port)) || 443,
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
private async showEditTargetDialog(target: interfaces.data.INetworkTarget, table: any) {
const hostStr = Array.isArray(target.host) ? target.host.join(', ') : target.host;
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit Target: ${target.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${target.name}></dees-input-text>
<dees-input-text .key=${'host'} .label=${'Host'} .value=${hostStr}></dees-input-text>
<dees-input-text .key=${'port'} .label=${'Port'} .value=${String(target.port)}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${target.description || ''}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot!.querySelector('dees-form');
const data = await form.collectFormData();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateTargetAction, {
id: target.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
host: String(data.host),
port: parseInt(String(data.port)) || 443,
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
private async deleteTarget(target: interfaces.data.INetworkTarget) {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteTargetAction, {
id: target.id,
force: false,
});
const currentState = appstate.profilesTargetsStatePart.getState()!;
if (currentState.error?.includes('in use')) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Target In Use',
content: html`<p>${currentState.error} Force delete?</p>`,
menuOptions: [
{
name: 'Force Delete',
action: async (modalArg: any) => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteTargetAction, {
id: target.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}

View File

@@ -0,0 +1,242 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-securityprofiles': OpsViewSecurityProfiles;
}
}
@customElement('ops-view-securityprofiles')
export class OpsViewSecurityProfiles extends DeesElement {
@state()
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
constructor() {
super();
const sub = appstate.profilesTargetsStatePart.select().subscribe((newState) => {
this.profilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.profilesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
public render(): TemplateResult {
const profiles = this.profilesState.profiles;
const statsTiles: IStatsTile[] = [
{
id: 'totalProfiles',
title: 'Total Profiles',
type: 'number',
value: profiles.length,
icon: 'lucide:shieldCheck',
description: 'Reusable security profiles',
color: '#3b82f6',
},
];
return html`
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading=${'Security Profiles'}
.data=${profiles}
.displayFunction=${(profile: interfaces.data.ISecurityProfile) => ({
Name: profile.name,
Description: profile.description || '-',
'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
'IP Block List': (profile.security?.ipBlockList || []).join(', ') || '-',
'Max Connections': profile.security?.maxConnections ?? '-',
'Rate Limit': profile.security?.rateLimit?.enabled ? `${profile.security.rateLimit.maxRequests}/${profile.security.rateLimit.window}s` : '-',
Extends: (profile.extendsProfiles || []).length > 0
? profile.extendsProfiles!.map(id => {
const p = profiles.find(pp => pp.id === id);
return p ? p.name : id.slice(0, 8);
}).join(', ')
: '-',
})}
.dataActions=${[
{
name: 'Create Profile',
iconName: 'lucide:plus',
type: ['header' as const],
action: async (_: any, table: any) => {
await this.showCreateProfileDialog(table);
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
action: async () => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['contextmenu' as const],
action: async (profile: interfaces.data.ISecurityProfile, table: any) => {
await this.showEditProfileDialog(profile, table);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['contextmenu' as const],
action: async (profile: interfaces.data.ISecurityProfile) => {
await this.deleteProfile(profile);
},
},
]}
></dees-table>
</div>
`;
}
private async showCreateProfileDialog(table: any) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create Security Profile',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'ipAllowList'} .label=${'IP Allow List (comma-separated)'}></dees-input-text>
<dees-input-text .key=${'ipBlockList'} .label=${'IP Block List (comma-separated)'}></dees-input-text>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot!.querySelector('dees-form');
const data = await form.collectFormData();
const ipAllowList = data.ipAllowList
? String(data.ipAllowList).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const ipBlockList = data.ipBlockList
? String(data.ipBlockList).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
security: {
...(ipAllowList ? { ipAllowList } : {}),
...(ipBlockList ? { ipBlockList } : {}),
...(maxConnections ? { maxConnections } : {}),
},
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile, table: any) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-text .key=${'ipAllowList'} .label=${'IP Allow List (comma-separated)'} .value=${(profile.security?.ipAllowList || []).join(', ')}></dees-input-text>
<dees-input-text .key=${'ipBlockList'} .label=${'IP Block List (comma-separated)'} .value=${(profile.security?.ipBlockList || []).join(', ')}></dees-input-text>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'} .value=${String(profile.security?.maxConnections || '')}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot!.querySelector('dees-form');
const data = await form.collectFormData();
const ipAllowList = data.ipAllowList
? String(data.ipAllowList).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const ipBlockList = data.ipBlockList
? String(data.ipBlockList).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
id: profile.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
security: {
ipAllowList,
ipBlockList,
...(maxConnections ? { maxConnections } : {}),
},
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
private async deleteProfile(profile: interfaces.data.ISecurityProfile) {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
id: profile.id,
force: false,
});
const currentState = appstate.profilesTargetsStatePart.getState()!;
if (currentState.error?.includes('in use')) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Profile In Use',
content: html`<p>${currentState.error} Force delete?</p>`,
menuOptions: [
{
name: 'Force Delete',
action: async (modalArg: any) => {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
id: profile.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}

View File

@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn'] as const;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'securityprofiles', 'networktargets'] as const;
export type TValidView = typeof validViews[number];