feat(vpn): add VPN server management and route-based VPN access control

This commit is contained in:
2026-03-30 08:15:09 +00:00
parent fbe845cd8e
commit 6f72e4fdbc
22 changed files with 1547 additions and 10 deletions

View File

@@ -905,6 +905,161 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
}
});
// ============================================================================
// VPN State
// ============================================================================
export interface IVpnState {
clients: interfaces.data.IVpnClient[];
status: interfaces.data.IVpnServerStatus | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
/** WireGuard config shown after create/rotate (only shown once) */
newClientConfig: string | null;
}
export const vpnStatePart = await appState.getStatePart<IVpnState>(
'vpn',
{
clients: [],
status: null,
isLoading: false,
error: null,
lastUpdated: 0,
newClientConfig: null,
},
'soft'
);
// ============================================================================
// VPN Actions
// ============================================================================
export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnClients
>('/typedrequest', 'getVpnClients');
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnStatus
>('/typedrequest', 'getVpnStatus');
const [clientsResponse, statusResponse] = await Promise.all([
clientsRequest.fire({ identity: context.identity }),
statusRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
clients: clientsResponse.clients,
status: statusResponse.status,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch VPN data',
};
}
});
export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string;
tags?: string[];
description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateVpnClient
>('/typedrequest', 'createVpnClient');
const response = await request.fire({
identity: context.identity!,
clientId: dataArg.clientId,
tags: dataArg.tags,
description: dataArg.description,
});
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to create client' };
}
const refreshed = await actionContext!.dispatch(fetchVpnAction, null);
return {
...refreshed,
newClientConfig: response.wireguardConfig || null,
};
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create VPN client',
};
}
});
export const deleteVpnClientAction = vpnStatePart.createAction<string>(
async (statePartArg, clientId, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteVpnClient
>('/typedrequest', 'deleteVpnClient');
await request.fire({ identity: context.identity!, clientId });
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete VPN client',
};
}
},
);
export const toggleVpnClientAction = vpnStatePart.createAction<{
clientId: string;
enabled: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient';
type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient;
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<TReq>(
'/typedrequest', method,
);
await request.fire({ identity: context.identity!, clientId: dataArg.clientId });
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle VPN client',
};
}
});
export const clearNewClientConfigAction = vpnStatePart.createAction(
async (statePartArg): Promise<IVpnState> => {
return { ...statePartArg.getState()!, newClientConfig: null };
},
);
// ============================================================================
// Route Management Actions
// ============================================================================
@@ -1372,6 +1527,15 @@ async function dispatchCombinedRefreshActionInner() {
console.error('Remote ingress refresh failed:', error);
}
}
// Refresh VPN data if on vpn view
if (currentView === 'vpn') {
try {
await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) {
console.error('VPN refresh failed:', error);
}
}
} catch (error) {
console.error('Combined refresh failed:', error);
// If the error looks like an auth failure (invalid JWT), force re-login