mirror of https://github.com/wg-easy/wg-easy
committed by
GitHub
57 changed files with 7659 additions and 49 deletions
@ -0,0 +1,150 @@ |
|||
--- |
|||
title: Client Groups |
|||
--- |
|||
|
|||
Client Groups allow administrators to assign a client to zero, one, or multiple groups and manage shared client policy values centrally. |
|||
|
|||
Groups can define policy values for: |
|||
|
|||
- **Allowed IPs** |
|||
- **DNS** |
|||
- **Firewall IPs** |
|||
|
|||
Each policy field is resolved independently. A group can manage one field without managing the others. |
|||
|
|||
## Supported Policies |
|||
|
|||
### Allowed IPs |
|||
|
|||
Allowed IPs control the routes written into the generated client configuration. |
|||
|
|||
They affect the `AllowedIPs` value that the WireGuard client receives. They do not, by themselves, prevent a user from editing their local WireGuard configuration. |
|||
|
|||
### DNS |
|||
|
|||
DNS controls the DNS servers written into the generated client configuration. |
|||
|
|||
### Firewall IPs |
|||
|
|||
Firewall IPs are enforced by the `wg-easy` server using firewall rules. |
|||
|
|||
/// note | Per-Client Firewall Required |
|||
|
|||
Firewall IP group policies are only available when **Per-Client Firewall** is enabled in the Admin Panel -> Interface settings. |
|||
|
|||
/// |
|||
|
|||
Use Firewall IPs when server-side access enforcement is required. |
|||
|
|||
## Policy Resolution |
|||
|
|||
A client can belong to multiple groups. For each policy field, `wg-easy` resolves the effective value as follows: |
|||
|
|||
1. Values from all assigned groups that define that field are combined. |
|||
2. Duplicate values are removed. |
|||
3. Group membership order and each group's internal value order determine the final order. |
|||
4. Global/default configuration is used only when none of the assigned groups defines that field. |
|||
5. Global values are not added to a non-empty group result. |
|||
6. While a client belongs to at least one group, its individual Allowed IPs, DNS, and Firewall IP values are not used. |
|||
7. Individual client values remain stored and become active again when all groups are removed. |
|||
|
|||
Example: |
|||
|
|||
```text |
|||
Customers group: |
|||
Allowed IPs: 10.77.0.0/16 |
|||
DNS: 1.1.1.1, 9.9.9.9 |
|||
|
|||
NAS Access group: |
|||
Allowed IPs: 172.20.1.33/32, 10.77.0.0/16 |
|||
DNS: 9.9.9.9, 8.8.8.8 |
|||
``` |
|||
|
|||
If the client is assigned to `Customers` first and `NAS Access` second, the generated configuration uses: |
|||
|
|||
```text |
|||
AllowedIPs = 10.77.0.0/16, 172.20.1.33/32 |
|||
DNS = 1.1.1.1, 9.9.9.9, 8.8.8.8 |
|||
``` |
|||
|
|||
`10.77.0.0/16` and `9.9.9.9` appear in both groups, but each duplicate is included only once. |
|||
|
|||
## Creating a Group |
|||
|
|||
Open **Groups** from the user menu. |
|||
|
|||
To create a group: |
|||
|
|||
1. Click **Create**. |
|||
2. Enter a group name. |
|||
3. Optionally enter a description. |
|||
4. Enable any policy field the group should manage. |
|||
5. Add at least one valid entry for each enabled policy field. |
|||
6. Save the group. |
|||
|
|||
If a policy field is disabled, that group does not contribute to that field. |
|||
|
|||
/// warning | Enabled Policies Require Values |
|||
|
|||
An enabled group policy must contain at least one valid entry. There is no separate "enabled but empty" policy state. |
|||
|
|||
/// |
|||
|
|||
## Editing a Group |
|||
|
|||
Open **Groups**, then select the group you want to edit. |
|||
|
|||
You can change the name, description, and policy values. Saving the group updates the effective policy for assigned clients the next time their configuration or firewall rules are generated. |
|||
|
|||
## Assigning Clients |
|||
|
|||
You can assign groups from two places. |
|||
|
|||
### While Creating a Client |
|||
|
|||
When creating a client, select one or more groups in the **Client Groups** section. |
|||
|
|||
The selected group order controls the order used when policy values are combined. |
|||
|
|||
### From the Client Edit Page |
|||
|
|||
Open a client and use the **Client Groups** section to add or remove groups. |
|||
|
|||
When a client belongs to at least one group, the individual Allowed IPs, DNS, and Firewall IP fields become read-only. The page shows the effective value from the assigned groups or, when no assigned group defines that field, the global/default value. |
|||
|
|||
Use **Save** to persist membership changes. Use **Revert** to return to the last saved client values and group assignments. |
|||
|
|||
## Group Membership Page |
|||
|
|||
Each group detail page includes a membership section. |
|||
|
|||
From this page you can: |
|||
|
|||
- Assign an existing client to the group. |
|||
- Remove a client from that group. |
|||
|
|||
Removing a client from one group only removes that one membership. It does not remove the client from other groups, and it never deletes the client. |
|||
|
|||
## Deleting Groups |
|||
|
|||
Deleting a group deletes the group and its memberships. |
|||
|
|||
Assigned clients remain intact. Memberships to other groups remain intact. After the group is deleted, each affected client's effective policy is recalculated from its remaining groups. If no remaining group defines a policy field, `wg-easy` uses the global/default value for that field. |
|||
|
|||
## Generated Configurations |
|||
|
|||
The effective policy is used consistently for: |
|||
|
|||
- Normal configuration downloads |
|||
- QR-code configurations |
|||
- One-time configuration links |
|||
|
|||
## Security Note |
|||
|
|||
/// warning | Use Firewall IPs for Server-Side Enforcement |
|||
|
|||
Allowed IPs in the client configuration are not a complete access-control mechanism because users may edit their local WireGuard configuration. |
|||
|
|||
Use Firewall IPs when server-side enforcement is required. The **Per-Client Firewall** feature must be enabled in the Admin Panel -> Interface settings, and you should verify that firewall rules work as expected in your deployment environment. |
|||
|
|||
/// |
|||
@ -0,0 +1,38 @@ |
|||
<template> |
|||
<BaseDialog |
|||
:trigger-class="triggerClass" |
|||
:trigger-aria-label="triggerAriaLabel" |
|||
:trigger-title="triggerTitle" |
|||
> |
|||
<template #trigger><slot /></template> |
|||
<template #title>{{ title }}</template> |
|||
<template #description> |
|||
{{ description }} |
|||
</template> |
|||
<template #actions> |
|||
<DialogClose as-child> |
|||
<BaseSecondaryButton>{{ $t('dialog.cancel') }}</BaseSecondaryButton> |
|||
</DialogClose> |
|||
<DialogClose as-child> |
|||
<BasePrimaryButton @click="$emit('confirm')"> |
|||
{{ confirmLabel }} |
|||
</BasePrimaryButton> |
|||
</DialogClose> |
|||
</template> |
|||
</BaseDialog> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
defineEmits<{ |
|||
confirm: []; |
|||
}>(); |
|||
|
|||
defineProps<{ |
|||
triggerClass?: string; |
|||
triggerAriaLabel?: string; |
|||
triggerTitle?: string; |
|||
title: string; |
|||
description: string; |
|||
confirmLabel: string; |
|||
}>(); |
|||
</script> |
|||
@ -0,0 +1,56 @@ |
|||
<template> |
|||
<BaseDialog |
|||
:trigger-class="triggerClass" |
|||
:trigger-aria-label="triggerAriaLabel" |
|||
:trigger-title="triggerTitle" |
|||
> |
|||
<template #trigger><slot /></template> |
|||
<template #title>{{ $t('clientGroup.deleteGroup') }}</template> |
|||
<template #description> |
|||
<span> |
|||
{{ $t('clientGroup.deleteConfirm', { name: groupName }) }} |
|||
</span> |
|||
<span v-if="assignedClientCount > 0" class="mt-2 block"> |
|||
{{ |
|||
$t(deleteAssignedConfirmKey, { |
|||
count: assignedClientCount, |
|||
}) |
|||
}} |
|||
</span> |
|||
</template> |
|||
<template #actions> |
|||
<DialogClose as-child> |
|||
<BasePrimaryButton>{{ $t('dialog.cancel') }}</BasePrimaryButton> |
|||
</DialogClose> |
|||
<DialogClose as-child> |
|||
<BaseSecondaryButton @click="$emit('delete')"> |
|||
{{ $t('clientGroup.deleteGroup') }} |
|||
</BaseSecondaryButton> |
|||
</DialogClose> |
|||
</template> |
|||
</BaseDialog> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { pluralKey } from '../../utils/clientGroups'; |
|||
|
|||
defineEmits<{ |
|||
delete: []; |
|||
}>(); |
|||
|
|||
const props = defineProps<{ |
|||
triggerClass?: string; |
|||
triggerAriaLabel?: string; |
|||
triggerTitle?: string; |
|||
groupName: string; |
|||
assignedClientCount: number; |
|||
}>(); |
|||
|
|||
const deleteAssignedConfirmKey = computed(() => |
|||
pluralKey( |
|||
props.assignedClientCount, |
|||
'clientGroup.deleteAssignedConfirmOne', |
|||
'clientGroup.deleteAssignedConfirmOther' |
|||
) |
|||
); |
|||
</script> |
|||
@ -0,0 +1,74 @@ |
|||
<template> |
|||
<FormElement @submit.prevent="$emit('submit')"> |
|||
<FormGroup> |
|||
<FormHeading>{{ $t('form.sectionGeneral') }}</FormHeading> |
|||
<FormTextField |
|||
id="clientGroupName" |
|||
v-model="form.name" |
|||
:label="$t('general.name')" |
|||
/> |
|||
<FormTextArea |
|||
id="clientGroupDescription" |
|||
v-model="form.description" |
|||
:label="$t('clientGroup.description')" |
|||
/> |
|||
</FormGroup> |
|||
|
|||
<FormGroup> |
|||
<ClientGroupsPolicyField |
|||
v-model="form.allowedIps" |
|||
name="allowedIps" |
|||
:label="$t('general.allowedIps')" |
|||
:description="$t('client.allowedIpsDesc')" |
|||
:switch-label="$t('clientGroup.manageAllowedIps')" |
|||
:not-defined-text="$t('clientGroup.notDefined')" |
|||
:remove-label="$t('clientGroup.removeAllowedIp')" |
|||
/> |
|||
</FormGroup> |
|||
|
|||
<FormGroup> |
|||
<ClientGroupsPolicyField |
|||
v-model="form.dns" |
|||
name="dns" |
|||
:label="$t('general.dns')" |
|||
:description="$t('client.dnsDesc')" |
|||
:switch-label="$t('clientGroup.manageDns')" |
|||
:not-defined-text="$t('clientGroup.notDefined')" |
|||
:remove-label="$t('clientGroup.removeDns')" |
|||
/> |
|||
</FormGroup> |
|||
|
|||
<FormGroup v-if="showFirewallIps"> |
|||
<ClientGroupsPolicyField |
|||
v-model="form.firewallIps" |
|||
name="firewallIps" |
|||
:label="$t('client.firewallIps')" |
|||
:description="$t('client.firewallIpsDesc')" |
|||
:switch-label="$t('clientGroup.manageFirewallIps')" |
|||
:not-defined-text="$t('clientGroup.notDefined')" |
|||
:remove-label="$t('clientGroup.removeFirewallIp')" |
|||
/> |
|||
</FormGroup> |
|||
|
|||
<FormGroup> |
|||
<FormHeading>{{ $t('form.actions') }}</FormHeading> |
|||
<FormPrimaryActionField type="submit" :label="submitLabel" /> |
|||
<slot name="actions" /> |
|||
</FormGroup> |
|||
</FormElement> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import type { ClientGroupForm } from '../../utils/clientGroups'; |
|||
|
|||
defineEmits<{ |
|||
submit: []; |
|||
}>(); |
|||
|
|||
defineProps<{ |
|||
submitLabel: string; |
|||
showFirewallIps: boolean; |
|||
}>(); |
|||
|
|||
const form = defineModel<ClientGroupForm>({ required: true }); |
|||
</script> |
|||
@ -0,0 +1,45 @@ |
|||
<template> |
|||
<div |
|||
class="col-span-2 rounded border border-gray-100 bg-gray-50 p-3 text-sm dark:border-neutral-700 dark:bg-neutral-800" |
|||
> |
|||
<i18n-t |
|||
:keypath="messageKey" |
|||
tag="p" |
|||
class="font-medium text-gray-700 dark:text-neutral-100" |
|||
> |
|||
<template #groups> |
|||
<template v-for="(group, index) in groups" :key="group.id"> |
|||
<span v-if="index > 0">, </span> |
|||
<NuxtLink |
|||
:to="`/groups/${group.id}`" |
|||
class="text-red-800 underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-red-800 dark:text-red-400" |
|||
> |
|||
{{ group.name }} |
|||
</NuxtLink> |
|||
</template> |
|||
</template> |
|||
</i18n-t> |
|||
<p |
|||
v-if="value.length === 0" |
|||
class="mt-1 text-gray-500 dark:text-neutral-300" |
|||
> |
|||
{{ $t('form.noItems') }} |
|||
</p> |
|||
<ul |
|||
v-else |
|||
class="mt-2 space-y-1 break-all text-gray-600 dark:text-neutral-200" |
|||
> |
|||
<li v-for="entry in value" :key="entry"> |
|||
{{ entry }} |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineProps<{ |
|||
messageKey: string; |
|||
groups: { id: number; name: string }[]; |
|||
value: string[]; |
|||
}>(); |
|||
</script> |
|||
@ -0,0 +1,127 @@ |
|||
<template> |
|||
<FormHeading :description="description"> |
|||
{{ label }} |
|||
</FormHeading> |
|||
|
|||
<div class="flex items-center"> |
|||
<FormLabel :for="switchId"> |
|||
{{ switchLabel }} |
|||
</FormLabel> |
|||
</div> |
|||
<div class="my-auto"> |
|||
<BaseSwitch :id="switchId" v-model="isDefined" :aria-label="switchLabel" /> |
|||
</div> |
|||
|
|||
<div class="col-span-full min-w-0 space-y-2"> |
|||
<p v-if="data === null" class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
{{ notDefinedText }} |
|||
</p> |
|||
|
|||
<div v-if="data !== null" class="space-y-2"> |
|||
<div |
|||
v-for="(item, index) in data" |
|||
:key="index" |
|||
class="mt-1 flex min-w-0 flex-row gap-1" |
|||
> |
|||
<input |
|||
:id="`${name}-${index}`" |
|||
:name="name" |
|||
:value="item" |
|||
type="text" |
|||
class="min-w-0 rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" |
|||
@input="updateEntry($event, index)" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="inline-flex size-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-400 transition hover:bg-red-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-800 dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white" |
|||
:aria-label="removeLabel" |
|||
:title="removeLabel" |
|||
@click="removeEntry(index)" |
|||
> |
|||
<IconsDelete class="w-5" /> |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="mt-2"> |
|||
<BasePrimaryButton type="button" class="rounded-lg" @click="addEntry"> |
|||
{{ $t('form.add') }} |
|||
</BasePrimaryButton> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { |
|||
addPolicyEntry, |
|||
removePolicyEntry, |
|||
setPolicyDraftDefined, |
|||
updatePolicyEntry, |
|||
} from '../../utils/clientGroups'; |
|||
|
|||
const data = defineModel<string[] | null>({ required: true }); |
|||
|
|||
const props = defineProps<{ |
|||
name: string; |
|||
label: string; |
|||
description?: string; |
|||
switchLabel: string; |
|||
notDefinedText: string; |
|||
removeLabel: string; |
|||
}>(); |
|||
|
|||
const draftValue = ref<string[] | null>( |
|||
data.value === null ? null : [...data.value] |
|||
); |
|||
const internalNullWrite = ref(false); |
|||
|
|||
const isDefined = computed({ |
|||
get: () => data.value !== null, |
|||
set: (defined: boolean) => { |
|||
const nextState = setPolicyDraftDefined( |
|||
{ value: data.value, draft: draftValue.value }, |
|||
defined |
|||
); |
|||
|
|||
draftValue.value = nextState.draft; |
|||
internalNullWrite.value = !defined; |
|||
data.value = nextState.value; |
|||
}, |
|||
}); |
|||
|
|||
watch( |
|||
data, |
|||
(value) => { |
|||
if (value === null) { |
|||
if (internalNullWrite.value) { |
|||
internalNullWrite.value = false; |
|||
} else { |
|||
draftValue.value = null; |
|||
} |
|||
return; |
|||
} |
|||
|
|||
draftValue.value = [...value]; |
|||
}, |
|||
{ deep: true } |
|||
); |
|||
|
|||
function addEntry() { |
|||
data.value = addPolicyEntry(data.value); |
|||
} |
|||
|
|||
function updateEntry(event: Event, index: number) { |
|||
data.value = updatePolicyEntry( |
|||
data.value, |
|||
index, |
|||
(event.target as HTMLInputElement).value |
|||
); |
|||
} |
|||
|
|||
function removeEntry(index: number) { |
|||
data.value = removePolicyEntry(data.value, index); |
|||
} |
|||
|
|||
const name = computed(() => props.name); |
|||
const switchId = computed(() => `${props.name}Managed`); |
|||
</script> |
|||
@ -0,0 +1,99 @@ |
|||
<template> |
|||
<div class="col-span-full flex min-w-0 flex-col gap-2"> |
|||
<p |
|||
v-if="selectedGroups.length === 0" |
|||
class="text-sm text-gray-500 dark:text-neutral-300" |
|||
> |
|||
{{ $t('clientGroup.noGroupsSelected') }} |
|||
</p> |
|||
|
|||
<div |
|||
v-for="group in selectedGroups" |
|||
:key="group.id" |
|||
class="mt-1 flex min-w-0 flex-row gap-1" |
|||
> |
|||
<input |
|||
:value="group.name" |
|||
type="text" |
|||
readonly |
|||
class="min-w-0 rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="inline-flex size-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-400 transition hover:bg-red-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-800 dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white" |
|||
:aria-label="$t('clientGroup.removeGroupLabel', { name: group.name })" |
|||
:title="$t('clientGroup.removeGroupLabel', { name: group.name })" |
|||
@click="removeSelectedGroup(String(group.id))" |
|||
> |
|||
<IconsUnlink class="w-5" /> |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="mt-2 grid min-w-0 gap-1 sm:grid-cols-[minmax(0,1fr)_auto]"> |
|||
<select |
|||
v-model="pendingGroupId" |
|||
class="min-w-0 rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200" |
|||
:disabled="disabled" |
|||
> |
|||
<option value="">{{ $t('clientGroup.selectGroup') }}</option> |
|||
<option |
|||
v-for="group in availableGroups" |
|||
:key="group.id" |
|||
:value="String(group.id)" |
|||
> |
|||
{{ group.name }} |
|||
</option> |
|||
</select> |
|||
<BasePrimaryButton |
|||
type="button" |
|||
class="justify-center whitespace-nowrap rounded-lg" |
|||
:disabled="disabled || !pendingGroupId" |
|||
@click="addSelectedGroup" |
|||
> |
|||
{{ $t('form.add') }} |
|||
</BasePrimaryButton> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { |
|||
addGroupSelection, |
|||
removeGroupSelection, |
|||
selectedClientGroups, |
|||
type ClientGroupPolicySource, |
|||
} from '../../utils/clientGroups'; |
|||
|
|||
const selectedGroupIds = defineModel<string[]>({ required: true }); |
|||
|
|||
const props = defineProps<{ |
|||
groups: ClientGroupPolicySource[] | null | undefined; |
|||
disabled?: boolean; |
|||
}>(); |
|||
|
|||
const pendingGroupId = ref(''); |
|||
|
|||
const selectedGroups = computed(() => |
|||
selectedClientGroups(props.groups, selectedGroupIds.value) |
|||
); |
|||
const availableGroups = computed(() => |
|||
(props.groups ?? []).filter( |
|||
(group) => !selectedGroupIds.value.includes(String(group.id)) |
|||
) |
|||
); |
|||
|
|||
function addSelectedGroup() { |
|||
selectedGroupIds.value = addGroupSelection( |
|||
selectedGroupIds.value, |
|||
pendingGroupId.value |
|||
); |
|||
pendingGroupId.value = ''; |
|||
} |
|||
|
|||
function removeSelectedGroup(groupId: string) { |
|||
selectedGroupIds.value = removeGroupSelection( |
|||
selectedGroupIds.value, |
|||
groupId |
|||
); |
|||
} |
|||
</script> |
|||
@ -0,0 +1,7 @@ |
|||
<template> |
|||
<LinkSlashIcon /> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import LinkSlashIcon from '@heroicons/vue/24/outline/esm/LinkSlashIcon'; |
|||
</script> |
|||
@ -0,0 +1,383 @@ |
|||
<template> |
|||
<main> |
|||
<Panel> |
|||
<PanelHead> |
|||
<PanelHeadTitle> |
|||
{{ group?.name ?? $t('clientGroup.details') }} |
|||
</PanelHeadTitle> |
|||
<PanelHeadBoat> |
|||
<NuxtLink to="/groups"> |
|||
<BaseSecondaryButton as="span"> |
|||
{{ $t('clientGroup.backToGroups') }} |
|||
</BaseSecondaryButton> |
|||
</NuxtLink> |
|||
</PanelHeadBoat> |
|||
</PanelHead> |
|||
<PanelBody> |
|||
<div v-if="pending" class="py-8 text-center"> |
|||
<IconsLoading class="mx-auto w-5 animate-spin" /> |
|||
</div> |
|||
|
|||
<div |
|||
v-else-if="loadError" |
|||
class="rounded border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200" |
|||
> |
|||
{{ loadError }} |
|||
</div> |
|||
|
|||
<div v-else-if="group" class="space-y-8"> |
|||
<div |
|||
v-if="formError" |
|||
class="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200" |
|||
> |
|||
{{ formError }} |
|||
</div> |
|||
|
|||
<ClientGroupsForm |
|||
v-model="form" |
|||
:submit-label="$t('form.save')" |
|||
:show-firewall-ips="showFirewallIps" |
|||
@submit="submit" |
|||
> |
|||
<template #actions> |
|||
<FormSecondaryActionField |
|||
:label="$t('form.revert')" |
|||
@click="revert" |
|||
/> |
|||
<ClientGroupsDeleteDialog |
|||
trigger-class="col-span-2" |
|||
:group-name="group.name" |
|||
:assigned-client-count="group.assignedClientCount" |
|||
@delete="deleteGroup" |
|||
> |
|||
<FormSecondaryActionField |
|||
as="span" |
|||
class="inline-block w-full" |
|||
:label="$t('clientGroup.deleteGroup')" |
|||
/> |
|||
</ClientGroupsDeleteDialog> |
|||
</template> |
|||
</ClientGroupsForm> |
|||
|
|||
<section class="space-y-4"> |
|||
<div> |
|||
<h3 class="text-lg font-semibold"> |
|||
{{ $t('clientGroup.membership') }} |
|||
</h3> |
|||
<p class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
{{ |
|||
$t(assignedClientCountKey, { |
|||
count: group.assignedClientCount, |
|||
}) |
|||
}} |
|||
</p> |
|||
</div> |
|||
|
|||
<div |
|||
class="rounded border border-gray-100 p-4 dark:border-neutral-600" |
|||
> |
|||
<label |
|||
for="clientGroupAssign" |
|||
class="mb-2 block font-medium text-gray-700 dark:text-neutral-100" |
|||
> |
|||
{{ $t('clientGroup.assignClient') }} |
|||
</label> |
|||
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]"> |
|||
<select |
|||
id="clientGroupAssign" |
|||
v-model="selectedClientId" |
|||
class="w-full rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200" |
|||
> |
|||
<option value=""> |
|||
{{ $t('clientGroup.selectClient') }} |
|||
</option> |
|||
<option |
|||
v-for="client in assignableClients" |
|||
:key="client.id" |
|||
:value="String(client.id)" |
|||
> |
|||
{{ client.name }}{{ clientGroupSuffix(client.id) }} |
|||
</option> |
|||
</select> |
|||
|
|||
<BasePrimaryButton |
|||
type="button" |
|||
:disabled="!selectedClientId" |
|||
class="justify-center whitespace-nowrap" |
|||
@click="assignSelectedClient" |
|||
> |
|||
{{ $t('clientGroup.assignClient') }} |
|||
</BasePrimaryButton> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
v-if="group.clients.length === 0" |
|||
class="rounded border border-dashed border-gray-200 p-6 text-center text-gray-500 dark:border-neutral-600 dark:text-neutral-300" |
|||
> |
|||
{{ $t('clientGroup.noAssignedClients') }} |
|||
</div> |
|||
|
|||
<div v-else class="grid gap-3"> |
|||
<article |
|||
v-for="client in group.clients" |
|||
:key="client.id" |
|||
class="min-w-0 rounded border border-gray-100 p-4 dark:border-neutral-600" |
|||
> |
|||
<div |
|||
class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(100px,1fr)_auto_minmax(120px,auto)_minmax(240px,1.5fr)_auto] lg:items-center" |
|||
> |
|||
<div class="min-w-0 lg:order-1"> |
|||
<NuxtLink |
|||
:to="`/clients/${client.id}`" |
|||
class="break-words font-semibold text-red-800 hover:underline dark:text-red-400" |
|||
> |
|||
{{ client.name }} |
|||
</NuxtLink> |
|||
</div> |
|||
<div class="flex shrink-0 justify-end lg:order-5"> |
|||
<ClientGroupsConfirmMembershipDialog |
|||
trigger-class="inline-flex size-10 items-center justify-center rounded bg-gray-100 text-gray-400 transition hover:bg-red-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-800 dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white" |
|||
:trigger-aria-label="$t('clientGroup.unassignClient')" |
|||
:trigger-title="$t('clientGroup.unassignClient')" |
|||
:title="$t('clientGroup.unassignClient')" |
|||
:description=" |
|||
$t('clientGroup.unassignClientConfirm', { |
|||
name: client.name, |
|||
}) |
|||
" |
|||
:confirm-label="$t('clientGroup.unassignClient')" |
|||
@confirm="unassignClient(client.id)" |
|||
> |
|||
<IconsUnlink class="w-5" /> |
|||
</ClientGroupsConfirmMembershipDialog> |
|||
</div> |
|||
<span |
|||
class="col-span-full text-sm text-gray-500 lg:order-2 lg:col-auto dark:text-neutral-300" |
|||
> |
|||
{{ |
|||
client.enabled |
|||
? $t('client.enabled') |
|||
: $t('clientGroup.disabled') |
|||
}} |
|||
</span> |
|||
<span |
|||
class="col-span-full min-w-0 text-sm text-gray-500 lg:order-3 lg:col-auto dark:text-neutral-300" |
|||
> |
|||
IPv4: |
|||
<span |
|||
class="font-mono text-sm [overflow-wrap:anywhere] lg:whitespace-nowrap" |
|||
> |
|||
{{ client.ipv4Address }} |
|||
</span> |
|||
</span> |
|||
<span |
|||
class="col-span-full min-w-0 text-sm text-gray-500 lg:order-4 lg:col-auto dark:text-neutral-300" |
|||
> |
|||
IPv6: |
|||
<span |
|||
class="font-mono text-sm [overflow-wrap:anywhere] lg:whitespace-nowrap" |
|||
> |
|||
{{ client.ipv6Address }} |
|||
</span> |
|||
</span> |
|||
</div> |
|||
</article> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</PanelBody> |
|||
</Panel> |
|||
</main> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { |
|||
apiErrorMessage, |
|||
clientGroupFormToPayload, |
|||
clientGroupToForm, |
|||
createClientGroupForm, |
|||
groupIdsForClient, |
|||
invalidDefinedPolicyKey, |
|||
pluralKey, |
|||
} from '../../utils/clientGroups'; |
|||
import type { ClientGroupDetails } from '../../stores/clientGroups'; |
|||
|
|||
const route = useRoute(); |
|||
const groupId = route.params.id as string; |
|||
const groupsStore = useClientGroupsStore(); |
|||
const clientsStore = useClientsStore(); |
|||
const globalStore = useGlobalStore(); |
|||
const toast = useToast(); |
|||
const { t } = useI18n(); |
|||
|
|||
const group = ref<ClientGroupDetails | null>(null); |
|||
const form = ref(createClientGroupForm()); |
|||
const pending = ref(true); |
|||
const loadError = ref(''); |
|||
const formError = ref(''); |
|||
const selectedClientId = ref(''); |
|||
|
|||
await load(); |
|||
|
|||
const assignableClients = computed(() => { |
|||
const clients = clientsStore.clients ?? []; |
|||
const membership = groupsStore.membership ?? []; |
|||
|
|||
return clients.filter( |
|||
(client) => |
|||
!groupIdsForClient(membership, client.id).includes(group.value?.id ?? -1) |
|||
); |
|||
}); |
|||
|
|||
const showFirewallIps = computed( |
|||
() => globalStore.information?.firewallEnabled === true |
|||
); |
|||
const assignedClientCountKey = computed(() => |
|||
pluralKey( |
|||
group.value?.assignedClientCount ?? 0, |
|||
'clientGroup.assignedClientCountOne', |
|||
'clientGroup.assignedClientCountOther' |
|||
) |
|||
); |
|||
|
|||
async function load() { |
|||
pending.value = true; |
|||
loadError.value = ''; |
|||
|
|||
try { |
|||
await Promise.all([ |
|||
groupsStore.refresh(), |
|||
groupsStore.refreshMembership(), |
|||
clientsStore.refresh(), |
|||
]); |
|||
group.value = await groupsStore.getDetails(groupId); |
|||
form.value = clientGroupToForm(group.value); |
|||
} catch (error) { |
|||
loadError.value = apiErrorMessage(error, t('clientGroup.loadError')); |
|||
} finally { |
|||
pending.value = false; |
|||
} |
|||
} |
|||
|
|||
function clientGroupSuffix(clientId: number) { |
|||
const currentGroupIds = groupIdsForClient( |
|||
groupsStore.membership ?? [], |
|||
clientId |
|||
); |
|||
|
|||
if (currentGroupIds.length === 0) { |
|||
return ''; |
|||
} |
|||
|
|||
const currentGroups = currentGroupIds |
|||
.map((currentGroupId) => |
|||
groupsStore.groups?.find((candidate) => candidate.id === currentGroupId) |
|||
) |
|||
.filter((candidate) => !!candidate); |
|||
|
|||
return currentGroups.length > 0 |
|||
? ` (${currentGroups.map((currentGroup) => currentGroup.name).join(', ')})` |
|||
: ''; |
|||
} |
|||
|
|||
async function submit() { |
|||
const payload = clientGroupFormToPayload(form.value); |
|||
|
|||
if (!payload.name) { |
|||
formError.value = t('clientGroup.nameRequired'); |
|||
return; |
|||
} |
|||
|
|||
if (invalidDefinedPolicyKey(form.value, showFirewallIps.value)) { |
|||
formError.value = t('clientGroup.policyRequired'); |
|||
return; |
|||
} |
|||
|
|||
formError.value = ''; |
|||
|
|||
try { |
|||
await groupsStore.updateGroup(groupId, payload); |
|||
await load(); |
|||
toast.showToast({ |
|||
type: 'success', |
|||
message: t('toast.saved'), |
|||
}); |
|||
} catch (error) { |
|||
formError.value = apiErrorMessage(error, t('toast.unknown')); |
|||
toast.showToast({ |
|||
type: 'error', |
|||
message: formError.value, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
function revert() { |
|||
if (group.value) { |
|||
form.value = clientGroupToForm(group.value); |
|||
} |
|||
} |
|||
|
|||
async function assignSelectedClient() { |
|||
const clientId = Number(selectedClientId.value); |
|||
|
|||
if (!clientId || !group.value) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await groupsStore.assignClient(clientId, group.value.id); |
|||
selectedClientId.value = ''; |
|||
await load(); |
|||
toast.showToast({ |
|||
type: 'success', |
|||
message: t('clientGroup.clientAssigned'), |
|||
}); |
|||
} catch (error) { |
|||
toast.showToast({ |
|||
type: 'error', |
|||
message: apiErrorMessage(error, t('toast.unknown')), |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async function unassignClient(clientId: number) { |
|||
if (!group.value) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await groupsStore.removeClient(clientId, group.value.id); |
|||
await load(); |
|||
toast.showToast({ |
|||
type: 'success', |
|||
message: t('clientGroup.clientUnassigned'), |
|||
}); |
|||
} catch (error) { |
|||
toast.showToast({ |
|||
type: 'error', |
|||
message: apiErrorMessage(error, t('toast.unknown')), |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async function deleteGroup() { |
|||
if (!group.value) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await groupsStore.deleteGroup(group.value.id); |
|||
toast.showToast({ |
|||
type: 'success', |
|||
message: t('clientGroup.deleted'), |
|||
}); |
|||
await navigateTo('/groups'); |
|||
} catch (error) { |
|||
toast.showToast({ |
|||
type: 'error', |
|||
message: apiErrorMessage(error, t('toast.unknown')), |
|||
}); |
|||
} |
|||
} |
|||
</script> |
|||
@ -0,0 +1,162 @@ |
|||
<template> |
|||
<main> |
|||
<Panel> |
|||
<PanelHead> |
|||
<PanelHeadTitle>{{ $t('clientGroup.title') }}</PanelHeadTitle> |
|||
<PanelHeadBoat> |
|||
<NuxtLink to="/groups/new"> |
|||
<BasePrimaryButton as="span"> |
|||
<IconsPlus class="mr-2 size-4" /> |
|||
{{ $t('clientGroup.create') }} |
|||
</BasePrimaryButton> |
|||
</NuxtLink> |
|||
</PanelHeadBoat> |
|||
</PanelHead> |
|||
|
|||
<PanelBody> |
|||
<div v-if="pending" class="py-8 text-center"> |
|||
<IconsLoading class="mx-auto w-5 animate-spin" /> |
|||
</div> |
|||
|
|||
<div |
|||
v-else-if="error" |
|||
class="rounded border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200" |
|||
> |
|||
{{ $t('clientGroup.loadError') }} |
|||
</div> |
|||
|
|||
<div |
|||
v-else-if="groupsStore.groups?.length === 0" |
|||
class="rounded border border-dashed border-gray-200 p-6 text-center text-gray-500 dark:border-neutral-600 dark:text-neutral-300" |
|||
> |
|||
<p>{{ $t('clientGroup.empty') }}</p> |
|||
<NuxtLink to="/groups/new" class="mt-4 inline-block"> |
|||
<BasePrimaryButton as="span"> |
|||
{{ $t('clientGroup.create') }} |
|||
</BasePrimaryButton> |
|||
</NuxtLink> |
|||
</div> |
|||
|
|||
<div v-else> |
|||
<div |
|||
v-for="group in groupsStore.groups" |
|||
:key="group.id" |
|||
class="relative overflow-hidden border-b border-solid border-gray-100 last:border-b-0 dark:border-neutral-600" |
|||
> |
|||
<div |
|||
class="relative flex flex-col justify-between gap-3 px-3 py-3 sm:flex-row sm:items-center md:py-5" |
|||
> |
|||
<div class="flex min-w-0 flex-1 flex-col gap-1"> |
|||
<h3 |
|||
class="truncate font-semibold text-gray-700 dark:text-neutral-200" |
|||
> |
|||
{{ group.name }} |
|||
</h3> |
|||
<div |
|||
class="flex flex-col gap-1 text-xs text-gray-500 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-3 dark:text-neutral-400" |
|||
> |
|||
<span |
|||
v-if="hasDescription(group.description)" |
|||
class="min-w-0 break-words" |
|||
> |
|||
{{ group.description }} |
|||
</span> |
|||
<span class="shrink-0"> |
|||
{{ formatClientCount(group.assignedClientCount) }} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
<div class="flex shrink-0 items-center justify-end"> |
|||
<div |
|||
class="flex items-center justify-between gap-1 text-gray-400 dark:text-neutral-400" |
|||
> |
|||
<NuxtLink |
|||
:to="`/groups/${group.id}`" |
|||
class="rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-800 dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white" |
|||
:aria-label=" |
|||
$t('clientGroup.editGroupLabel', { name: group.name }) |
|||
" |
|||
:title="$t('clientGroup.edit')" |
|||
> |
|||
<IconsEdit class="w-5" /> |
|||
</NuxtLink> |
|||
<ClientGroupsDeleteDialog |
|||
trigger-class="rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-800 dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white" |
|||
:trigger-aria-label=" |
|||
$t('clientGroup.deleteGroupLabel', { |
|||
name: group.name, |
|||
}) |
|||
" |
|||
:trigger-title="$t('clientGroup.delete')" |
|||
:group-name="group.name" |
|||
:assigned-client-count="group.assignedClientCount" |
|||
@delete="deleteGroup(group.id)" |
|||
> |
|||
<IconsDelete class="w-5" /> |
|||
</ClientGroupsDeleteDialog> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</PanelBody> |
|||
</Panel> |
|||
</main> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { apiErrorMessage, pluralKey } from '../../utils/clientGroups'; |
|||
|
|||
const groupsStore = useClientGroupsStore(); |
|||
const toast = useToast(); |
|||
const { t } = useI18n(); |
|||
|
|||
const pending = ref(true); |
|||
const error = ref(false); |
|||
|
|||
await loadGroups(); |
|||
|
|||
async function loadGroups() { |
|||
pending.value = true; |
|||
error.value = false; |
|||
|
|||
try { |
|||
await groupsStore.refresh(); |
|||
} catch { |
|||
error.value = true; |
|||
} finally { |
|||
pending.value = false; |
|||
} |
|||
} |
|||
|
|||
function formatClientCount(count: number) { |
|||
return t( |
|||
pluralKey( |
|||
count, |
|||
'clientGroup.clientCountOne', |
|||
'clientGroup.clientCountOther' |
|||
), |
|||
{ count } |
|||
); |
|||
} |
|||
|
|||
function hasDescription(description: string | null | undefined) { |
|||
return Boolean(description?.trim()); |
|||
} |
|||
|
|||
async function deleteGroup(groupId: number) { |
|||
try { |
|||
await groupsStore.deleteGroup(groupId); |
|||
await groupsStore.refresh(); |
|||
toast.showToast({ |
|||
type: 'success', |
|||
message: t('clientGroup.deleted'), |
|||
}); |
|||
} catch (error) { |
|||
toast.showToast({ |
|||
type: 'error', |
|||
message: apiErrorMessage(error, t('toast.unknown')), |
|||
}); |
|||
} |
|||
} |
|||
</script> |
|||
@ -0,0 +1,85 @@ |
|||
<template> |
|||
<main> |
|||
<Panel> |
|||
<PanelHead> |
|||
<PanelHeadTitle>{{ $t('clientGroup.create') }}</PanelHeadTitle> |
|||
</PanelHead> |
|||
<PanelBody> |
|||
<div |
|||
v-if="formError" |
|||
class="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200" |
|||
> |
|||
{{ formError }} |
|||
</div> |
|||
<ClientGroupsForm |
|||
v-model="form" |
|||
:submit-label="$t('clientGroup.create')" |
|||
:show-firewall-ips="showFirewallIps" |
|||
@submit="submit" |
|||
> |
|||
<template #actions> |
|||
<NuxtLink to="/groups" class="col-span-2"> |
|||
<FormSecondaryActionField |
|||
as="span" |
|||
class="inline-flex w-full justify-center" |
|||
:label="$t('dialog.cancel')" |
|||
/> |
|||
</NuxtLink> |
|||
</template> |
|||
</ClientGroupsForm> |
|||
</PanelBody> |
|||
</Panel> |
|||
</main> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { |
|||
apiErrorMessage, |
|||
clientGroupFormToPayload, |
|||
createClientGroupForm, |
|||
invalidDefinedPolicyKey, |
|||
} from '../../utils/clientGroups'; |
|||
|
|||
const groupsStore = useClientGroupsStore(); |
|||
const globalStore = useGlobalStore(); |
|||
const toast = useToast(); |
|||
const { t } = useI18n(); |
|||
|
|||
const form = ref(createClientGroupForm()); |
|||
const formError = ref(''); |
|||
const showFirewallIps = computed( |
|||
() => globalStore.information?.firewallEnabled === true |
|||
); |
|||
|
|||
async function submit() { |
|||
const payload = clientGroupFormToPayload(form.value); |
|||
|
|||
if (!payload.name) { |
|||
formError.value = t('clientGroup.nameRequired'); |
|||
return; |
|||
} |
|||
|
|||
if (invalidDefinedPolicyKey(form.value, showFirewallIps.value)) { |
|||
formError.value = t('clientGroup.policyRequired'); |
|||
return; |
|||
} |
|||
|
|||
formError.value = ''; |
|||
|
|||
try { |
|||
await groupsStore.createGroup(payload); |
|||
await groupsStore.refresh(); |
|||
toast.showToast({ |
|||
type: 'success', |
|||
message: t('clientGroup.created'), |
|||
}); |
|||
await navigateTo('/groups'); |
|||
} catch (error) { |
|||
formError.value = apiErrorMessage(error, t('toast.unknown')); |
|||
toast.showToast({ |
|||
type: 'error', |
|||
message: formError.value, |
|||
}); |
|||
} |
|||
} |
|||
</script> |
|||
@ -0,0 +1,137 @@ |
|||
import { defineStore } from 'pinia'; |
|||
|
|||
import type { |
|||
ClientGroupMembership, |
|||
EffectivePolicyField, |
|||
} from '../utils/clientGroups'; |
|||
|
|||
export type ClientGroupListItem = { |
|||
id: number; |
|||
name: string; |
|||
description: string | null; |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
assignedClientCount: number; |
|||
createdAt: string | Date; |
|||
updatedAt: string | Date; |
|||
}; |
|||
|
|||
export type ClientGroupDetails = ClientGroupListItem & { |
|||
clients: { |
|||
id: number; |
|||
name: string; |
|||
enabled: boolean; |
|||
ipv4Address: string; |
|||
ipv6Address: string; |
|||
createdAt: string | Date; |
|||
updatedAt: string | Date; |
|||
}[]; |
|||
}; |
|||
|
|||
export type ClientEffectivePolicy = { |
|||
allowedIps: EffectivePolicyField; |
|||
dns: EffectivePolicyField; |
|||
firewallIps: EffectivePolicyField; |
|||
}; |
|||
|
|||
export const useClientGroupsStore = defineStore('ClientGroups', () => { |
|||
const groups = ref<ClientGroupListItem[] | null>(null); |
|||
const membership = ref<ClientGroupMembership[] | null>(null); |
|||
|
|||
async function refresh() { |
|||
groups.value = await $fetch<ClientGroupListItem[]>('/api/client-group'); |
|||
} |
|||
|
|||
async function refreshMembership() { |
|||
membership.value = await $fetch<ClientGroupMembership[]>( |
|||
'/api/client-group/membership' |
|||
); |
|||
} |
|||
|
|||
async function getDetails(groupId: number | string) { |
|||
return await $fetch<ClientGroupDetails>(`/api/client-group/${groupId}`); |
|||
} |
|||
|
|||
async function createGroup(body: { |
|||
name: string; |
|||
description: string | null; |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
}) { |
|||
return await $fetch('/api/client-group', { |
|||
method: 'post', |
|||
body, |
|||
}); |
|||
} |
|||
|
|||
async function updateGroup( |
|||
groupId: number | string, |
|||
body: { |
|||
name: string; |
|||
description: string | null; |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
} |
|||
) { |
|||
return await $fetch(`/api/client-group/${groupId}`, { |
|||
method: 'post', |
|||
body, |
|||
}); |
|||
} |
|||
|
|||
async function deleteGroup(groupId: number | string) { |
|||
return await $fetch(`/api/client-group/${groupId}`, { |
|||
method: 'delete', |
|||
}); |
|||
} |
|||
|
|||
async function assignClient(clientId: number, groupId: number) { |
|||
return await $fetch(`/api/client/${clientId}/groups/${groupId}`, { |
|||
method: 'post', |
|||
}); |
|||
} |
|||
|
|||
async function removeClient(clientId: number, groupId: number) { |
|||
return await $fetch(`/api/client/${clientId}/groups/${groupId}`, { |
|||
method: 'delete', |
|||
}); |
|||
} |
|||
|
|||
async function getClientGroups(clientId: number) { |
|||
return await $fetch<ClientGroupMembership[]>( |
|||
`/api/client/${clientId}/groups` |
|||
); |
|||
} |
|||
|
|||
async function setClientGroups(clientId: number, groupIds: number[]) { |
|||
return await $fetch(`/api/client/${clientId}/groups`, { |
|||
method: 'put', |
|||
body: { groupIds }, |
|||
}); |
|||
} |
|||
|
|||
async function getClientEffectivePolicy(clientId: number) { |
|||
return await $fetch<ClientEffectivePolicy>( |
|||
`/api/client/${clientId}/effective-policy` |
|||
); |
|||
} |
|||
|
|||
return { |
|||
groups, |
|||
membership, |
|||
refresh, |
|||
refreshMembership, |
|||
getDetails, |
|||
createGroup, |
|||
updateGroup, |
|||
deleteGroup, |
|||
assignClient, |
|||
removeClient, |
|||
getClientGroups, |
|||
setClientGroups, |
|||
getClientEffectivePolicy, |
|||
}; |
|||
}); |
|||
@ -0,0 +1,365 @@ |
|||
export type GroupPolicyValue = string[] | null; |
|||
|
|||
export type ClientGroupForm = { |
|||
name: string; |
|||
description: string; |
|||
allowedIps: GroupPolicyValue; |
|||
dns: GroupPolicyValue; |
|||
firewallIps: GroupPolicyValue; |
|||
}; |
|||
|
|||
export type ClientGroupPayload = { |
|||
name: string; |
|||
description: string | null; |
|||
allowedIps: GroupPolicyValue; |
|||
dns: GroupPolicyValue; |
|||
firewallIps: GroupPolicyValue; |
|||
}; |
|||
|
|||
export type ClientGroupSummarySource = { |
|||
allowedIps: GroupPolicyValue; |
|||
dns: GroupPolicyValue; |
|||
firewallIps: GroupPolicyValue; |
|||
}; |
|||
|
|||
export type ClientGroupPolicyKey = keyof ClientGroupSummarySource; |
|||
|
|||
export type ClientGroupPolicySource = ClientGroupSummarySource & { |
|||
id: number; |
|||
name: string; |
|||
}; |
|||
|
|||
export type ClientGroupMembership = { |
|||
clientId: number; |
|||
groupId: number; |
|||
position: number; |
|||
}; |
|||
|
|||
export type ClientGroupMembershipChange = { |
|||
changed: boolean; |
|||
groupIds: number[]; |
|||
}; |
|||
|
|||
export type ClientGroupMembershipActions = { |
|||
setGroups: (clientId: number, groupIds: number[]) => Promise<unknown>; |
|||
}; |
|||
|
|||
export type ClientGroupPolicyDraftState = { |
|||
value: GroupPolicyValue; |
|||
draft: string[] | null; |
|||
}; |
|||
|
|||
export type EffectivePolicyField = { |
|||
value: string[]; |
|||
source: 'client' | 'groups' | 'global'; |
|||
groups: { id: number; name: string }[]; |
|||
}; |
|||
|
|||
export function createClientGroupForm(): ClientGroupForm { |
|||
return { |
|||
name: '', |
|||
description: '', |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}; |
|||
} |
|||
|
|||
export function clientGroupToForm(group: ClientGroupPayload): ClientGroupForm { |
|||
return { |
|||
name: group.name, |
|||
description: group.description ?? '', |
|||
allowedIps: clonePolicyValue(group.allowedIps), |
|||
dns: clonePolicyValue(group.dns), |
|||
firewallIps: clonePolicyValue(group.firewallIps), |
|||
}; |
|||
} |
|||
|
|||
export function clientGroupFormToPayload( |
|||
form: ClientGroupForm |
|||
): ClientGroupPayload { |
|||
return { |
|||
name: form.name.trim(), |
|||
description: form.description.trim() || null, |
|||
allowedIps: normalizePolicyPayload(form.allowedIps), |
|||
dns: normalizePolicyPayload(form.dns), |
|||
firewallIps: normalizePolicyPayload(form.firewallIps), |
|||
}; |
|||
} |
|||
|
|||
export function clonePolicyValue(value: GroupPolicyValue): GroupPolicyValue { |
|||
return value === null ? null : [...value]; |
|||
} |
|||
|
|||
export function setPolicyDefined(value: GroupPolicyValue, defined: boolean) { |
|||
if (!defined) { |
|||
return null; |
|||
} |
|||
|
|||
return value === null || value.length === 0 ? [''] : value; |
|||
} |
|||
|
|||
export function createPolicyDraftState( |
|||
value: GroupPolicyValue |
|||
): ClientGroupPolicyDraftState { |
|||
return { |
|||
value: clonePolicyValue(value), |
|||
draft: clonePolicyValue(value), |
|||
}; |
|||
} |
|||
|
|||
export function setPolicyDraftDefined( |
|||
state: ClientGroupPolicyDraftState, |
|||
defined: boolean |
|||
): ClientGroupPolicyDraftState { |
|||
if (!defined) { |
|||
return { |
|||
value: null, |
|||
draft: |
|||
state.value === null ? clonePolicyValue(state.draft) : [...state.value], |
|||
}; |
|||
} |
|||
|
|||
const draft = clonePolicyValue(state.draft); |
|||
|
|||
return { |
|||
value: draft && draft.length > 0 ? [...draft] : [''], |
|||
draft: draft && draft.length > 0 ? draft : [''], |
|||
}; |
|||
} |
|||
|
|||
export function addPolicyEntry(value: GroupPolicyValue) { |
|||
return [...(value ?? []), '']; |
|||
} |
|||
|
|||
export function updatePolicyEntry( |
|||
value: GroupPolicyValue, |
|||
index: number, |
|||
entry: string |
|||
) { |
|||
const nextValue = [...(value ?? [])]; |
|||
nextValue[index] = entry; |
|||
return nextValue; |
|||
} |
|||
|
|||
export function removePolicyEntry(value: GroupPolicyValue, index: number) { |
|||
const nextValue = [...(value ?? [])]; |
|||
nextValue.splice(index, 1); |
|||
return nextValue.length > 0 ? nextValue : ['']; |
|||
} |
|||
|
|||
export function policyCount(value: GroupPolicyValue) { |
|||
return value === null ? null : value.length; |
|||
} |
|||
|
|||
export function pluralKey( |
|||
count: number, |
|||
singularKey: string, |
|||
pluralKey: string |
|||
) { |
|||
return count === 1 ? singularKey : pluralKey; |
|||
} |
|||
|
|||
export function groupPolicySummary(group: ClientGroupSummarySource) { |
|||
return { |
|||
allowedIps: policyCount(group.allowedIps), |
|||
dns: policyCount(group.dns), |
|||
firewallIps: policyCount(group.firewallIps), |
|||
}; |
|||
} |
|||
|
|||
export function groupIdsForClient( |
|||
membership: ClientGroupMembership[], |
|||
clientId: number |
|||
) { |
|||
return membership |
|||
.filter((entry) => entry.clientId === clientId) |
|||
.sort((a, b) => a.position - b.position) |
|||
.map((entry) => entry.groupId); |
|||
} |
|||
|
|||
export function clientIdsForGroup( |
|||
membership: ClientGroupMembership[], |
|||
groupId: number |
|||
) { |
|||
return membership |
|||
.filter((entry) => entry.groupId === groupId) |
|||
.map((entry) => entry.clientId); |
|||
} |
|||
|
|||
export function groupSelectionFromMembership( |
|||
membership: ClientGroupMembership[], |
|||
clientId: number |
|||
) { |
|||
return groupIdsForClient(membership, clientId).map(String); |
|||
} |
|||
|
|||
export function groupIdsFromSelection(selection: string[]) { |
|||
return selection.map(Number); |
|||
} |
|||
|
|||
export function selectedClientGroups<T extends ClientGroupPolicySource>( |
|||
groups: T[] | null | undefined, |
|||
selection: string[] |
|||
) { |
|||
const groupIds = groupIdsFromSelection(selection); |
|||
return groupIds |
|||
.map((groupId) => groups?.find((group) => group.id === groupId)) |
|||
.filter((group): group is T => !!group); |
|||
} |
|||
|
|||
export function groupManagesClientPolicy( |
|||
groups: ClientGroupPolicySource[] | null | undefined, |
|||
key: ClientGroupPolicyKey, |
|||
firewallEnabled = true |
|||
) { |
|||
if (!groups || groups.length === 0) { |
|||
return false; |
|||
} |
|||
|
|||
if (key === 'firewallIps' && !firewallEnabled) { |
|||
return false; |
|||
} |
|||
|
|||
return groups.some((group) => group[key] !== null && group[key]!.length > 0); |
|||
} |
|||
|
|||
export function groupManagedPolicyValue( |
|||
groups: ClientGroupPolicySource[] | null | undefined, |
|||
key: ClientGroupPolicyKey |
|||
) { |
|||
if (!groups || groups.length === 0) { |
|||
return null; |
|||
} |
|||
|
|||
const seen = new Set<string>(); |
|||
const value: string[] = []; |
|||
|
|||
for (const group of groups) { |
|||
const entries = group[key]; |
|||
|
|||
if (!entries || entries.length === 0) { |
|||
continue; |
|||
} |
|||
|
|||
for (const entry of entries) { |
|||
if (!seen.has(entry)) { |
|||
seen.add(entry); |
|||
value.push(entry); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return value.length > 0 ? value : null; |
|||
} |
|||
|
|||
export function visibleGroupPolicyKeys(firewallEnabled: boolean) { |
|||
return firewallEnabled |
|||
? (['allowedIps', 'dns', 'firewallIps'] as const) |
|||
: (['allowedIps', 'dns'] as const); |
|||
} |
|||
|
|||
export function invalidDefinedPolicyKey( |
|||
form: Pick<ClientGroupForm, 'allowedIps' | 'dns' | 'firewallIps'>, |
|||
firewallEnabled = true |
|||
) { |
|||
const keys = visibleGroupPolicyKeys(firewallEnabled); |
|||
|
|||
return ( |
|||
keys.find((key) => { |
|||
const value = form[key]; |
|||
return value !== null && value.some((entry) => entry.trim() === ''); |
|||
}) ?? null |
|||
); |
|||
} |
|||
|
|||
export function getMembershipChange( |
|||
initialSelection: string[], |
|||
selectedSelection: string[] |
|||
): ClientGroupMembershipChange { |
|||
const initialGroupIds = groupIdsFromSelection(initialSelection); |
|||
const groupIds = groupIdsFromSelection(selectedSelection); |
|||
|
|||
return { |
|||
changed: !orderedNumberArraysEqual(initialGroupIds, groupIds), |
|||
groupIds, |
|||
}; |
|||
} |
|||
|
|||
export async function saveClientGroupMembership( |
|||
clientId: number, |
|||
initialSelection: string[], |
|||
selectedSelection: string[], |
|||
actions: ClientGroupMembershipActions |
|||
) { |
|||
const change = getMembershipChange(initialSelection, selectedSelection); |
|||
|
|||
if (!change.changed) { |
|||
return false; |
|||
} |
|||
|
|||
await actions.setGroups(clientId, change.groupIds); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
export function addGroupSelection(selection: string[], groupId: string) { |
|||
if (!groupId || selection.includes(groupId)) { |
|||
return selection; |
|||
} |
|||
|
|||
return [...selection, groupId]; |
|||
} |
|||
|
|||
export function removeGroupSelection(selection: string[], groupId: string) { |
|||
return selection.filter((selectedGroupId) => selectedGroupId !== groupId); |
|||
} |
|||
|
|||
export function orderedNumberArraysEqual(first: number[], second: number[]) { |
|||
return ( |
|||
first.length === second.length && |
|||
first.every((value, index) => value === second[index]) |
|||
); |
|||
} |
|||
|
|||
export function effectivePolicyMessageKey(field: EffectivePolicyField) { |
|||
if (field.source === 'groups') { |
|||
return field.groups.length === 1 |
|||
? 'clientGroup.managedByOneGroupLinked' |
|||
: 'clientGroup.managedByManyGroupsLinked'; |
|||
} |
|||
|
|||
if (field.source === 'global') { |
|||
return 'clientGroup.inheritedFromGlobal'; |
|||
} |
|||
|
|||
return 'clientGroup.usingClientValue'; |
|||
} |
|||
|
|||
export function apiErrorMessage(error: unknown, fallback: string) { |
|||
if ( |
|||
error && |
|||
typeof error === 'object' && |
|||
'data' in error && |
|||
error.data && |
|||
typeof error.data === 'object' && |
|||
'message' in error.data && |
|||
typeof error.data.message === 'string' |
|||
) { |
|||
return error.data.message; |
|||
} |
|||
|
|||
if (error instanceof Error && error.message) { |
|||
return error.message; |
|||
} |
|||
|
|||
return fallback; |
|||
} |
|||
|
|||
function normalizePolicyPayload(value: GroupPolicyValue): GroupPolicyValue { |
|||
if (value === null) { |
|||
return null; |
|||
} |
|||
|
|||
return value.map((entry) => entry.trim()); |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
import { createError, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGroupGetSchema } from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'admin', |
|||
'any', |
|||
async ({ event }) => { |
|||
const { groupId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupGetSchema, event) |
|||
); |
|||
|
|||
const group = await Database.clientGroups.get(groupId); |
|||
|
|||
if (!group) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client group not found', |
|||
}); |
|||
} |
|||
|
|||
await Database.clientGroups.delete(groupId); |
|||
return { success: true }; |
|||
} |
|||
); |
|||
@ -0,0 +1,28 @@ |
|||
import { createError, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGroupGetSchema } from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'admin', |
|||
'any', |
|||
async ({ event }) => { |
|||
const { groupId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupGetSchema, event) |
|||
); |
|||
|
|||
const group = await Database.clientGroups.getDetails(groupId); |
|||
|
|||
if (!group) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client group not found', |
|||
}); |
|||
} |
|||
|
|||
return group; |
|||
} |
|||
); |
|||
@ -0,0 +1,48 @@ |
|||
import { createError, getValidatedRouterParams, readValidatedBody } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { |
|||
ClientGroupGetSchema, |
|||
ClientGroupUpdateSchema, |
|||
} from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'admin', |
|||
'any', |
|||
async ({ event }) => { |
|||
const { groupId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupGetSchema, event) |
|||
); |
|||
|
|||
const data = await readValidatedBody( |
|||
event, |
|||
validateZod(ClientGroupUpdateSchema, event) |
|||
); |
|||
|
|||
try { |
|||
const group = await Database.clientGroups.update(groupId, data); |
|||
return { success: true, group }; |
|||
} catch (error) { |
|||
if (error instanceof Error) { |
|||
if (error.message === 'Client group not found') { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: error.message, |
|||
}); |
|||
} |
|||
|
|||
if (error.message === 'Client group already exists') { |
|||
throw createError({ |
|||
statusCode: 409, |
|||
statusMessage: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
); |
|||
@ -0,0 +1,6 @@ |
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
|
|||
export default definePermissionEventHandler('admin', 'any', async () => { |
|||
return Database.clientGroups.list(); |
|||
}); |
|||
@ -0,0 +1,34 @@ |
|||
import { createError, readValidatedBody } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGroupCreateSchema } from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'admin', |
|||
'any', |
|||
async ({ event }) => { |
|||
const data = await readValidatedBody( |
|||
event, |
|||
validateZod(ClientGroupCreateSchema, event) |
|||
); |
|||
|
|||
try { |
|||
const group = await Database.clientGroups.create(data); |
|||
return { success: true, group }; |
|||
} catch (error) { |
|||
if ( |
|||
error instanceof Error && |
|||
error.message === 'Client group already exists' |
|||
) { |
|||
throw createError({ |
|||
statusCode: 409, |
|||
statusMessage: error.message, |
|||
}); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
); |
|||
@ -0,0 +1,6 @@ |
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
|
|||
export default definePermissionEventHandler('admin', 'any', async () => { |
|||
return Database.clientGroups.listMembership(); |
|||
}); |
|||
@ -0,0 +1,43 @@ |
|||
import { createError, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { resolveClientEffectivePolicy } from '#shared/utils/clientPolicy'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGetSchema } from '#db/repositories/client/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'clients', |
|||
'view', |
|||
async ({ event, checkPermissions }) => { |
|||
const { clientId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGetSchema, event) |
|||
); |
|||
|
|||
const client = await Database.clients.get(clientId); |
|||
checkPermissions(client); |
|||
|
|||
if (!client) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client not found', |
|||
}); |
|||
} |
|||
|
|||
const [wgInterface, userConfig, groups] = await Promise.all([ |
|||
Database.interfaces.get(), |
|||
Database.userConfigs.get(), |
|||
Database.clientGroups.getGroupsForClient(clientId), |
|||
]); |
|||
|
|||
const policy = resolveClientEffectivePolicy({ |
|||
client, |
|||
groups, |
|||
userConfig, |
|||
firewallEnabled: wgInterface.firewallEnabled, |
|||
}); |
|||
|
|||
return policy.fields; |
|||
} |
|||
); |
|||
@ -0,0 +1,33 @@ |
|||
import { createError, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import WireGuard from '#server/utils/WireGuard'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGroupMembershipDeleteParamsSchema } from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'clients', |
|||
'update', |
|||
async ({ event, checkPermissions }) => { |
|||
const { clientId, groupId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupMembershipDeleteParamsSchema, event) |
|||
); |
|||
|
|||
const client = await Database.clients.get(clientId); |
|||
checkPermissions(client); |
|||
|
|||
if (!client) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client not found', |
|||
}); |
|||
} |
|||
|
|||
await Database.clientGroups.removeClient(clientId, groupId); |
|||
await WireGuard.saveConfig(); |
|||
|
|||
return { success: true }; |
|||
} |
|||
); |
|||
@ -0,0 +1,48 @@ |
|||
import { createError, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import WireGuard from '#server/utils/WireGuard'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGroupMembershipDeleteParamsSchema } from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'clients', |
|||
'update', |
|||
async ({ event, checkPermissions }) => { |
|||
const { clientId, groupId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupMembershipDeleteParamsSchema, event) |
|||
); |
|||
|
|||
const client = await Database.clients.get(clientId); |
|||
checkPermissions(client); |
|||
|
|||
if (!client) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client not found', |
|||
}); |
|||
} |
|||
|
|||
try { |
|||
await Database.clientGroups.assignClient(clientId, groupId); |
|||
} catch (error) { |
|||
if ( |
|||
error instanceof Error && |
|||
error.message === 'Client group not found' |
|||
) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: error.message, |
|||
}); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
|
|||
await WireGuard.saveConfig(); |
|||
|
|||
return { success: true }; |
|||
} |
|||
); |
|||
@ -0,0 +1,29 @@ |
|||
import { createError, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGroupClientParamsSchema } from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'clients', |
|||
'view', |
|||
async ({ event, checkPermissions }) => { |
|||
const { clientId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupClientParamsSchema, event) |
|||
); |
|||
|
|||
const client = await Database.clients.get(clientId); |
|||
checkPermissions(client); |
|||
|
|||
if (!client) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client not found', |
|||
}); |
|||
} |
|||
|
|||
return Database.clientGroups.getClientGroups(clientId); |
|||
} |
|||
); |
|||
@ -0,0 +1,65 @@ |
|||
import { createError, getValidatedRouterParams, readValidatedBody } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import WireGuard from '#server/utils/WireGuard'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { |
|||
ClientGroupClientParamsSchema, |
|||
ClientGroupMembershipReplaceSchema, |
|||
} from '#db/repositories/clientGroup/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'clients', |
|||
'update', |
|||
async ({ event, checkPermissions }) => { |
|||
const { clientId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGroupClientParamsSchema, event) |
|||
); |
|||
const { groupIds } = await readValidatedBody( |
|||
event, |
|||
validateZod(ClientGroupMembershipReplaceSchema, event) |
|||
); |
|||
|
|||
const client = await Database.clients.get(clientId); |
|||
checkPermissions(client); |
|||
|
|||
if (!client) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client not found', |
|||
}); |
|||
} |
|||
|
|||
try { |
|||
await Database.clientGroups.setClientGroups(clientId, groupIds); |
|||
} catch (error) { |
|||
if ( |
|||
error instanceof Error && |
|||
error.message === 'Client group not found' |
|||
) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: error.message, |
|||
}); |
|||
} |
|||
|
|||
if ( |
|||
error instanceof Error && |
|||
error.message === 'Duplicate client group' |
|||
) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: error.message, |
|||
}); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
|
|||
await WireGuard.saveConfig(); |
|||
|
|||
return { success: true }; |
|||
} |
|||
); |
|||
@ -0,0 +1,14 @@ |
|||
CREATE TABLE `client_groups_table` ( |
|||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, |
|||
`name` text NOT NULL, |
|||
`description` text, |
|||
`allowed_ips` text, |
|||
`dns` text, |
|||
`firewall_ips` text, |
|||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, |
|||
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `client_groups_table_name_unique` ON `client_groups_table` (`name`);--> statement-breakpoint |
|||
ALTER TABLE `clients_table` ADD `group_id` integer REFERENCES `client_groups_table`(`id`) ON UPDATE cascade ON DELETE set null;--> statement-breakpoint |
|||
CREATE INDEX `clients_table_group_id_index` ON `clients_table` (`group_id`); |
|||
@ -0,0 +1,63 @@ |
|||
CREATE TABLE `client_group_memberships_table` ( |
|||
`client_id` integer NOT NULL, |
|||
`group_id` integer NOT NULL, |
|||
`position` integer NOT NULL, |
|||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, |
|||
PRIMARY KEY(`client_id`, `group_id`), |
|||
FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade, |
|||
FOREIGN KEY (`group_id`) REFERENCES `client_groups_table`(`id`) ON UPDATE cascade ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE INDEX `client_group_memberships_table_client_id_index` ON `client_group_memberships_table` (`client_id`);--> statement-breakpoint |
|||
CREATE INDEX `client_group_memberships_table_group_id_index` ON `client_group_memberships_table` (`group_id`);--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `client_group_memberships_table_client_position_unique` ON `client_group_memberships_table` (`client_id`,`position`);--> statement-breakpoint |
|||
INSERT INTO `client_group_memberships_table` (`client_id`, `group_id`, `position`) |
|||
SELECT `id`, `group_id`, 0 FROM `clients_table` WHERE `group_id` IS NOT NULL;--> statement-breakpoint |
|||
UPDATE `client_groups_table` SET `allowed_ips` = NULL WHERE `allowed_ips` = '[]';--> statement-breakpoint |
|||
UPDATE `client_groups_table` SET `dns` = NULL WHERE `dns` = '[]';--> statement-breakpoint |
|||
UPDATE `client_groups_table` SET `firewall_ips` = NULL WHERE `firewall_ips` = '[]';--> statement-breakpoint |
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint |
|||
CREATE TABLE `__new_clients_table` ( |
|||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, |
|||
`user_id` integer NOT NULL, |
|||
`interface_id` text NOT NULL, |
|||
`name` text NOT NULL, |
|||
`ipv4_address` text NOT NULL, |
|||
`ipv6_address` text NOT NULL, |
|||
`pre_up` text DEFAULT '' NOT NULL, |
|||
`post_up` text DEFAULT '' NOT NULL, |
|||
`pre_down` text DEFAULT '' NOT NULL, |
|||
`post_down` text DEFAULT '' NOT NULL, |
|||
`private_key` text NOT NULL, |
|||
`public_key` text NOT NULL, |
|||
`pre_shared_key` text NOT NULL, |
|||
`expires_at` text, |
|||
`allowed_ips` text, |
|||
`server_allowed_ips` text NOT NULL, |
|||
`firewall_ips` text, |
|||
`persistent_keepalive` integer NOT NULL, |
|||
`mtu` integer NOT NULL, |
|||
`j_c` integer, |
|||
`j_min` integer, |
|||
`j_max` integer, |
|||
`i1` text, |
|||
`i2` text, |
|||
`i3` text, |
|||
`i4` text, |
|||
`i5` text, |
|||
`dns` text, |
|||
`server_endpoint` text, |
|||
`enabled` integer NOT NULL, |
|||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, |
|||
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, |
|||
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE cascade ON DELETE restrict, |
|||
FOREIGN KEY (`interface_id`) REFERENCES `interfaces_table`(`name`) ON UPDATE cascade ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
INSERT INTO `__new_clients_table`("id", "user_id", "interface_id", "name", "ipv4_address", "ipv6_address", "pre_up", "post_up", "pre_down", "post_down", "private_key", "public_key", "pre_shared_key", "expires_at", "allowed_ips", "server_allowed_ips", "firewall_ips", "persistent_keepalive", "mtu", "j_c", "j_min", "j_max", "i1", "i2", "i3", "i4", "i5", "dns", "server_endpoint", "enabled", "created_at", "updated_at") SELECT "id", "user_id", "interface_id", "name", "ipv4_address", "ipv6_address", "pre_up", "post_up", "pre_down", "post_down", "private_key", "public_key", "pre_shared_key", "expires_at", "allowed_ips", "server_allowed_ips", "firewall_ips", "persistent_keepalive", "mtu", "j_c", "j_min", "j_max", "i1", "i2", "i3", "i4", "i5", "dns", "server_endpoint", "enabled", "created_at", "updated_at" FROM `clients_table`;--> statement-breakpoint |
|||
DROP TABLE `clients_table`;--> statement-breakpoint |
|||
ALTER TABLE `__new_clients_table` RENAME TO `clients_table`;--> statement-breakpoint |
|||
PRAGMA foreign_keys=ON;--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `clients_table_ipv4_address_unique` ON `clients_table` (`ipv4_address`);--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `clients_table_ipv6_address_unique` ON `clients_table` (`ipv6_address`);--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `public_key_interface_unique` ON `clients_table` (`public_key`,`interface_id`); |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,85 @@ |
|||
import { sql, relations } from 'drizzle-orm'; |
|||
import { |
|||
index, |
|||
int, |
|||
primaryKey, |
|||
sqliteTable, |
|||
text, |
|||
uniqueIndex, |
|||
} from 'drizzle-orm/sqlite-core'; |
|||
|
|||
import { client } from '../client/schema'; |
|||
|
|||
export const clientGroup = sqliteTable( |
|||
'client_groups_table', |
|||
{ |
|||
id: int().primaryKey({ autoIncrement: true }), |
|||
name: text().notNull(), |
|||
description: text(), |
|||
allowedIps: text('allowed_ips', { mode: 'json' }).$type<string[]>(), |
|||
dns: text({ mode: 'json' }).$type<string[]>(), |
|||
firewallIps: text('firewall_ips', { mode: 'json' }).$type< |
|||
string[] | null |
|||
>(), |
|||
createdAt: text('created_at') |
|||
.notNull() |
|||
.default(sql`(CURRENT_TIMESTAMP)`), |
|||
updatedAt: text('updated_at') |
|||
.notNull() |
|||
.default(sql`(CURRENT_TIMESTAMP)`) |
|||
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`), |
|||
}, |
|||
(table) => [uniqueIndex('client_groups_table_name_unique').on(table.name)] |
|||
); |
|||
|
|||
export const clientGroupMembership = sqliteTable( |
|||
'client_group_memberships_table', |
|||
{ |
|||
clientId: int('client_id') |
|||
.notNull() |
|||
.references(() => client.id, { |
|||
onDelete: 'cascade', |
|||
onUpdate: 'cascade', |
|||
}), |
|||
groupId: int('group_id') |
|||
.notNull() |
|||
.references(() => clientGroup.id, { |
|||
onDelete: 'cascade', |
|||
onUpdate: 'cascade', |
|||
}), |
|||
position: int().notNull(), |
|||
createdAt: text('created_at') |
|||
.notNull() |
|||
.default(sql`(CURRENT_TIMESTAMP)`), |
|||
}, |
|||
(table) => [ |
|||
primaryKey({ |
|||
name: 'client_group_memberships_table_client_group_pk', |
|||
columns: [table.clientId, table.groupId], |
|||
}), |
|||
index('client_group_memberships_table_client_id_index').on(table.clientId), |
|||
index('client_group_memberships_table_group_id_index').on(table.groupId), |
|||
uniqueIndex('client_group_memberships_table_client_position_unique').on( |
|||
table.clientId, |
|||
table.position |
|||
), |
|||
] |
|||
); |
|||
|
|||
export const clientGroupMembershipRelations = relations( |
|||
clientGroupMembership, |
|||
({ one }) => ({ |
|||
client: one(client, { |
|||
fields: [clientGroupMembership.clientId], |
|||
references: [client.id], |
|||
}), |
|||
group: one(clientGroup, { |
|||
fields: [clientGroupMembership.groupId], |
|||
references: [clientGroup.id], |
|||
}), |
|||
}) |
|||
); |
|||
|
|||
export const clientGroupRelations = relations(clientGroup, ({ many }) => ({ |
|||
memberships: many(clientGroupMembership), |
|||
})); |
|||
@ -0,0 +1,466 @@ |
|||
import { and, count, eq, inArray, ne, sql } from 'drizzle-orm'; |
|||
|
|||
import { clientGroup, clientGroupMembership } from './schema'; |
|||
import type { |
|||
ClientGroupCreateType, |
|||
ClientGroupDetailsType, |
|||
ClientGroupEdgeType, |
|||
ClientGroupMemberType, |
|||
ClientGroupMembershipType, |
|||
ClientGroupResultType, |
|||
ClientGroupUpdateType, |
|||
ClientGroupWithCountType, |
|||
} from './types'; |
|||
import { ClientGroupCreateSchema, ClientGroupUpdateSchema } from './types'; |
|||
|
|||
import { client } from '#db/schema'; |
|||
import type { DBType } from '#db/sqlite'; |
|||
import type { ID } from '#server/utils/types'; |
|||
|
|||
const CLIENT_GROUP_NAME_CONFLICT_MESSAGE = 'Client group already exists'; |
|||
|
|||
export function isClientGroupNameConflictError(error: unknown) { |
|||
let currentError = error; |
|||
|
|||
while (currentError instanceof Error) { |
|||
const code = (currentError as { code?: unknown }).code; |
|||
const message = currentError.message; |
|||
|
|||
const isSqliteConstraint = |
|||
code === 'SQLITE_CONSTRAINT' || |
|||
code === 'SQLITE_CONSTRAINT_UNIQUE' || |
|||
message.includes('SQLITE_CONSTRAINT') || |
|||
message.includes('UNIQUE constraint failed'); |
|||
|
|||
if ( |
|||
isSqliteConstraint && |
|||
(message.includes('client_groups_table.name') || |
|||
message.includes('client_groups_table_name_unique')) |
|||
) { |
|||
return true; |
|||
} |
|||
|
|||
currentError = (currentError as { cause?: unknown }).cause; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
function throwClientGroupNameConflict(error: unknown): never { |
|||
if (isClientGroupNameConflictError(error)) { |
|||
throw new Error(CLIENT_GROUP_NAME_CONFLICT_MESSAGE); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
|
|||
function normalizeGroupPolicy<T extends string[] | null>(value: T) { |
|||
return value && value.length === 0 ? null : value; |
|||
} |
|||
|
|||
function normalizeGroupData<T extends ClientGroupCreateType>(data: T): T { |
|||
return { |
|||
...data, |
|||
allowedIps: normalizeGroupPolicy(data.allowedIps), |
|||
dns: normalizeGroupPolicy(data.dns), |
|||
firewallIps: normalizeGroupPolicy(data.firewallIps), |
|||
}; |
|||
} |
|||
|
|||
function assertUniqueGroupIds(groupIds: ID[]) { |
|||
if (new Set(groupIds).size !== groupIds.length) { |
|||
throw new Error('Duplicate client group'); |
|||
} |
|||
} |
|||
|
|||
function createPreparedStatement(db: DBType) { |
|||
return { |
|||
findById: db.query.clientGroup |
|||
.findFirst({ where: eq(clientGroup.id, sql.placeholder('id')) }) |
|||
.prepare(), |
|||
delete: db |
|||
.delete(clientGroup) |
|||
.where(eq(clientGroup.id, sql.placeholder('id'))) |
|||
.prepare(), |
|||
}; |
|||
} |
|||
|
|||
export class ClientGroupService { |
|||
#db: DBType; |
|||
#statements: ReturnType<typeof createPreparedStatement>; |
|||
|
|||
constructor(db: DBType) { |
|||
this.#db = db; |
|||
this.#statements = createPreparedStatement(db); |
|||
} |
|||
|
|||
#toClientGroup(row: { |
|||
id: number; |
|||
name: string; |
|||
description: string | null; |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
createdAt: string; |
|||
updatedAt: string; |
|||
}): ClientGroupResultType { |
|||
return { |
|||
...row, |
|||
allowedIps: normalizeGroupPolicy(row.allowedIps), |
|||
dns: normalizeGroupPolicy(row.dns), |
|||
firewallIps: normalizeGroupPolicy(row.firewallIps), |
|||
createdAt: new Date(row.createdAt), |
|||
updatedAt: new Date(row.updatedAt), |
|||
}; |
|||
} |
|||
|
|||
#toClientGroupWithCount(row: { |
|||
id: number; |
|||
name: string; |
|||
description: string | null; |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
createdAt: string; |
|||
updatedAt: string; |
|||
assignedClientCount: number; |
|||
}): ClientGroupWithCountType { |
|||
return { |
|||
...this.#toClientGroup(row), |
|||
assignedClientCount: row.assignedClientCount, |
|||
}; |
|||
} |
|||
|
|||
#toClientGroupMember(row: { |
|||
id: number; |
|||
name: string; |
|||
enabled: boolean; |
|||
ipv4Address: string; |
|||
ipv6Address: string; |
|||
createdAt: string; |
|||
updatedAt: string; |
|||
}): ClientGroupMemberType { |
|||
return { |
|||
...row, |
|||
createdAt: new Date(row.createdAt), |
|||
updatedAt: new Date(row.updatedAt), |
|||
}; |
|||
} |
|||
|
|||
#withAssignedClientCount() { |
|||
return { |
|||
id: clientGroup.id, |
|||
name: clientGroup.name, |
|||
description: clientGroup.description, |
|||
allowedIps: clientGroup.allowedIps, |
|||
dns: clientGroup.dns, |
|||
firewallIps: clientGroup.firewallIps, |
|||
createdAt: clientGroup.createdAt, |
|||
updatedAt: clientGroup.updatedAt, |
|||
assignedClientCount: count(clientGroupMembership.clientId), |
|||
}; |
|||
} |
|||
|
|||
async create(data: ClientGroupCreateType) { |
|||
const parsedData = normalizeGroupData(ClientGroupCreateSchema.parse(data)); |
|||
const existingGroup = await this.#db.query.clientGroup |
|||
.findFirst({ where: eq(clientGroup.name, parsedData.name) }) |
|||
.execute(); |
|||
|
|||
if (existingGroup) { |
|||
throw new Error(CLIENT_GROUP_NAME_CONFLICT_MESSAGE); |
|||
} |
|||
|
|||
const [createdGroup] = await this.#db |
|||
.insert(clientGroup) |
|||
.values(parsedData) |
|||
.returning() |
|||
.execute() |
|||
.catch(throwClientGroupNameConflict); |
|||
|
|||
if (!createdGroup) { |
|||
throw new Error('Client group was not created'); |
|||
} |
|||
|
|||
return this.#toClientGroup(createdGroup); |
|||
} |
|||
|
|||
async list(): Promise<ClientGroupWithCountType[]> { |
|||
const groups = await this.#db |
|||
.select(this.#withAssignedClientCount()) |
|||
.from(clientGroup) |
|||
.leftJoin( |
|||
clientGroupMembership, |
|||
eq(clientGroupMembership.groupId, clientGroup.id) |
|||
) |
|||
.groupBy(clientGroup.id) |
|||
.orderBy(clientGroup.name) |
|||
.execute(); |
|||
|
|||
return groups.map((group) => this.#toClientGroupWithCount(group)); |
|||
} |
|||
|
|||
async get(id: ID): Promise<ClientGroupWithCountType | undefined> { |
|||
const [group] = await this.#db |
|||
.select(this.#withAssignedClientCount()) |
|||
.from(clientGroup) |
|||
.leftJoin( |
|||
clientGroupMembership, |
|||
eq(clientGroupMembership.groupId, clientGroup.id) |
|||
) |
|||
.where(eq(clientGroup.id, id)) |
|||
.groupBy(clientGroup.id) |
|||
.execute(); |
|||
|
|||
return group ? this.#toClientGroupWithCount(group) : undefined; |
|||
} |
|||
|
|||
async getDetails(id: ID): Promise<ClientGroupDetailsType | undefined> { |
|||
const group = await this.get(id); |
|||
|
|||
if (!group) { |
|||
return undefined; |
|||
} |
|||
|
|||
const clients = await this.#db |
|||
.select({ |
|||
id: client.id, |
|||
name: client.name, |
|||
enabled: client.enabled, |
|||
ipv4Address: client.ipv4Address, |
|||
ipv6Address: client.ipv6Address, |
|||
createdAt: client.createdAt, |
|||
updatedAt: client.updatedAt, |
|||
}) |
|||
.from(clientGroupMembership) |
|||
.innerJoin(client, eq(client.id, clientGroupMembership.clientId)) |
|||
.where(eq(clientGroupMembership.groupId, id)) |
|||
.orderBy(clientGroupMembership.position, client.name) |
|||
.execute(); |
|||
|
|||
return { |
|||
...group, |
|||
clients: clients.map((member) => this.#toClientGroupMember(member)), |
|||
}; |
|||
} |
|||
|
|||
async update(id: ID, data: ClientGroupUpdateType) { |
|||
const parsedData = normalizeGroupData(ClientGroupUpdateSchema.parse(data)); |
|||
const existingGroup = await this.#db.query.clientGroup |
|||
.findFirst({ |
|||
where: and( |
|||
eq(clientGroup.name, parsedData.name), |
|||
ne(clientGroup.id, id) |
|||
), |
|||
}) |
|||
.execute(); |
|||
|
|||
if (existingGroup) { |
|||
throw new Error(CLIENT_GROUP_NAME_CONFLICT_MESSAGE); |
|||
} |
|||
|
|||
const [updatedGroup] = await this.#db |
|||
.update(clientGroup) |
|||
.set(parsedData) |
|||
.where(eq(clientGroup.id, id)) |
|||
.returning() |
|||
.execute() |
|||
.catch(throwClientGroupNameConflict); |
|||
|
|||
if (!updatedGroup) { |
|||
throw new Error('Client group not found'); |
|||
} |
|||
|
|||
return this.#toClientGroup(updatedGroup); |
|||
} |
|||
|
|||
delete(id: ID) { |
|||
return this.#statements.delete.execute({ id }); |
|||
} |
|||
|
|||
async assignClient(clientId: ID, groupId: ID) { |
|||
return this.#db.transaction(async (tx) => { |
|||
const txClient = await tx.query.client |
|||
.findFirst({ where: eq(client.id, clientId) }) |
|||
.execute(); |
|||
|
|||
if (!txClient) { |
|||
throw new Error('Client not found'); |
|||
} |
|||
|
|||
const txGroup = await tx.query.clientGroup |
|||
.findFirst({ where: eq(clientGroup.id, groupId) }) |
|||
.execute(); |
|||
|
|||
if (!txGroup) { |
|||
throw new Error('Client group not found'); |
|||
} |
|||
|
|||
const existingMembership = await tx.query.clientGroupMembership |
|||
.findFirst({ |
|||
where: and( |
|||
eq(clientGroupMembership.clientId, clientId), |
|||
eq(clientGroupMembership.groupId, groupId) |
|||
), |
|||
}) |
|||
.execute(); |
|||
|
|||
if (existingMembership) { |
|||
return; |
|||
} |
|||
|
|||
const memberships = await tx.query.clientGroupMembership |
|||
.findMany({ |
|||
where: eq(clientGroupMembership.clientId, clientId), |
|||
columns: { position: true }, |
|||
}) |
|||
.execute(); |
|||
const nextPosition = |
|||
memberships.reduce( |
|||
(maxPosition, membership) => |
|||
Math.max(maxPosition, membership.position), |
|||
-1 |
|||
) + 1; |
|||
|
|||
await tx |
|||
.insert(clientGroupMembership) |
|||
.values({ clientId, groupId, position: nextPosition }) |
|||
.execute(); |
|||
}); |
|||
} |
|||
|
|||
async removeClient(clientId: ID, groupId: ID) { |
|||
return this.#db |
|||
.delete(clientGroupMembership) |
|||
.where( |
|||
and( |
|||
eq(clientGroupMembership.clientId, clientId), |
|||
eq(clientGroupMembership.groupId, groupId) |
|||
) |
|||
) |
|||
.execute(); |
|||
} |
|||
|
|||
async setClientGroups(clientId: ID, groupIds: ID[]) { |
|||
assertUniqueGroupIds(groupIds); |
|||
|
|||
return this.#db.transaction(async (tx) => { |
|||
const txClient = await tx.query.client |
|||
.findFirst({ where: eq(client.id, clientId) }) |
|||
.execute(); |
|||
|
|||
if (!txClient) { |
|||
throw new Error('Client not found'); |
|||
} |
|||
|
|||
if (groupIds.length > 0) { |
|||
const existingGroups = await tx.query.clientGroup |
|||
.findMany({ |
|||
where: inArray(clientGroup.id, groupIds), |
|||
columns: { id: true }, |
|||
}) |
|||
.execute(); |
|||
const existingGroupIds = new Set( |
|||
existingGroups.map((group) => group.id) |
|||
); |
|||
const missingGroupId = groupIds.find( |
|||
(groupId) => !existingGroupIds.has(groupId) |
|||
); |
|||
|
|||
if (missingGroupId) { |
|||
throw new Error('Client group not found'); |
|||
} |
|||
} |
|||
|
|||
await tx |
|||
.delete(clientGroupMembership) |
|||
.where(eq(clientGroupMembership.clientId, clientId)) |
|||
.execute(); |
|||
|
|||
if (groupIds.length > 0) { |
|||
await tx |
|||
.insert(clientGroupMembership) |
|||
.values( |
|||
groupIds.map((groupId, position) => ({ |
|||
clientId, |
|||
groupId, |
|||
position, |
|||
})) |
|||
) |
|||
.execute(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
async countAssignedClients(groupId: ID) { |
|||
return this.#db.$count( |
|||
clientGroupMembership, |
|||
eq(clientGroupMembership.groupId, groupId) |
|||
); |
|||
} |
|||
|
|||
async listMembership(): Promise<ClientGroupMembershipType[]> { |
|||
const rows = await this.#db |
|||
.select({ |
|||
clientId: clientGroupMembership.clientId, |
|||
groupId: clientGroupMembership.groupId, |
|||
position: clientGroupMembership.position, |
|||
}) |
|||
.from(clientGroupMembership) |
|||
.innerJoin(client, eq(client.id, clientGroupMembership.clientId)) |
|||
.orderBy(client.name, clientGroupMembership.position) |
|||
.execute(); |
|||
|
|||
return rows; |
|||
} |
|||
|
|||
async getClientGroups(clientId: ID): Promise<ClientGroupEdgeType[]> { |
|||
const txClient = await this.#db.query.client |
|||
.findFirst({ |
|||
where: eq(client.id, clientId), |
|||
columns: { id: true }, |
|||
}) |
|||
.execute(); |
|||
|
|||
if (!txClient) { |
|||
throw new Error('Client not found'); |
|||
} |
|||
|
|||
const rows = await this.#db |
|||
.select({ |
|||
clientId: clientGroupMembership.clientId, |
|||
groupId: clientGroupMembership.groupId, |
|||
position: clientGroupMembership.position, |
|||
groupName: clientGroup.name, |
|||
}) |
|||
.from(clientGroupMembership) |
|||
.innerJoin(clientGroup, eq(clientGroup.id, clientGroupMembership.groupId)) |
|||
.where(eq(clientGroupMembership.clientId, clientId)) |
|||
.orderBy(clientGroupMembership.position, clientGroup.name) |
|||
.execute(); |
|||
|
|||
return rows; |
|||
} |
|||
|
|||
async getGroupsForClient(clientId: ID): Promise<ClientGroupResultType[]> { |
|||
const rows = await this.#db |
|||
.select({ |
|||
id: clientGroup.id, |
|||
name: clientGroup.name, |
|||
description: clientGroup.description, |
|||
allowedIps: clientGroup.allowedIps, |
|||
dns: clientGroup.dns, |
|||
firewallIps: clientGroup.firewallIps, |
|||
createdAt: clientGroup.createdAt, |
|||
updatedAt: clientGroup.updatedAt, |
|||
position: clientGroupMembership.position, |
|||
}) |
|||
.from(clientGroupMembership) |
|||
.innerJoin(clientGroup, eq(clientGroup.id, clientGroupMembership.groupId)) |
|||
.where(eq(clientGroupMembership.clientId, clientId)) |
|||
.orderBy(clientGroupMembership.position, clientGroup.name) |
|||
.execute(); |
|||
|
|||
return rows.map((row) => this.#toClientGroup(row)); |
|||
} |
|||
} |
|||
@ -0,0 +1,157 @@ |
|||
import type { InferSelectModel } from 'drizzle-orm'; |
|||
import z from 'zod'; |
|||
import { isIP } from 'is-ip'; |
|||
import isCidr from 'is-cidr'; |
|||
|
|||
import type { clientGroup, clientGroupMembership } from './schema'; |
|||
|
|||
import { |
|||
AddressSchema, |
|||
DnsSchema, |
|||
FirewallIpsSchema, |
|||
controlStringRefine, |
|||
safeStringRefine, |
|||
schemaForType, |
|||
t, |
|||
} from '#server/utils/types'; |
|||
|
|||
export type ClientGroupType = InferSelectModel<typeof clientGroup>; |
|||
export type ClientGroupMembershipRowType = InferSelectModel< |
|||
typeof clientGroupMembership |
|||
>; |
|||
|
|||
export type ClientGroupCreateType = Pick< |
|||
ClientGroupType, |
|||
'name' | 'description' | 'allowedIps' | 'dns' | 'firewallIps' |
|||
>; |
|||
|
|||
export type ClientGroupUpdateType = ClientGroupCreateType; |
|||
|
|||
export type ClientGroupResultType = Omit< |
|||
ClientGroupType, |
|||
'createdAt' | 'updatedAt' |
|||
> & { |
|||
createdAt: Date; |
|||
updatedAt: Date; |
|||
}; |
|||
|
|||
export type ClientGroupWithCountType = ClientGroupResultType & { |
|||
assignedClientCount: number; |
|||
}; |
|||
|
|||
export type ClientGroupMemberType = { |
|||
id: number; |
|||
name: string; |
|||
enabled: boolean; |
|||
ipv4Address: string; |
|||
ipv6Address: string; |
|||
createdAt: Date; |
|||
updatedAt: Date; |
|||
}; |
|||
|
|||
export type ClientGroupDetailsType = ClientGroupWithCountType & { |
|||
clients: ClientGroupMemberType[]; |
|||
}; |
|||
|
|||
export type ClientGroupMembershipType = { |
|||
clientId: number; |
|||
groupId: number; |
|||
position: number; |
|||
}; |
|||
|
|||
export type ClientGroupEdgeType = ClientGroupMembershipType & { |
|||
groupName: string; |
|||
}; |
|||
|
|||
export type ClientGroupEffectivePolicyType = { |
|||
allowedIps: SafeEffectivePolicyFieldType; |
|||
dns: SafeEffectivePolicyFieldType; |
|||
firewallIps: SafeEffectivePolicyFieldType; |
|||
}; |
|||
|
|||
export type SafeEffectivePolicyFieldType = { |
|||
value: string[]; |
|||
source: 'client' | 'groups' | 'global'; |
|||
groups: { id: number; name: string }[]; |
|||
}; |
|||
|
|||
const name = z |
|||
.string({ message: t('zod.clientGroup.name') }) |
|||
.trim() |
|||
.min(1, t('zod.clientGroup.name')) |
|||
.pipe(safeStringRefine) |
|||
.pipe(controlStringRefine); |
|||
|
|||
const description = z |
|||
.string({ message: t('zod.clientGroup.description') }) |
|||
.pipe(safeStringRefine) |
|||
.pipe(controlStringRefine) |
|||
.nullable(); |
|||
|
|||
const groupAllowedIp = AddressSchema.refine( |
|||
(value) => isIP(value) || isCidr(value), |
|||
{ |
|||
message: t('zod.allowedIps'), |
|||
} |
|||
); |
|||
|
|||
const nonEmptyPolicyArray = <T extends z.ZodTypeAny>(schema: T) => |
|||
z |
|||
.array(schema) |
|||
.refine((values) => values.some((value) => String(value).trim() !== ''), { |
|||
message: t('zod.clientGroup.policyRequired'), |
|||
}) |
|||
.min(1, { message: t('zod.clientGroup.policyRequired') }); |
|||
|
|||
export const ClientGroupCreateSchema = schemaForType<ClientGroupCreateType>()( |
|||
z.object({ |
|||
name, |
|||
description, |
|||
allowedIps: nonEmptyPolicyArray(groupAllowedIp).nullable(), |
|||
dns: DnsSchema.min(1, { message: t('zod.clientGroup.policyRequired') }) |
|||
.refine((values) => values.every((value) => value.trim() !== ''), { |
|||
message: t('zod.clientGroup.policyRequired'), |
|||
}) |
|||
.nullable(), |
|||
firewallIps: FirewallIpsSchema.min(1, { |
|||
message: t('zod.clientGroup.policyRequired'), |
|||
}) |
|||
.refine((values) => values.every((value) => value.trim() !== ''), { |
|||
message: t('zod.clientGroup.policyRequired'), |
|||
}) |
|||
.nullable(), |
|||
}) |
|||
); |
|||
|
|||
export const ClientGroupUpdateSchema = ClientGroupCreateSchema; |
|||
|
|||
const groupId = z.coerce |
|||
.number({ message: t('zod.clientGroup.id') }) |
|||
.int() |
|||
.positive(); |
|||
|
|||
const clientId = z.coerce |
|||
.number({ message: t('zod.client.id') }) |
|||
.int() |
|||
.positive(); |
|||
|
|||
export const ClientGroupGetSchema = z.object({ |
|||
groupId, |
|||
}); |
|||
|
|||
export const ClientGroupAssignSchema = z.object({ |
|||
groupId, |
|||
}); |
|||
|
|||
export const ClientGroupClientParamsSchema = z.object({ |
|||
clientId, |
|||
}); |
|||
|
|||
export const ClientGroupMembershipReplaceSchema = z.object({ |
|||
groupIds: z.array(groupId), |
|||
}); |
|||
|
|||
export const ClientGroupMembershipDeleteParamsSchema = z.object({ |
|||
clientId, |
|||
groupId, |
|||
}); |
|||
@ -0,0 +1,183 @@ |
|||
export type ClientPolicyGroup = { |
|||
id: number; |
|||
name: string; |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
}; |
|||
|
|||
export type ClientPolicyDraft = { |
|||
allowedIps: string[] | null; |
|||
dns: string[] | null; |
|||
firewallIps: string[] | null; |
|||
}; |
|||
|
|||
export type ClientPolicyDefaults = { |
|||
defaultAllowedIps: string[]; |
|||
defaultDns: string[]; |
|||
}; |
|||
|
|||
export type EffectivePolicySource = 'client' | 'groups' | 'global'; |
|||
|
|||
export type EffectiveClientPolicyField = { |
|||
value: string[]; |
|||
source: EffectivePolicySource; |
|||
groups: { id: number; name: string }[]; |
|||
}; |
|||
|
|||
export type EffectiveClientPolicy = { |
|||
allowedIps: string[]; |
|||
dns: string[]; |
|||
firewallIps: string[]; |
|||
fields: { |
|||
allowedIps: EffectiveClientPolicyField; |
|||
dns: EffectiveClientPolicyField; |
|||
firewallIps: EffectiveClientPolicyField; |
|||
}; |
|||
groupManagedAllowedIps: boolean; |
|||
groupManagedDns: boolean; |
|||
groupManagedFirewallIps: boolean; |
|||
}; |
|||
|
|||
export function resolveClientEffectivePolicy({ |
|||
client, |
|||
groups = [], |
|||
userConfig, |
|||
firewallEnabled, |
|||
}: { |
|||
client: ClientPolicyDraft; |
|||
groups?: ClientPolicyGroup[]; |
|||
userConfig: ClientPolicyDefaults; |
|||
firewallEnabled: boolean; |
|||
}): EffectiveClientPolicy { |
|||
const isGrouped = groups.length > 0; |
|||
const allowedIps = resolvePolicyField({ |
|||
isGrouped, |
|||
groups, |
|||
field: 'allowedIps', |
|||
clientValue: client.allowedIps, |
|||
globalValue: userConfig.defaultAllowedIps, |
|||
}); |
|||
const dns = resolvePolicyField({ |
|||
isGrouped, |
|||
groups, |
|||
field: 'dns', |
|||
clientValue: client.dns, |
|||
globalValue: userConfig.defaultDns, |
|||
}); |
|||
const firewallIps = resolveFirewallPolicyField({ |
|||
isGrouped, |
|||
groups, |
|||
clientValue: client.firewallIps, |
|||
fallbackValue: allowedIps.value, |
|||
firewallEnabled, |
|||
}); |
|||
|
|||
return { |
|||
allowedIps: allowedIps.value, |
|||
dns: dns.value, |
|||
firewallIps: firewallIps.value, |
|||
fields: { |
|||
allowedIps, |
|||
dns, |
|||
firewallIps, |
|||
}, |
|||
groupManagedAllowedIps: allowedIps.source === 'groups', |
|||
groupManagedDns: dns.source === 'groups', |
|||
groupManagedFirewallIps: firewallIps.source === 'groups', |
|||
}; |
|||
} |
|||
|
|||
function resolvePolicyField({ |
|||
isGrouped, |
|||
groups, |
|||
field, |
|||
clientValue, |
|||
globalValue, |
|||
}: { |
|||
isGrouped: boolean; |
|||
groups: ClientPolicyGroup[]; |
|||
field: 'allowedIps' | 'dns'; |
|||
clientValue: string[] | null; |
|||
globalValue: string[]; |
|||
}): EffectiveClientPolicyField { |
|||
if (!isGrouped) { |
|||
const draftValue = definedEntries(clientValue); |
|||
|
|||
return draftValue.length > 0 |
|||
? { value: draftValue, source: 'client', groups: [] } |
|||
: { value: globalValue, source: 'global', groups: [] }; |
|||
} |
|||
|
|||
const groupResult = unionGroupValues(groups, field); |
|||
|
|||
return groupResult.value.length > 0 |
|||
? { ...groupResult, source: 'groups' } |
|||
: { value: globalValue, source: 'global', groups: [] }; |
|||
} |
|||
|
|||
function resolveFirewallPolicyField({ |
|||
isGrouped, |
|||
groups, |
|||
clientValue, |
|||
fallbackValue, |
|||
firewallEnabled, |
|||
}: { |
|||
isGrouped: boolean; |
|||
groups: ClientPolicyGroup[]; |
|||
clientValue: string[] | null; |
|||
fallbackValue: string[]; |
|||
firewallEnabled: boolean; |
|||
}): EffectiveClientPolicyField { |
|||
if (!firewallEnabled) { |
|||
return { value: [], source: 'global', groups: [] }; |
|||
} |
|||
|
|||
if (!isGrouped) { |
|||
const draftValue = definedEntries(clientValue); |
|||
|
|||
return draftValue.length > 0 |
|||
? { value: draftValue, source: 'client', groups: [] } |
|||
: { value: fallbackValue, source: 'global', groups: [] }; |
|||
} |
|||
|
|||
const groupResult = unionGroupValues(groups, 'firewallIps'); |
|||
|
|||
return groupResult.value.length > 0 |
|||
? { ...groupResult, source: 'groups' } |
|||
: { value: fallbackValue, source: 'global', groups: [] }; |
|||
} |
|||
|
|||
function unionGroupValues( |
|||
groups: ClientPolicyGroup[], |
|||
field: 'allowedIps' | 'dns' | 'firewallIps' |
|||
) { |
|||
const value: string[] = []; |
|||
const seen = new Set<string>(); |
|||
const contributors: { id: number; name: string }[] = []; |
|||
|
|||
for (const group of groups) { |
|||
const entries = definedEntries(group[field]); |
|||
|
|||
if (entries.length === 0) { |
|||
continue; |
|||
} |
|||
|
|||
contributors.push({ id: group.id, name: group.name }); |
|||
|
|||
for (const entry of entries) { |
|||
if (seen.has(entry)) { |
|||
continue; |
|||
} |
|||
|
|||
seen.add(entry); |
|||
value.push(entry); |
|||
} |
|||
} |
|||
|
|||
return { value, groups: contributors }; |
|||
} |
|||
|
|||
function definedEntries(value: string[] | null) { |
|||
return value?.map((entry) => entry.trim()).filter(Boolean) ?? []; |
|||
} |
|||
@ -0,0 +1,664 @@ |
|||
import { mkdtempSync, readFileSync } from 'node:fs'; |
|||
import { tmpdir } from 'node:os'; |
|||
import { join } from 'node:path'; |
|||
|
|||
import { createClient } from '@libsql/client'; |
|||
import { drizzle } from 'drizzle-orm/libsql'; |
|||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'; |
|||
|
|||
import { |
|||
ClientGroupService, |
|||
isClientGroupNameConflictError, |
|||
} from '../../server/database/repositories/clientGroup/service'; |
|||
import { |
|||
ClientGroupAssignSchema, |
|||
ClientGroupClientParamsSchema, |
|||
ClientGroupCreateSchema, |
|||
ClientGroupGetSchema, |
|||
} from '../../server/database/repositories/clientGroup/types'; |
|||
import * as schema from '../../server/database/schema'; |
|||
import { hasPermissions, roles } from '../../shared/utils/permissions'; |
|||
|
|||
const migrationsThrough0006 = [ |
|||
'0000_short_skin.sql', |
|||
'0001_classy_the_stranger.sql', |
|||
'0002_keen_sleepwalker.sql', |
|||
'0003_breezy_colossus.sql', |
|||
'0004_optimal_mandrill.sql', |
|||
'0005_clumsy_korg.sql', |
|||
'0006_clear_leech.sql', |
|||
]; |
|||
|
|||
const migration0007 = '0007_amused_madame_web.sql'; |
|||
const migration0008 = '0008_sweet_firelord.sql'; |
|||
|
|||
type LibsqlClient = ReturnType<typeof createClient>; |
|||
type TestDb = ReturnType<typeof drizzle<typeof schema>>; |
|||
|
|||
async function applyMigration(client: LibsqlClient, migration: string) { |
|||
const sql = readFileSync( |
|||
join(process.cwd(), 'server', 'database', 'migrations', migration), |
|||
'utf8' |
|||
); |
|||
|
|||
const statements = sql |
|||
.split('--> statement-breakpoint') |
|||
.map((statement) => statement.trim()) |
|||
.filter((statement) => statement !== 'PRAGMA journal_mode=WAL;') |
|||
.filter(Boolean); |
|||
|
|||
for (const statement of statements) { |
|||
await client.execute(statement); |
|||
} |
|||
} |
|||
|
|||
async function applyMigrations(client: LibsqlClient, migrations: string[]) { |
|||
for (const migration of migrations) { |
|||
await applyMigration(client, migration); |
|||
} |
|||
} |
|||
|
|||
async function createTestDb( |
|||
migrations = [...migrationsThrough0006, migration0007, migration0008] |
|||
) { |
|||
const dir = mkdtempSync(join(tmpdir(), 'wg-easy-client-groups-')); |
|||
const client = createClient({ url: `file:${join(dir, 'test.db')}` }); |
|||
await applyMigrations(client, migrations); |
|||
await client.execute('PRAGMA foreign_keys=ON'); |
|||
|
|||
const db = drizzle({ client, schema }); |
|||
return { client, db }; |
|||
} |
|||
|
|||
async function seedUser(db: TestDb) { |
|||
await db.insert(schema.user).values({ |
|||
id: 1, |
|||
username: 'admin', |
|||
password: 'hash', |
|||
email: null, |
|||
name: 'Administrator', |
|||
role: 2, |
|||
totpVerified: false, |
|||
enabled: true, |
|||
}); |
|||
} |
|||
|
|||
async function seedClient( |
|||
db: TestDb, |
|||
data: Partial<typeof schema.client.$inferInsert> = {} |
|||
) { |
|||
const [createdClient] = await db |
|||
.insert(schema.client) |
|||
.values({ |
|||
userId: 1, |
|||
interfaceId: 'wg0', |
|||
name: data.name ?? 'Phone', |
|||
ipv4Address: data.ipv4Address ?? '10.8.0.2', |
|||
ipv6Address: data.ipv6Address ?? 'fdcc:ad94:bacf:61a4::cafe:2', |
|||
privateKey: data.privateKey ?? 'private', |
|||
publicKey: data.publicKey ?? 'public', |
|||
preSharedKey: data.preSharedKey ?? 'psk', |
|||
allowedIps: data.allowedIps ?? ['10.0.0.0/24'], |
|||
dns: data.dns ?? ['1.1.1.1'], |
|||
firewallIps: data.firewallIps ?? ['10.0.0.1:443/tcp'], |
|||
persistentKeepalive: data.persistentKeepalive ?? 0, |
|||
mtu: data.mtu ?? 1420, |
|||
serverAllowedIps: data.serverAllowedIps ?? [], |
|||
enabled: data.enabled ?? true, |
|||
}) |
|||
.returning() |
|||
.execute(); |
|||
|
|||
if (!createdClient) { |
|||
throw new Error('Client was not created'); |
|||
} |
|||
|
|||
return createdClient; |
|||
} |
|||
|
|||
async function seedExistingClientBefore0007(client: LibsqlClient) { |
|||
await client.execute({ |
|||
sql: `INSERT INTO users_table
|
|||
(id, username, password, email, name, role, totp_verified, enabled) |
|||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|||
args: [1, 'admin', 'hash', null, 'Administrator', 2, 0, 1], |
|||
}); |
|||
|
|||
await client.execute({ |
|||
sql: `INSERT INTO clients_table
|
|||
( |
|||
id, user_id, interface_id, name, ipv4_address, ipv6_address, |
|||
pre_up, post_up, pre_down, post_down, |
|||
private_key, public_key, pre_shared_key, expires_at, |
|||
allowed_ips, server_allowed_ips, firewall_ips, |
|||
persistent_keepalive, mtu, j_c, j_min, j_max, |
|||
i1, i2, i3, i4, i5, dns, server_endpoint, enabled |
|||
) |
|||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|||
args: [ |
|||
7, |
|||
1, |
|||
'wg0', |
|||
'Migrated Phone', |
|||
'10.8.0.77', |
|||
'fdcc:ad94:bacf:61a4::cafe:77', |
|||
'pre-up', |
|||
'post-up', |
|||
'pre-down', |
|||
'post-down', |
|||
'private-existing', |
|||
'public-existing', |
|||
'psk-existing', |
|||
'2030-01-01T00:00:00.000Z', |
|||
JSON.stringify(['10.77.0.0/24']), |
|||
JSON.stringify(['192.168.77.0/24']), |
|||
JSON.stringify(['10.77.0.10:8443/tcp']), |
|||
25, |
|||
1280, |
|||
9, |
|||
11, |
|||
999, |
|||
'i1-value', |
|||
'i2-value', |
|||
'i3-value', |
|||
'i4-value', |
|||
'i5-value', |
|||
JSON.stringify(['9.9.9.9']), |
|||
'vpn.example.test', |
|||
0, |
|||
], |
|||
}); |
|||
} |
|||
|
|||
describe('ClientGroupService', () => { |
|||
let client: LibsqlClient; |
|||
let db: TestDb; |
|||
let service: ClientGroupService; |
|||
|
|||
beforeEach(async () => { |
|||
const testDb = await createTestDb(); |
|||
client = testDb.client; |
|||
db = testDb.db; |
|||
service = new ClientGroupService(db); |
|||
await seedUser(db); |
|||
}); |
|||
|
|||
afterEach(async () => { |
|||
await client.close(); |
|||
}); |
|||
|
|||
test('creates, lists deterministically, gets, updates, and deletes client groups', async () => { |
|||
const secondGroup = await service.create({ |
|||
name: 'Vendors', |
|||
description: 'External vendor devices', |
|||
allowedIps: ['10.20.0.0/24'], |
|||
dns: ['9.9.9.9'], |
|||
firewallIps: ['10.20.0.10:443/tcp'], |
|||
}); |
|||
const firstGroup = await service.create({ |
|||
name: 'Customers', |
|||
description: 'Production customer devices', |
|||
allowedIps: ['10.10.0.0/24'], |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: ['10.10.0.10:443/tcp'], |
|||
}); |
|||
|
|||
expect(firstGroup.createdAt).toBeInstanceOf(Date); |
|||
expect(firstGroup.updatedAt).toBeInstanceOf(Date); |
|||
await expect(service.list()).resolves.toMatchObject([ |
|||
{ id: firstGroup.id, name: 'Customers', assignedClientCount: 0 }, |
|||
{ id: secondGroup.id, name: 'Vendors', assignedClientCount: 0 }, |
|||
]); |
|||
|
|||
await expect(service.get(firstGroup.id)).resolves.toMatchObject({ |
|||
id: firstGroup.id, |
|||
assignedClientCount: 0, |
|||
}); |
|||
await expect(service.get(9999)).resolves.toBeUndefined(); |
|||
|
|||
await expect( |
|||
service.update(firstGroup.id, { |
|||
name: 'Internal Staff', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
).resolves.toMatchObject({ |
|||
id: firstGroup.id, |
|||
name: 'Internal Staff', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await expect( |
|||
service.update(9999, { |
|||
name: 'Missing', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
).rejects.toThrow('Client group not found'); |
|||
|
|||
await service.delete(firstGroup.id); |
|||
await expect(service.get(firstGroup.id)).resolves.toBeUndefined(); |
|||
}); |
|||
|
|||
test('assigns, unassigns, counts, and handles missing clients or groups', async () => { |
|||
const existingClient = await seedClient(db); |
|||
const group = await service.create({ |
|||
name: 'Temporary Contractors', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await service.assignClient(existingClient.id, group.id); |
|||
await expect(service.countAssignedClients(group.id)).resolves.toBe(1); |
|||
await expect(service.get(group.id)).resolves.toMatchObject({ |
|||
assignedClientCount: 1, |
|||
}); |
|||
await expect(service.listMembership()).resolves.toContainEqual({ |
|||
clientId: existingClient.id, |
|||
groupId: group.id, |
|||
position: 0, |
|||
}); |
|||
|
|||
await expect(service.assignClient(9999, group.id)).rejects.toThrow( |
|||
'Client not found' |
|||
); |
|||
await expect(service.assignClient(existingClient.id, 9999)).rejects.toThrow( |
|||
'Client group not found' |
|||
); |
|||
|
|||
await service.removeClient(existingClient.id, group.id); |
|||
await expect(service.countAssignedClients(group.id)).resolves.toBe(0); |
|||
await expect(service.listMembership()).resolves.toEqual([]); |
|||
|
|||
await expect(service.removeClient(9999, group.id)).resolves.toBeDefined(); |
|||
}); |
|||
|
|||
test('appends clients to multiple groups and assigning to the same group is idempotent', async () => { |
|||
const existingClient = await seedClient(db); |
|||
const firstGroup = await service.create({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
const secondGroup = await service.create({ |
|||
name: 'Staff', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await service.assignClient(existingClient.id, firstGroup.id); |
|||
await service.assignClient(existingClient.id, firstGroup.id); |
|||
await expect(service.countAssignedClients(firstGroup.id)).resolves.toBe(1); |
|||
|
|||
await service.assignClient(existingClient.id, secondGroup.id); |
|||
await expect(service.countAssignedClients(firstGroup.id)).resolves.toBe(1); |
|||
await expect(service.countAssignedClients(secondGroup.id)).resolves.toBe(1); |
|||
await expect(service.listMembership()).resolves.toEqual([ |
|||
{ clientId: existingClient.id, groupId: firstGroup.id, position: 0 }, |
|||
{ clientId: existingClient.id, groupId: secondGroup.id, position: 1 }, |
|||
]); |
|||
}); |
|||
|
|||
test('lists minimal client membership without exposing client data', async () => { |
|||
const firstClient = await seedClient(db, { name: 'Phone' }); |
|||
const secondClient = await seedClient(db, { |
|||
name: 'Tablet', |
|||
publicKey: 'public-2', |
|||
privateKey: 'private-2', |
|||
preSharedKey: 'psk-2', |
|||
ipv4Address: '10.8.0.3', |
|||
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:3', |
|||
}); |
|||
const group = await service.create({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await service.assignClient(secondClient.id, group.id); |
|||
|
|||
await expect(service.listMembership()).resolves.toEqual([ |
|||
{ clientId: secondClient.id, groupId: group.id, position: 0 }, |
|||
]); |
|||
expect(firstClient.id).toBeGreaterThan(0); |
|||
}); |
|||
|
|||
test('deleting a group removes only memberships and preserves clients', async () => { |
|||
const existingClient = await seedClient(db); |
|||
const group = await service.create({ |
|||
name: 'External Vendor', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await service.assignClient(existingClient.id, group.id); |
|||
await service.delete(group.id); |
|||
|
|||
await expect(db.query.client.findFirst()).resolves.toMatchObject({ |
|||
id: existingClient.id, |
|||
name: 'Phone', |
|||
}); |
|||
await expect(service.listMembership()).resolves.toEqual([]); |
|||
}); |
|||
|
|||
test('preserves null and non-empty group JSON values and rejects empty arrays', async () => { |
|||
const nullGroup = await service.create({ |
|||
name: 'Null Values', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
const configuredGroup = await service.create({ |
|||
name: 'Configured Values', |
|||
description: null, |
|||
allowedIps: ['10.30.0.0/24'], |
|||
dns: ['8.8.8.8'], |
|||
firewallIps: ['10.30.0.10:443/tcp'], |
|||
}); |
|||
|
|||
await expect(service.get(nullGroup.id)).resolves.toMatchObject({ |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
await expect(service.get(configuredGroup.id)).resolves.toMatchObject({ |
|||
allowedIps: ['10.30.0.0/24'], |
|||
dns: ['8.8.8.8'], |
|||
firewallIps: ['10.30.0.10:443/tcp'], |
|||
}); |
|||
|
|||
await expect( |
|||
service.create({ |
|||
name: 'Empty Values', |
|||
description: null, |
|||
allowedIps: [], |
|||
dns: [], |
|||
firewallIps: [], |
|||
}) |
|||
).rejects.toThrow(); |
|||
|
|||
await expect( |
|||
service.update(configuredGroup.id, { |
|||
name: 'Configured Values', |
|||
description: null, |
|||
allowedIps: [], |
|||
dns: [], |
|||
firewallIps: [], |
|||
}) |
|||
).rejects.toThrow(); |
|||
await service.update(configuredGroup.id, { |
|||
name: 'Configured Values', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
await expect(service.get(configuredGroup.id)).resolves.toMatchObject({ |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
}); |
|||
|
|||
test('rejects invalid group values and empty arrays without collapsing null', async () => { |
|||
await expect( |
|||
service.create({ |
|||
name: 'Invalid Allowed IPs', |
|||
description: null, |
|||
allowedIps: ['not-an-ip-or-cidr'], |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
).rejects.toThrow(); |
|||
|
|||
await expect( |
|||
service.create({ |
|||
name: 'Invalid DNS', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: [''], |
|||
firewallIps: null, |
|||
}) |
|||
).rejects.toThrow(); |
|||
|
|||
await expect( |
|||
service.create({ |
|||
name: 'Invalid Firewall', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: ['10.0.0.1/tcp'], |
|||
}) |
|||
).rejects.toThrow(); |
|||
|
|||
expect(() => ClientGroupCreateSchema.parse({ name: ' ' })).toThrow(); |
|||
expect(() => ClientGroupGetSchema.parse({ groupId: 'abc' })).toThrow(); |
|||
expect(() => |
|||
ClientGroupClientParamsSchema.parse({ clientId: 'abc' }) |
|||
).toThrow(); |
|||
expect(() => ClientGroupAssignSchema.parse({ groupId: 'abc' })).toThrow(); |
|||
}); |
|||
|
|||
test('trims names, rejects whitespace-only names, and rejects exact duplicates', async () => { |
|||
await expect( |
|||
service.create({ |
|||
name: ' Customers ', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
).resolves.toMatchObject({ name: 'Customers' }); |
|||
|
|||
await expect( |
|||
service.create({ |
|||
name: ' ', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
).rejects.toThrow(); |
|||
|
|||
await expect( |
|||
service.create({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
).rejects.toThrow(); |
|||
}); |
|||
|
|||
test('detects only client group name unique-constraint errors', async () => { |
|||
await service.create({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
let groupConflictError: unknown; |
|||
try { |
|||
await db |
|||
.insert(schema.clientGroup) |
|||
.values({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}) |
|||
.execute(); |
|||
} catch (error) { |
|||
groupConflictError = error; |
|||
} |
|||
|
|||
expect(isClientGroupNameConflictError(groupConflictError)).toBe(true); |
|||
|
|||
await seedClient(db); |
|||
let unrelatedConflictError: unknown; |
|||
try { |
|||
await seedClient(db, { |
|||
name: 'Second Phone', |
|||
publicKey: 'public-2', |
|||
}); |
|||
} catch (error) { |
|||
unrelatedConflictError = error; |
|||
} |
|||
|
|||
expect(isClientGroupNameConflictError(unrelatedConflictError)).toBe(false); |
|||
}); |
|||
|
|||
test('group details include safe assigned-client summaries only', async () => { |
|||
const existingClient = await seedClient(db, { |
|||
privateKey: 'super-private', |
|||
preSharedKey: 'super-psk', |
|||
}); |
|||
const group = await service.create({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await service.assignClient(existingClient.id, group.id); |
|||
const details = await service.getDetails(group.id); |
|||
|
|||
expect(details).toMatchObject({ |
|||
id: group.id, |
|||
assignedClientCount: 1, |
|||
clients: [ |
|||
{ |
|||
id: existingClient.id, |
|||
name: 'Phone', |
|||
enabled: true, |
|||
ipv4Address: '10.8.0.2', |
|||
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:2', |
|||
}, |
|||
], |
|||
}); |
|||
expect(details?.clients[0]).not.toHaveProperty('privateKey'); |
|||
expect(details?.clients[0]).not.toHaveProperty('preSharedKey'); |
|||
}); |
|||
|
|||
test('administrator permission model allows group APIs and client role does not', () => { |
|||
expect(hasPermissions({ id: 1, role: roles.ADMIN }, 'admin', 'any')).toBe( |
|||
true |
|||
); |
|||
expect(hasPermissions({ id: 2, role: roles.CLIENT }, 'admin', 'any')).toBe( |
|||
false |
|||
); |
|||
}); |
|||
|
|||
test('upgrades existing 0006 clients through migrations 0007 and 0008', async () => { |
|||
await client.close(); |
|||
const testDb = await createTestDb(migrationsThrough0006); |
|||
client = testDb.client; |
|||
await seedExistingClientBefore0007(client); |
|||
|
|||
await applyMigration(client, migration0007); |
|||
await client.execute({ |
|||
sql: `INSERT INTO client_groups_table
|
|||
(id, name, description, allowed_ips, dns, firewall_ips) |
|||
VALUES (?, ?, ?, ?, ?, ?)`,
|
|||
args: [ |
|||
42, |
|||
'Legacy Group', |
|||
null, |
|||
JSON.stringify([]), |
|||
JSON.stringify([]), |
|||
JSON.stringify([]), |
|||
], |
|||
}); |
|||
await client.execute({ |
|||
sql: 'UPDATE clients_table SET group_id = ? WHERE id = ?', |
|||
args: [42, 7], |
|||
}); |
|||
await applyMigration(client, migration0008); |
|||
await client.execute('PRAGMA foreign_keys=ON'); |
|||
db = drizzle({ client, schema }); |
|||
service = new ClientGroupService(db); |
|||
|
|||
const existingClient = await db.query.client.findFirst(); |
|||
expect(existingClient).toMatchObject({ |
|||
id: 7, |
|||
name: 'Migrated Phone', |
|||
ipv4Address: '10.8.0.77', |
|||
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:77', |
|||
preUp: 'pre-up', |
|||
postUp: 'post-up', |
|||
preDown: 'pre-down', |
|||
postDown: 'post-down', |
|||
privateKey: 'private-existing', |
|||
publicKey: 'public-existing', |
|||
preSharedKey: 'psk-existing', |
|||
expiresAt: '2030-01-01T00:00:00.000Z', |
|||
allowedIps: ['10.77.0.0/24'], |
|||
serverAllowedIps: ['192.168.77.0/24'], |
|||
firewallIps: ['10.77.0.10:8443/tcp'], |
|||
persistentKeepalive: 25, |
|||
mtu: 1280, |
|||
jC: 9, |
|||
jMin: 11, |
|||
jMax: 999, |
|||
i1: 'i1-value', |
|||
i2: 'i2-value', |
|||
i3: 'i3-value', |
|||
i4: 'i4-value', |
|||
i5: 'i5-value', |
|||
dns: ['9.9.9.9'], |
|||
serverEndpoint: 'vpn.example.test', |
|||
enabled: false, |
|||
}); |
|||
expect(existingClient).not.toHaveProperty('groupId'); |
|||
|
|||
await expect(service.listMembership()).resolves.toEqual([ |
|||
{ clientId: 7, groupId: 42, position: 0 }, |
|||
]); |
|||
await expect(service.get(42)).resolves.toMatchObject({ |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
const group = await service.create({ |
|||
name: 'Migrated Group', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}); |
|||
|
|||
await service.assignClient(7, group.id); |
|||
await expect(service.listMembership()).resolves.toEqual([ |
|||
{ clientId: 7, groupId: 42, position: 0 }, |
|||
{ clientId: 7, groupId: group.id, position: 1 }, |
|||
]); |
|||
await service.delete(group.id); |
|||
await expect(service.listMembership()).resolves.toEqual([ |
|||
{ clientId: 7, groupId: 42, position: 0 }, |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,522 @@ |
|||
import { readFileSync } from 'node:fs'; |
|||
import { resolve } from 'node:path'; |
|||
|
|||
import { describe, expect, test, vi } from 'vitest'; |
|||
|
|||
import { |
|||
addGroupSelection, |
|||
addPolicyEntry, |
|||
apiErrorMessage, |
|||
clientGroupFormToPayload, |
|||
clientGroupToForm, |
|||
clientIdsForGroup, |
|||
createClientGroupForm, |
|||
createPolicyDraftState, |
|||
effectivePolicyMessageKey, |
|||
getMembershipChange, |
|||
groupIdsForClient, |
|||
groupManagedPolicyValue, |
|||
groupManagesClientPolicy, |
|||
groupPolicySummary, |
|||
groupSelectionFromMembership, |
|||
invalidDefinedPolicyKey, |
|||
removeGroupSelection, |
|||
removePolicyEntry, |
|||
saveClientGroupMembership, |
|||
selectedClientGroups, |
|||
setPolicyDraftDefined, |
|||
setPolicyDefined, |
|||
visibleGroupPolicyKeys, |
|||
} from '../../app/utils/clientGroups'; |
|||
import { resolveClientEffectivePolicy } from '../../shared/utils/clientPolicy'; |
|||
|
|||
describe('client group frontend helpers', () => { |
|||
test('serializes create form with trimmed name and complete payload', () => { |
|||
const form = createClientGroupForm(); |
|||
form.name = ' Customers '; |
|||
form.description = ' External customers '; |
|||
form.allowedIps = null; |
|||
form.dns = [' 1.1.1.1 ']; |
|||
form.firewallIps = [' 10.0.0.0/24 ']; |
|||
|
|||
expect(clientGroupFormToPayload(form)).toEqual({ |
|||
name: 'Customers', |
|||
description: 'External customers', |
|||
allowedIps: null, |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: ['10.0.0.0/24'], |
|||
}); |
|||
}); |
|||
|
|||
test('preserves null and non-empty arrays in edit forms', () => { |
|||
const form = clientGroupToForm({ |
|||
name: 'Staff', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: ['192.0.2.0/24'], |
|||
}); |
|||
|
|||
expect(clientGroupFormToPayload(form)).toEqual({ |
|||
name: 'Staff', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: ['192.0.2.0/24'], |
|||
}); |
|||
}); |
|||
|
|||
test('policy field actions keep an enabled field with at least one input row', () => { |
|||
expect(setPolicyDefined(null, true)).toEqual(['']); |
|||
expect(addPolicyEntry(['10.0.0.0/24'])).toEqual(['10.0.0.0/24', '']); |
|||
expect(removePolicyEntry(['10.0.0.0/24'], 0)).toEqual(['']); |
|||
expect(setPolicyDefined(['10.0.0.0/24'], false)).toBeNull(); |
|||
}); |
|||
|
|||
test('policy drafts survive switch off and back on during one edit session', () => { |
|||
let state = createPolicyDraftState(['10.0.0.0/24']); |
|||
|
|||
state = setPolicyDraftDefined(state, false); |
|||
expect(state.value).toBeNull(); |
|||
expect(state.draft).toEqual(['10.0.0.0/24']); |
|||
|
|||
state = setPolicyDraftDefined(state, true); |
|||
expect(state.value).toEqual(['10.0.0.0/24']); |
|||
|
|||
expect(setPolicyDraftDefined(createPolicyDraftState(null), true)).toEqual({ |
|||
value: [''], |
|||
draft: [''], |
|||
}); |
|||
}); |
|||
|
|||
test('detects invalid enabled policy rows before submit', () => { |
|||
expect( |
|||
invalidDefinedPolicyKey({ |
|||
allowedIps: null, |
|||
dns: [''], |
|||
firewallIps: null, |
|||
}) |
|||
).toBe('dns'); |
|||
expect( |
|||
invalidDefinedPolicyKey({ |
|||
allowedIps: ['10.0.0.0/24'], |
|||
dns: null, |
|||
firewallIps: ['10.0.0.1'], |
|||
}) |
|||
).toBeNull(); |
|||
}); |
|||
|
|||
test('summarizes group list policy counts', () => { |
|||
expect( |
|||
groupPolicySummary({ |
|||
allowedIps: null, |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: ['10.0.0.1', '10.0.0.2'], |
|||
}) |
|||
).toEqual({ |
|||
allowedIps: null, |
|||
dns: 1, |
|||
firewallIps: 2, |
|||
}); |
|||
}); |
|||
|
|||
test('resolves ordered multi-group membership helpers', () => { |
|||
const membership = [ |
|||
{ clientId: 1, groupId: 20, position: 1 }, |
|||
{ clientId: 1, groupId: 10, position: 0 }, |
|||
{ clientId: 2, groupId: 10, position: 0 }, |
|||
]; |
|||
|
|||
expect(groupIdsForClient(membership, 1)).toEqual([10, 20]); |
|||
expect(clientIdsForGroup(membership, 10)).toEqual([1, 2]); |
|||
expect(groupSelectionFromMembership(membership, 1)).toEqual(['10', '20']); |
|||
}); |
|||
|
|||
test('selects multiple groups and unions managed policy values', () => { |
|||
const groups = [ |
|||
{ |
|||
id: 1, |
|||
name: 'Staff', |
|||
allowedIps: ['10.0.0.0/24'], |
|||
dns: null, |
|||
firewallIps: ['10.0.0.10:443/tcp'], |
|||
}, |
|||
{ |
|||
id: 2, |
|||
name: 'NAS', |
|||
allowedIps: ['10.0.0.0/24', '192.0.2.0/24'], |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: null, |
|||
}, |
|||
]; |
|||
const selectedGroups = selectedClientGroups(groups, ['1', '2']); |
|||
|
|||
expect(selectedGroups.map((group) => group.name)).toEqual(['Staff', 'NAS']); |
|||
expect(groupManagesClientPolicy(selectedGroups, 'allowedIps')).toBe(true); |
|||
expect(groupManagesClientPolicy(selectedGroups, 'dns')).toBe(true); |
|||
expect(groupManagedPolicyValue(selectedGroups, 'allowedIps')).toEqual([ |
|||
'10.0.0.0/24', |
|||
'192.0.2.0/24', |
|||
]); |
|||
expect(groupManagesClientPolicy(selectedGroups, 'firewallIps', false)).toBe( |
|||
false |
|||
); |
|||
}); |
|||
|
|||
test('detects ordered membership changes and saves complete replacement only when changed', async () => { |
|||
expect(getMembershipChange(['1', '2'], ['1', '2'])).toEqual({ |
|||
changed: false, |
|||
groupIds: [1, 2], |
|||
}); |
|||
expect(getMembershipChange(['1', '2'], ['2', '1'])).toEqual({ |
|||
changed: true, |
|||
groupIds: [2, 1], |
|||
}); |
|||
|
|||
const setGroups = vi.fn().mockResolvedValue(undefined); |
|||
await expect( |
|||
saveClientGroupMembership(7, ['1'], ['1', '2'], { setGroups }) |
|||
).resolves.toBe(true); |
|||
expect(setGroups).toHaveBeenCalledWith(7, [1, 2]); |
|||
|
|||
await expect( |
|||
saveClientGroupMembership(7, ['1', '2'], ['1', '2'], { setGroups }) |
|||
).resolves.toBe(false); |
|||
}); |
|||
|
|||
test('adds and removes selected group chips without reordering existing choices', () => { |
|||
expect(addGroupSelection(['1'], '2')).toEqual(['1', '2']); |
|||
expect(addGroupSelection(['1'], '1')).toEqual(['1']); |
|||
expect(removeGroupSelection(['1', '2', '3'], '2')).toEqual(['1', '3']); |
|||
}); |
|||
|
|||
test('maps effective policy source messages', () => { |
|||
expect( |
|||
effectivePolicyMessageKey({ |
|||
source: 'groups', |
|||
value: ['10.0.0.0/24'], |
|||
groups: [{ id: 1, name: 'Staff' }], |
|||
}) |
|||
).toBe('clientGroup.managedByOneGroupLinked'); |
|||
expect( |
|||
effectivePolicyMessageKey({ |
|||
source: 'groups', |
|||
value: ['10.0.0.0/24'], |
|||
groups: [ |
|||
{ id: 1, name: 'Staff' }, |
|||
{ id: 2, name: 'NAS' }, |
|||
], |
|||
}) |
|||
).toBe('clientGroup.managedByManyGroupsLinked'); |
|||
expect( |
|||
effectivePolicyMessageKey({ source: 'global', value: [], groups: [] }) |
|||
).toBe('clientGroup.inheritedFromGlobal'); |
|||
}); |
|||
|
|||
test('draft client values preview immediately while ungrouped', () => { |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client: { |
|||
allowedIps: [' 10.0.0.0/24 ', ' '], |
|||
dns: ['9.9.9.9'], |
|||
firewallIps: null, |
|||
}, |
|||
groups: [], |
|||
userConfig: { |
|||
defaultAllowedIps: ['0.0.0.0/0'], |
|||
defaultDns: ['1.1.1.1'], |
|||
}, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.fields.allowedIps).toMatchObject({ |
|||
source: 'client', |
|||
value: ['10.0.0.0/24'], |
|||
}); |
|||
expect(policy.fields.firewallIps).toMatchObject({ |
|||
source: 'global', |
|||
value: ['10.0.0.0/24'], |
|||
}); |
|||
}); |
|||
|
|||
test('removing the last draft client value previews global fallback', () => { |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client: { |
|||
allowedIps: [' '], |
|||
dns: null, |
|||
firewallIps: null, |
|||
}, |
|||
groups: [], |
|||
userConfig: { |
|||
defaultAllowedIps: ['0.0.0.0/0'], |
|||
defaultDns: ['1.1.1.1'], |
|||
}, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.fields.allowedIps).toMatchObject({ |
|||
source: 'global', |
|||
value: ['0.0.0.0/0'], |
|||
}); |
|||
}); |
|||
|
|||
test('draft groups immediately preview ordered union and deduplication', () => { |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client: { |
|||
allowedIps: ['10.99.0.0/24'], |
|||
dns: ['9.9.9.9'], |
|||
firewallIps: ['10.99.0.10:443/tcp'], |
|||
}, |
|||
groups: [ |
|||
{ |
|||
id: 1, |
|||
name: 'Customers', |
|||
allowedIps: ['10.10.0.0/24', '10.20.0.0/24'], |
|||
dns: ['1.1.1.1'], |
|||
firewallIps: null, |
|||
}, |
|||
{ |
|||
id: 2, |
|||
name: 'NAS', |
|||
allowedIps: ['10.20.0.0/24', '10.30.0.0/24'], |
|||
dns: null, |
|||
firewallIps: null, |
|||
}, |
|||
], |
|||
userConfig: { |
|||
defaultAllowedIps: ['0.0.0.0/0'], |
|||
defaultDns: ['8.8.8.8'], |
|||
}, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.fields.allowedIps).toMatchObject({ |
|||
source: 'groups', |
|||
value: ['10.10.0.0/24', '10.20.0.0/24', '10.30.0.0/24'], |
|||
groups: [ |
|||
{ id: 1, name: 'Customers' }, |
|||
{ id: 2, name: 'NAS' }, |
|||
], |
|||
}); |
|||
expect(policy.fields.dns).toMatchObject({ |
|||
source: 'groups', |
|||
value: ['1.1.1.1'], |
|||
groups: [{ id: 1, name: 'Customers' }], |
|||
}); |
|||
}); |
|||
|
|||
test('draft groups with no contributor preview global fallback', () => { |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client: { |
|||
allowedIps: ['10.99.0.0/24'], |
|||
dns: ['9.9.9.9'], |
|||
firewallIps: null, |
|||
}, |
|||
groups: [ |
|||
{ |
|||
id: 1, |
|||
name: 'No Policy', |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}, |
|||
], |
|||
userConfig: { |
|||
defaultAllowedIps: ['0.0.0.0/0'], |
|||
defaultDns: ['8.8.8.8'], |
|||
}, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.fields.allowedIps).toMatchObject({ |
|||
source: 'global', |
|||
value: ['0.0.0.0/0'], |
|||
groups: [], |
|||
}); |
|||
}); |
|||
|
|||
test('hides Firewall IP policy controls without dropping form values', () => { |
|||
const form = clientGroupToForm({ |
|||
name: 'Staff', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: ['10.0.0.1:443/tcp'], |
|||
}); |
|||
|
|||
expect(visibleGroupPolicyKeys(false)).toEqual(['allowedIps', 'dns']); |
|||
expect(clientGroupFormToPayload(form).firewallIps).toEqual([ |
|||
'10.0.0.1:443/tcp', |
|||
]); |
|||
}); |
|||
|
|||
test('returns clean duplicate-name error display text', () => { |
|||
expect( |
|||
apiErrorMessage( |
|||
{ data: { message: 'Client group already exists' } }, |
|||
'Unknown error' |
|||
) |
|||
).toBe('Client group already exists'); |
|||
}); |
|||
|
|||
test('client edit page uses multi-group selection and main save flow', () => { |
|||
const clientPage = readFileSync( |
|||
resolve('app/pages/clients/[id].vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(clientPage).toContain('selectedGroupIds'); |
|||
expect(clientPage).toContain('ClientGroupsSelector'); |
|||
expect(clientPage).toContain('draftEffectivePolicy'); |
|||
expect(clientPage).toContain('resolveClientEffectivePolicy'); |
|||
expect(clientPage).toContain('groupsStore.setClientGroups'); |
|||
expect(clientPage).toContain('ClientGroupsManagedPolicyValue'); |
|||
expect(clientPage).toContain('effectivePolicyMessage'); |
|||
expect(clientPage).not.toContain('getClientEffectivePolicy'); |
|||
expect(clientPage).not.toContain('clientGroup.saveMembership'); |
|||
expect(clientPage).not.toContain('@click="saveClientGroup"'); |
|||
}); |
|||
|
|||
test('client create dialog accepts ordered group selection', () => { |
|||
const createDialog = readFileSync( |
|||
resolve('app/components/Clients/CreateDialog.vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(createDialog).toContain('selectedGroupIds'); |
|||
expect(createDialog).toContain('groupIds: groupIdsFromSelection'); |
|||
expect(createDialog).toContain('ClientGroupsSelector'); |
|||
}); |
|||
|
|||
test('client create and edit use the same group selector component', () => { |
|||
const createDialog = readFileSync( |
|||
resolve('app/components/Clients/CreateDialog.vue'), |
|||
'utf8' |
|||
); |
|||
const clientPage = readFileSync( |
|||
resolve('app/pages/clients/[id].vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(createDialog).toContain('<ClientGroupsSelector'); |
|||
expect(clientPage).toContain('<ClientGroupsSelector'); |
|||
}); |
|||
|
|||
test('group selector renders ordered rows with unlink controls and prevents duplicates', () => { |
|||
const selector = readFileSync( |
|||
resolve('app/components/ClientGroups/Selector.vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(selector).toContain('v-for="group in selectedGroups"'); |
|||
expect(selector).toContain('readonly'); |
|||
expect(selector).toContain('<IconsUnlink'); |
|||
expect(selector).toContain('availableGroups'); |
|||
expect(selector).toContain('!selectedGroupIds.value.includes'); |
|||
expect(selector).toContain("{{ $t('clientGroup.noGroupsSelected') }}"); |
|||
expect(selector).toContain('sm:grid-cols-[minmax(0,1fr)_auto]'); |
|||
}); |
|||
|
|||
test('client group selector avoids duplicate Client Groups labels', () => { |
|||
const clientPage = readFileSync( |
|||
resolve('app/pages/clients/[id].vue'), |
|||
'utf8' |
|||
); |
|||
const createDialog = readFileSync( |
|||
resolve('app/components/Clients/CreateDialog.vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(clientPage).not.toContain("{{ $t('clientGroup.clientSelector') }}"); |
|||
expect(createDialog).not.toContain( |
|||
"{{ $t('clientGroup.clientSelector') }}" |
|||
); |
|||
}); |
|||
|
|||
test('managed group display links every contributing group and supports global fallback text', () => { |
|||
const managedPolicyComponent = readFileSync( |
|||
resolve('app/components/ClientGroups/ManagedPolicyValue.vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(managedPolicyComponent).toContain(':keypath="messageKey"'); |
|||
expect(managedPolicyComponent).toContain('<template #groups>'); |
|||
expect(managedPolicyComponent).toContain(':to="`/groups/${group.id}`"'); |
|||
expect(managedPolicyComponent).toContain("{{ $t('form.noItems') }}"); |
|||
}); |
|||
|
|||
test('group policy form has no explicit empty persisted state', () => { |
|||
const policyComponent = readFileSync( |
|||
resolve('app/components/ClientGroups/PolicyField.vue'), |
|||
'utf8' |
|||
); |
|||
const formComponent = readFileSync( |
|||
resolve('app/components/ClientGroups/Form.vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(policyComponent).toContain('mt-1 flex min-w-0 flex-row gap-1'); |
|||
expect(policyComponent).toContain('<IconsDelete'); |
|||
expect(policyComponent).not.toContain('data.length === 0'); |
|||
expect(policyComponent).not.toContain('emptyText'); |
|||
expect(formComponent).not.toContain('definedEmpty'); |
|||
}); |
|||
|
|||
test('group edit membership uses additive assign and unlink icon remove', () => { |
|||
const editPage = readFileSync(resolve('app/pages/groups/[id].vue'), 'utf8'); |
|||
|
|||
expect(editPage).toContain('groupIdsForClient'); |
|||
expect(editPage).toContain('groupsStore.assignClient'); |
|||
expect(editPage).toContain('groupsStore.removeClient'); |
|||
expect(editPage).toContain('IconsUnlink'); |
|||
expect(editPage).not.toContain('moveClientConfirm'); |
|||
expect(editPage).not.toContain('IconsLink'); |
|||
}); |
|||
|
|||
test('base dialog exposes optional trigger accessibility attributes', () => { |
|||
const dialogComponent = readFileSync( |
|||
resolve('app/components/Base/Dialog.vue'), |
|||
'utf8' |
|||
); |
|||
const confirmDialogComponent = readFileSync( |
|||
resolve('app/components/ClientGroups/ConfirmMembershipDialog.vue'), |
|||
'utf8' |
|||
); |
|||
|
|||
expect(dialogComponent).toContain(':aria-label="triggerAriaLabel"'); |
|||
expect(dialogComponent).toContain(':title="triggerTitle"'); |
|||
expect(confirmDialogComponent).toContain( |
|||
':trigger-aria-label="triggerAriaLabel"' |
|||
); |
|||
}); |
|||
|
|||
test('group create redirects to list while group edit save stays put', () => { |
|||
const createPage = readFileSync( |
|||
resolve('app/pages/groups/new.vue'), |
|||
'utf8' |
|||
); |
|||
const editPage = readFileSync(resolve('app/pages/groups/[id].vue'), 'utf8'); |
|||
|
|||
expect(createPage).toContain("await navigateTo('/groups')"); |
|||
expect(createPage).toContain('catch (error)'); |
|||
expect(editPage).toContain( |
|||
'await groupsStore.updateGroup(groupId, payload)' |
|||
); |
|||
expect(editPage).toContain('async function deleteGroup()'); |
|||
}); |
|||
|
|||
test('user menu order is clients, groups, account, admin panel', () => { |
|||
const userMenu = readFileSync( |
|||
resolve('app/components/Ui/UserMenu.vue'), |
|||
'utf8' |
|||
); |
|||
const clientsIndex = userMenu.indexOf("{{ $t('pages.clients') }}"); |
|||
const groupsIndex = userMenu.indexOf("{{ $t('pages.groups') }}"); |
|||
const accountIndex = userMenu.indexOf("{{ $t('pages.me') }}"); |
|||
const adminIndex = userMenu.indexOf("{{ $t('pages.admin.panel') }}"); |
|||
|
|||
expect(clientsIndex).toBeGreaterThan(-1); |
|||
expect(groupsIndex).toBeGreaterThan(clientsIndex); |
|||
expect(accountIndex).toBeGreaterThan(groupsIndex); |
|||
expect(adminIndex).toBeGreaterThan(accountIndex); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,234 @@ |
|||
import { describe, expect, test, vi } from 'vitest'; |
|||
|
|||
import { resolveClientEffectivePolicy } from '#shared/utils/clientPolicy'; |
|||
import { firewallTestExports } from '#server/utils/firewall'; |
|||
import { wg } from '#server/utils/wgHelper'; |
|||
import type { ClientType } from '#db/repositories/client/types'; |
|||
import type { InterfaceType } from '#db/repositories/interface/types'; |
|||
import type { UserConfigType } from '#db/repositories/userConfig/types'; |
|||
|
|||
vi.mock('#server/utils/config', () => ({ |
|||
WG_ENV: { |
|||
WG_EXECUTABLE: 'wg', |
|||
DISABLE_IPV6: false, |
|||
}, |
|||
})); |
|||
|
|||
const userConfig = { |
|||
defaultAllowedIps: ['0.0.0.0/0'], |
|||
defaultDns: ['1.1.1.1'], |
|||
host: 'vpn.example.test', |
|||
port: 51820, |
|||
} as UserConfigType; |
|||
|
|||
const wgInterface = { |
|||
publicKey: 'server-public-key', |
|||
firewallEnabled: true, |
|||
} as InterfaceType; |
|||
|
|||
function createClient(overrides: Partial<ClientType> = {}) { |
|||
return { |
|||
id: 1, |
|||
name: 'Phone', |
|||
ipv4Address: '10.8.0.2', |
|||
ipv6Address: 'fd00::2', |
|||
privateKey: 'client-private-key', |
|||
publicKey: 'client-public-key', |
|||
preSharedKey: 'client-preshared-key', |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
serverAllowedIps: ['192.168.50.0/24'], |
|||
preUp: '', |
|||
postUp: '', |
|||
preDown: '', |
|||
postDown: '', |
|||
mtu: 1420, |
|||
jC: null, |
|||
jMin: null, |
|||
jMax: null, |
|||
i1: null, |
|||
i2: null, |
|||
i3: null, |
|||
i4: null, |
|||
i5: null, |
|||
persistentKeepalive: 25, |
|||
serverEndpoint: null, |
|||
enabled: true, |
|||
userId: 1, |
|||
interfaceId: 'wg0', |
|||
expiresAt: null, |
|||
createdAt: '2026-01-01 00:00:00', |
|||
updatedAt: '2026-01-01 00:00:00', |
|||
...overrides, |
|||
} as ClientType; |
|||
} |
|||
|
|||
describe('client group effective policy', () => { |
|||
test('ungrouped clients preserve client and global fallback behavior', () => { |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client: createClient({ |
|||
allowedIps: ['10.10.0.0/24'], |
|||
dns: null, |
|||
firewallIps: null, |
|||
}), |
|||
groups: [], |
|||
userConfig, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.allowedIps).toEqual(['10.10.0.0/24']); |
|||
expect(policy.dns).toEqual(['1.1.1.1']); |
|||
expect(policy.firewallIps).toEqual(['10.10.0.0/24']); |
|||
expect(policy.fields.allowedIps.source).toBe('client'); |
|||
expect(policy.fields.dns.source).toBe('global'); |
|||
}); |
|||
|
|||
test('combines multiple contributing groups in membership and entry order', () => { |
|||
const client = createClient({ |
|||
allowedIps: ['10.10.0.0/24'], |
|||
dns: ['9.9.9.9'], |
|||
firewallIps: ['10.10.0.10:443/tcp'], |
|||
}); |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client, |
|||
groups: [ |
|||
{ |
|||
id: 1, |
|||
name: 'Customers', |
|||
allowedIps: ['10.20.0.0/24', '172.16.0.10/32'], |
|||
dns: ['8.8.8.8'], |
|||
firewallIps: ['10.20.0.10:443/tcp'], |
|||
}, |
|||
{ |
|||
id: 2, |
|||
name: 'NAS', |
|||
allowedIps: ['172.16.0.10/32', '192.0.2.10/32'], |
|||
dns: ['1.0.0.1', '8.8.8.8'], |
|||
firewallIps: ['10.20.0.10:443/tcp', '10.30.0.10:443/tcp'], |
|||
}, |
|||
], |
|||
userConfig, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.allowedIps).toEqual([ |
|||
'10.20.0.0/24', |
|||
'172.16.0.10/32', |
|||
'192.0.2.10/32', |
|||
]); |
|||
expect(policy.dns).toEqual(['8.8.8.8', '1.0.0.1']); |
|||
expect(policy.firewallIps).toEqual([ |
|||
'10.20.0.10:443/tcp', |
|||
'10.30.0.10:443/tcp', |
|||
]); |
|||
expect(policy.fields.allowedIps.groups).toEqual([ |
|||
{ id: 1, name: 'Customers' }, |
|||
{ id: 2, name: 'NAS' }, |
|||
]); |
|||
}); |
|||
|
|||
test('grouped clients ignore individual values and fall back to global when no group contributes', () => { |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client: createClient({ |
|||
allowedIps: ['10.10.0.0/24'], |
|||
dns: ['9.9.9.9'], |
|||
firewallIps: ['10.10.0.10:443/tcp'], |
|||
}), |
|||
groups: [ |
|||
{ |
|||
id: 1, |
|||
name: 'Empty Legacy', |
|||
allowedIps: [], |
|||
dns: [], |
|||
firewallIps: [], |
|||
}, |
|||
{ |
|||
id: 2, |
|||
name: 'No Policy', |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}, |
|||
], |
|||
userConfig, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
expect(policy.allowedIps).toEqual(['0.0.0.0/0']); |
|||
expect(policy.dns).toEqual(['1.1.1.1']); |
|||
expect(policy.firewallIps).toEqual(['0.0.0.0/0']); |
|||
expect(policy.fields.allowedIps.source).toBe('global'); |
|||
expect(policy.groupManagedAllowedIps).toBe(false); |
|||
}); |
|||
|
|||
test('generated config uses group union without changing server peer routes', () => { |
|||
const client = createClient({ |
|||
allowedIps: ['10.10.0.0/24'], |
|||
dns: ['9.9.9.9'], |
|||
}); |
|||
const policy = resolveClientEffectivePolicy({ |
|||
client, |
|||
groups: [ |
|||
{ |
|||
id: 1, |
|||
name: 'Customers', |
|||
allowedIps: ['10.20.0.0/24'], |
|||
dns: ['8.8.8.8'], |
|||
firewallIps: null, |
|||
}, |
|||
], |
|||
userConfig, |
|||
firewallEnabled: true, |
|||
}); |
|||
|
|||
const config = wg.generateClientConfig(wgInterface, userConfig, client, { |
|||
effectivePolicy: policy, |
|||
}); |
|||
const serverPeer = wg.generateServerPeer(client); |
|||
|
|||
expect(config).toContain('AllowedIPs = 10.20.0.0/24'); |
|||
expect(config).toContain('DNS = 8.8.8.8'); |
|||
expect(serverPeer).toContain( |
|||
'AllowedIPs = 10.8.0.2/32, fd00::2/128, 192.168.50.0/24' |
|||
); |
|||
expect(serverPeer).not.toContain('10.20.0.0/24'); |
|||
}); |
|||
|
|||
test('firewall runtime uses group unions while enabled and ignores groups while disabled', () => { |
|||
const client = createClient({ |
|||
allowedIps: ['10.10.0.0/24'], |
|||
firewallIps: ['10.10.0.10:443/tcp'], |
|||
}); |
|||
const groups = [ |
|||
{ |
|||
id: 1, |
|||
name: 'Customers', |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: ['10.20.0.10:443/tcp'], |
|||
}, |
|||
{ |
|||
id: 2, |
|||
name: 'NAS', |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: ['10.20.0.10:443/tcp', '10.30.0.10:443/tcp'], |
|||
}, |
|||
]; |
|||
|
|||
expect( |
|||
firewallTestExports.resolveFirewallClientIps(client, userConfig, groups) |
|||
).toEqual(['10.20.0.10:443/tcp', '10.30.0.10:443/tcp']); |
|||
|
|||
const disabledPolicy = resolveClientEffectivePolicy({ |
|||
client, |
|||
groups, |
|||
userConfig, |
|||
firewallEnabled: false, |
|||
}); |
|||
|
|||
expect(disabledPolicy.firewallIps).toEqual([]); |
|||
expect(disabledPolicy.groupManagedFirewallIps).toBe(false); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,295 @@ |
|||
import { createEvent } from 'h3'; |
|||
import { beforeEach, describe, expect, test, vi } from 'vitest'; |
|||
|
|||
import { roles } from '../../shared/utils/permissions'; |
|||
|
|||
const mockDatabase = { |
|||
clientGroups: { |
|||
assignClient: vi.fn(), |
|||
create: vi.fn(), |
|||
getClientGroups: vi.fn(), |
|||
getDetails: vi.fn(), |
|||
listMembership: vi.fn(), |
|||
list: vi.fn(), |
|||
removeClient: vi.fn(), |
|||
setClientGroups: vi.fn(), |
|||
}, |
|||
clients: { |
|||
get: vi.fn(), |
|||
}, |
|||
}; |
|||
|
|||
const mockGetCurrentUser = vi.fn(); |
|||
|
|||
vi.mock('#server/utils/Database', () => ({ |
|||
default: mockDatabase, |
|||
})); |
|||
|
|||
vi.mock('#server/utils/session', () => ({ |
|||
getCurrentUser: mockGetCurrentUser, |
|||
})); |
|||
|
|||
vi.mock('#server/utils/WireGuard', () => ({ |
|||
default: { |
|||
saveConfig: vi.fn(), |
|||
}, |
|||
})); |
|||
|
|||
function createH3Event( |
|||
url: string, |
|||
options: RequestInit & { params?: Record<string, string> } = {} |
|||
) { |
|||
const event = createEvent(new Request(url, options)); |
|||
event.context.params = options.params; |
|||
|
|||
return event; |
|||
} |
|||
|
|||
describe('client group route handlers', () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
mockGetCurrentUser.mockResolvedValue({ |
|||
id: 1, |
|||
username: 'admin', |
|||
password: 'hash', |
|||
email: null, |
|||
name: 'Administrator', |
|||
role: roles.ADMIN, |
|||
totpKey: null, |
|||
totpVerified: false, |
|||
enabled: true, |
|||
oauthProvider: null, |
|||
oauthId: null, |
|||
createdAt: '', |
|||
updatedAt: '', |
|||
}); |
|||
}); |
|||
|
|||
test('rejects non-admin access before reaching the list handler', async () => { |
|||
mockGetCurrentUser.mockResolvedValueOnce({ |
|||
id: 2, |
|||
username: 'client', |
|||
password: 'hash', |
|||
email: null, |
|||
name: 'Client', |
|||
role: roles.CLIENT, |
|||
totpKey: null, |
|||
totpVerified: false, |
|||
enabled: true, |
|||
oauthProvider: null, |
|||
oauthId: null, |
|||
createdAt: '', |
|||
updatedAt: '', |
|||
}); |
|||
|
|||
const handler = (await import('../../server/api/client-group/index.get')) |
|||
.default; |
|||
|
|||
await expect( |
|||
handler(createH3Event('http://wg.test/api/client-group')) |
|||
).rejects.toMatchObject({ statusCode: 403 }); |
|||
expect(mockDatabase.clientGroups.list).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
test('allows admin access to reach the list handler', async () => { |
|||
mockDatabase.clientGroups.list.mockResolvedValueOnce([]); |
|||
const handler = (await import('../../server/api/client-group/index.get')) |
|||
.default; |
|||
|
|||
await expect( |
|||
handler(createH3Event('http://wg.test/api/client-group')) |
|||
).resolves.toEqual([]); |
|||
expect(mockDatabase.clientGroups.list).toHaveBeenCalledOnce(); |
|||
}); |
|||
|
|||
test('allows admin access to read minimal membership only', async () => { |
|||
mockDatabase.clientGroups.listMembership.mockResolvedValueOnce([ |
|||
{ clientId: 2, groupId: 5, position: 0 }, |
|||
{ clientId: 2, groupId: 7, position: 1 }, |
|||
]); |
|||
const handler = ( |
|||
await import('../../server/api/client-group/membership.get') |
|||
).default; |
|||
|
|||
await expect( |
|||
handler(createH3Event('http://wg.test/api/client-group/membership')) |
|||
).resolves.toEqual([ |
|||
{ clientId: 2, groupId: 5, position: 0 }, |
|||
{ clientId: 2, groupId: 7, position: 1 }, |
|||
]); |
|||
expect(mockDatabase.clientGroups.listMembership).toHaveBeenCalledOnce(); |
|||
}); |
|||
|
|||
test('client groups route returns ordered groups for one client', async () => { |
|||
mockDatabase.clients.get.mockResolvedValueOnce({ id: 7 }); |
|||
mockDatabase.clientGroups.getClientGroups.mockResolvedValueOnce([ |
|||
{ clientId: 7, groupId: 1, groupName: 'Customers', position: 0 }, |
|||
{ clientId: 7, groupId: 2, groupName: 'NAS', position: 1 }, |
|||
]); |
|||
const handler = ( |
|||
await import('../../server/api/client/[clientId]/groups/index.get') |
|||
).default; |
|||
|
|||
await expect( |
|||
handler( |
|||
createH3Event('http://wg.test/api/client/7/groups', { |
|||
params: { clientId: '7' }, |
|||
}) |
|||
) |
|||
).resolves.toEqual([ |
|||
{ clientId: 7, groupId: 1, groupName: 'Customers', position: 0 }, |
|||
{ clientId: 7, groupId: 2, groupName: 'NAS', position: 1 }, |
|||
]); |
|||
}); |
|||
|
|||
test('client groups route atomically replaces ordered group ids', async () => { |
|||
mockDatabase.clients.get.mockResolvedValueOnce({ id: 7 }); |
|||
mockDatabase.clientGroups.setClientGroups.mockResolvedValueOnce(undefined); |
|||
const handler = ( |
|||
await import('../../server/api/client/[clientId]/groups/index.put') |
|||
).default; |
|||
|
|||
await expect( |
|||
handler( |
|||
createH3Event('http://wg.test/api/client/7/groups', { |
|||
method: 'PUT', |
|||
params: { clientId: '7' }, |
|||
headers: { 'content-type': 'application/json' }, |
|||
body: JSON.stringify({ groupIds: [2, 1] }), |
|||
}) |
|||
) |
|||
).resolves.toEqual({ success: true }); |
|||
expect(mockDatabase.clientGroups.setClientGroups).toHaveBeenCalledWith( |
|||
7, |
|||
[2, 1] |
|||
); |
|||
}); |
|||
|
|||
test('client group member routes add and remove one membership only', async () => { |
|||
mockDatabase.clients.get.mockResolvedValue({ id: 7 }); |
|||
mockDatabase.clientGroups.assignClient.mockResolvedValueOnce(undefined); |
|||
mockDatabase.clientGroups.removeClient.mockResolvedValueOnce(undefined); |
|||
const addHandler = ( |
|||
await import('../../server/api/client/[clientId]/groups/[groupId]/index.post') |
|||
).default; |
|||
const removeHandler = ( |
|||
await import('../../server/api/client/[clientId]/groups/[groupId]/index.delete') |
|||
).default; |
|||
|
|||
await expect( |
|||
addHandler( |
|||
createH3Event('http://wg.test/api/client/7/groups/2', { |
|||
method: 'POST', |
|||
params: { clientId: '7', groupId: '2' }, |
|||
}) |
|||
) |
|||
).resolves.toEqual({ success: true }); |
|||
await expect( |
|||
removeHandler( |
|||
createH3Event('http://wg.test/api/client/7/groups/2', { |
|||
method: 'DELETE', |
|||
params: { clientId: '7', groupId: '2' }, |
|||
}) |
|||
) |
|||
).resolves.toEqual({ success: true }); |
|||
expect(mockDatabase.clientGroups.assignClient).toHaveBeenCalledWith(7, 2); |
|||
expect(mockDatabase.clientGroups.removeClient).toHaveBeenCalledWith(7, 2); |
|||
}); |
|||
|
|||
test('malformed group id rejects before lookup', async () => { |
|||
const handler = ( |
|||
await import('../../server/api/client-group/[groupId]/index.get') |
|||
).default; |
|||
|
|||
await expect( |
|||
handler( |
|||
createH3Event('http://wg.test/api/client-group/not-a-number', { |
|||
params: { groupId: 'not-a-number' }, |
|||
}) |
|||
) |
|||
).rejects.toThrow(); |
|||
expect(mockDatabase.clientGroups.getDetails).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
test('missing group returns 404', async () => { |
|||
mockDatabase.clientGroups.getDetails.mockResolvedValueOnce(undefined); |
|||
const handler = ( |
|||
await import('../../server/api/client-group/[groupId]/index.get') |
|||
).default; |
|||
|
|||
await expect( |
|||
handler( |
|||
createH3Event('http://wg.test/api/client-group/123', { |
|||
params: { groupId: '123' }, |
|||
}) |
|||
) |
|||
).rejects.toMatchObject({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client group not found', |
|||
}); |
|||
}); |
|||
|
|||
test('duplicate group create returns 409', async () => { |
|||
mockDatabase.clientGroups.create.mockRejectedValueOnce( |
|||
new Error('Client group already exists') |
|||
); |
|||
const handler = (await import('../../server/api/client-group/index.post')) |
|||
.default; |
|||
|
|||
await expect( |
|||
handler( |
|||
createH3Event('http://wg.test/api/client-group', { |
|||
method: 'POST', |
|||
headers: { 'content-type': 'application/json' }, |
|||
body: JSON.stringify({ |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
}), |
|||
}) |
|||
) |
|||
).rejects.toMatchObject({ |
|||
statusCode: 409, |
|||
statusMessage: 'Client group already exists', |
|||
}); |
|||
}); |
|||
|
|||
test('successful detail response does not expose client secrets', async () => { |
|||
mockDatabase.clientGroups.getDetails.mockResolvedValueOnce({ |
|||
id: 1, |
|||
name: 'Customers', |
|||
description: null, |
|||
allowedIps: null, |
|||
dns: null, |
|||
firewallIps: null, |
|||
assignedClientCount: 1, |
|||
createdAt: new Date('2026-01-01T00:00:00.000Z'), |
|||
updatedAt: new Date('2026-01-01T00:00:00.000Z'), |
|||
clients: [ |
|||
{ |
|||
id: 7, |
|||
name: 'Phone', |
|||
enabled: true, |
|||
ipv4Address: '10.8.0.2', |
|||
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:2', |
|||
createdAt: new Date('2026-01-01T00:00:00.000Z'), |
|||
updatedAt: new Date('2026-01-01T00:00:00.000Z'), |
|||
}, |
|||
], |
|||
}); |
|||
const handler = ( |
|||
await import('../../server/api/client-group/[groupId]/index.get') |
|||
).default; |
|||
|
|||
const response = await handler( |
|||
createH3Event('http://wg.test/api/client-group/1', { |
|||
params: { groupId: '1' }, |
|||
}) |
|||
); |
|||
|
|||
expect(response.clients[0]).not.toHaveProperty('privateKey'); |
|||
expect(response.clients[0]).not.toHaveProperty('preSharedKey'); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue