Browse Source

Merge b0ddd85c3e into 78b2838498

pull/2681/merge
BenchProtocol 4 days ago
committed by GitHub
parent
commit
28214b8af8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      README.md
  2. 4
      docs/content/guides/admin.md
  3. 150
      docs/content/guides/client-groups.md
  4. 10
      docs/content/guides/clients.md
  5. 3
      docs/zensical.toml
  6. 14
      src/app/components/Base/Dialog.vue
  7. 38
      src/app/components/ClientGroups/ConfirmMembershipDialog.vue
  8. 56
      src/app/components/ClientGroups/DeleteDialog.vue
  9. 74
      src/app/components/ClientGroups/Form.vue
  10. 45
      src/app/components/ClientGroups/ManagedPolicyValue.vue
  11. 127
      src/app/components/ClientGroups/PolicyField.vue
  12. 99
      src/app/components/ClientGroups/Selector.vue
  13. 19
      src/app/components/Clients/CreateDialog.vue
  14. 7
      src/app/components/Icons/Unlink.vue
  15. 13
      src/app/components/Ui/UserMenu.vue
  16. 245
      src/app/pages/clients/[id].vue
  17. 383
      src/app/pages/groups/[id].vue
  18. 162
      src/app/pages/groups/index.vue
  19. 85
      src/app/pages/groups/new.vue
  20. 137
      src/app/stores/clientGroups.ts
  21. 365
      src/app/utils/clientGroups.ts
  22. 74
      src/i18n/locales/en.json
  23. 32
      src/i18n/locales/tr.json
  24. 29
      src/server/api/client-group/[groupId]/index.delete.ts
  25. 28
      src/server/api/client-group/[groupId]/index.get.ts
  26. 48
      src/server/api/client-group/[groupId]/index.post.ts
  27. 6
      src/server/api/client-group/index.get.ts
  28. 34
      src/server/api/client-group/index.post.ts
  29. 6
      src/server/api/client-group/membership.get.ts
  30. 43
      src/server/api/client/[clientId]/effective-policy.get.ts
  31. 33
      src/server/api/client/[clientId]/groups/[groupId]/index.delete.ts
  32. 48
      src/server/api/client/[clientId]/groups/[groupId]/index.post.ts
  33. 29
      src/server/api/client/[clientId]/groups/index.get.ts
  34. 65
      src/server/api/client/[clientId]/groups/index.put.ts
  35. 7
      src/server/api/client/[clientId]/index.get.ts
  36. 4
      src/server/api/client/index.post.ts
  37. 14
      src/server/database/migrations/0007_amused_madame_web.sql
  38. 63
      src/server/database/migrations/0008_sweet_firelord.sql
  39. 1127
      src/server/database/migrations/meta/0007_snapshot.json
  40. 1197
      src/server/database/migrations/meta/0008_snapshot.json
  41. 14
      src/server/database/migrations/meta/_journal.json
  42. 58
      src/server/database/repositories/client/service.ts
  43. 7
      src/server/database/repositories/client/types.ts
  44. 85
      src/server/database/repositories/clientGroup/schema.ts
  45. 466
      src/server/database/repositories/clientGroup/service.ts
  46. 157
      src/server/database/repositories/clientGroup/types.ts
  47. 1
      src/server/database/schema.ts
  48. 3
      src/server/database/sqlite.ts
  49. 15
      src/server/utils/WireGuard.ts
  50. 62
      src/server/utils/firewall.ts
  51. 16
      src/server/utils/wgHelper.ts
  52. 183
      src/shared/utils/clientPolicy.ts
  53. 664
      src/test/unit/clientGroup.spec.ts
  54. 522
      src/test/unit/clientGroupFrontend.spec.ts
  55. 234
      src/test/unit/clientGroupPolicy.spec.ts
  56. 295
      src/test/unit/clientGroupRoutes.spec.ts
  57. 2
      src/vitest.config.ts

1
README.md

@ -32,6 +32,7 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
- CIDR support
- 2FA support
- Per-client firewall filtering (requires iptables)
- Client groups with composable Allowed IP, DNS, and firewall policies.
> [!NOTE]
> To better manage documentation for this project, it has its own site here: [https://wg-easy.github.io/wg-easy/latest](https://wg-easy.github.io/wg-easy/latest)

4
docs/content/guides/admin.md

@ -10,6 +10,8 @@ Enable server-side firewall filtering to enforce network access restrictions per
When enabled, each client can have custom "Firewall Allowed IPs" configured that restrict which destinations they can access through the VPN. These restrictions are enforced by the server using iptables/ip6tables and cannot be bypassed by the client.
Client Groups can also define Firewall IP policies. Group Firewall IP policies use the same Per-Client Firewall setting and are ignored while this feature is disabled.
/// warning | Experimental Feature
This feature is currently experimental. While functional, it should be thoroughly tested in your environment before relying on it for production security requirements. Always verify that firewall rules are working as expected using test traffic or by manually inspecting the rules.
@ -42,4 +44,6 @@ Most Linux distributions include iptables by default. If you're running in a min
3. Specify allowed destinations (IPs, subnets, ports) for that client
4. Server enforces these rules automatically
See [Client Groups](./client-groups.md) for grouped policy behavior.
See [Edit Client → Firewall Allowed IPs](./clients.md#firewall-allowed-ips) for detailed configuration syntax and examples.

150
docs/content/guides/client-groups.md

@ -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.
///

10
docs/content/guides/clients.md

@ -82,6 +82,16 @@ Which IPs will be routed to the client.
The DNS server that the client will use.
## Client Groups
Administrators can assign a client to one or more Client Groups.
When a client belongs to at least one group, the client's individual **Allowed IPs**, **DNS**, and **Firewall Allowed IPs** fields may become read-only. The page shows the effective value from the assigned groups, or the global/default value when none of the assigned groups defines that field.
Individual client values remain stored and become active again when all groups are removed.
See [Client Groups](./client-groups.md) for group creation, assignment, ordering, and policy resolution details.
## Advanced
- **MTU**: The maximum transmission unit for the client.

3
docs/zensical.toml

@ -18,6 +18,9 @@ edit_uri = "edit/master/docs/content"
docs_dir = "content/"
# Guide navigation is generated from docs/content/guides.
# Keep the Client Groups guide next to the Edit Client guide.
site_url = "https://wg-easy.github.io/wg-easy"
[project.theme]

14
src/app/components/Base/Dialog.vue

@ -1,6 +1,12 @@
<template>
<DialogRoot :modal="true">
<DialogTrigger :class="triggerClass"><slot name="trigger" /></DialogTrigger>
<DialogTrigger
:class="triggerClass"
:aria-label="triggerAriaLabel"
:title="triggerTitle"
>
<slot name="trigger" />
</DialogTrigger>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-30 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
@ -27,5 +33,9 @@
</template>
<script lang="ts" setup>
defineProps<{ triggerClass?: string }>();
defineProps<{
triggerClass?: string;
triggerAriaLabel?: string;
triggerTitle?: string;
}>();
</script>

38
src/app/components/ClientGroups/ConfirmMembershipDialog.vue

@ -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>

56
src/app/components/ClientGroups/DeleteDialog.vue

@ -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>

74
src/app/components/ClientGroups/Form.vue

@ -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>

45
src/app/components/ClientGroups/ManagedPolicyValue.vue

@ -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>

127
src/app/components/ClientGroups/PolicyField.vue

@ -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>

99
src/app/components/ClientGroups/Selector.vue

@ -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>

19
src/app/components/Clients/CreateDialog.vue

@ -14,6 +14,13 @@
v-model="expiresAt"
:label="$t('client.expireDate')"
/>
<FormHeading class="mt-4">
{{ $t('clientGroup.title') }}
</FormHeading>
<ClientGroupsSelector
v-model="selectedGroupIds"
:groups="groupsStore.groups"
/>
</div>
</template>
<template #actions>
@ -30,16 +37,26 @@
</template>
<script lang="ts" setup>
import { groupIdsFromSelection } from '../../utils/clientGroups';
const name = ref<string>('');
const expiresAt = ref<string | null>(null);
const clientsStore = useClientsStore();
const groupsStore = useClientGroupsStore();
const selectedGroupIds = ref<string[]>([]);
const { t } = useI18n();
defineProps<{ triggerClass?: string }>();
await groupsStore.refresh();
function createClient() {
return _createClient({ name: name.value, expiresAt: expiresAt.value });
return _createClient({
name: name.value,
expiresAt: expiresAt.value,
groupIds: groupIdsFromSelection(selectedGroupIds.value),
});
}
const _createClient = useSubmit(

7
src/app/components/Icons/Unlink.vue

@ -0,0 +1,7 @@
<template>
<LinkSlashIcon />
</template>
<script lang="ts" setup>
import LinkSlashIcon from '@heroicons/vue/24/outline/esm/LinkSlashIcon';
</script>

13
src/app/components/Ui/UserMenu.vue

@ -30,6 +30,19 @@
{{ $t('pages.clients') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem
v-if="
authStore.userData &&
hasPermissions(authStore.userData, 'admin', 'any')
"
>
<NuxtLink
to="/groups"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
{{ $t('pages.groups') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
<NuxtLink
to="/me"

245
src/app/pages/clients/[id].vue

@ -29,6 +29,28 @@
:label="$t('client.expireDate')"
/>
</FormGroup>
<FormGroup v-if="canManageGroups">
<FormHeading :description="$t('clientGroup.clientSelectorDesc')">
{{ $t('clientGroup.title') }}
</FormHeading>
<ClientGroupsSelector
v-model="selectedGroupIds"
:groups="groupsStore.groups"
:disabled="groupsLoading"
/>
<p
v-if="groupsLoading"
class="text-sm text-gray-500 dark:text-neutral-300"
>
{{ $t('general.loading') }}
</p>
<p
v-if="groupLoadError"
class="text-sm text-red-700 dark:text-red-300"
>
{{ groupLoadError }}
</p>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('client.address') }}</FormHeading>
<FormTextField
@ -52,7 +74,23 @@
<FormHeading :description="$t('client.allowedIpsDesc')">
{{ $t('general.allowedIps') }}
</FormHeading>
<FormNullArrayField v-model="data.allowedIps" name="allowedIps" />
<p
v-if="!isGroupManagedPolicy('allowedIps')"
class="col-span-2 text-sm text-gray-500 dark:text-neutral-300"
>
{{ $t(effectivePolicyMessage('allowedIps')) }}
</p>
<FormNullArrayField
v-if="!isGroupManagedPolicy('allowedIps')"
v-model="data.allowedIps"
name="allowedIps"
/>
<ClientGroupsManagedPolicyValue
v-else
:message-key="effectivePolicyMessage('allowedIps')"
:groups="draftEffectivePolicy?.allowedIps.groups ?? []"
:value="draftEffectivePolicy?.allowedIps.value ?? []"
/>
</FormGroup>
<FormGroup>
<FormHeading :description="$t('client.serverAllowedIpsDesc')">
@ -67,13 +105,45 @@
<FormHeading :description="$t('client.firewallIpsDesc')">
{{ $t('client.firewallIps') }}
</FormHeading>
<FormNullArrayField v-model="data.firewallIps" name="firewallIps" />
<p
v-if="!isGroupManagedPolicy('firewallIps')"
class="col-span-2 text-sm text-gray-500 dark:text-neutral-300"
>
{{ $t(effectivePolicyMessage('firewallIps')) }}
</p>
<FormNullArrayField
v-if="!isGroupManagedPolicy('firewallIps')"
v-model="data.firewallIps"
name="firewallIps"
/>
<ClientGroupsManagedPolicyValue
v-else
:message-key="effectivePolicyMessage('firewallIps')"
:groups="draftEffectivePolicy?.firewallIps.groups ?? []"
:value="draftEffectivePolicy?.firewallIps.value ?? []"
/>
</FormGroup>
<FormGroup>
<FormHeading :description="$t('client.dnsDesc')">
{{ $t('general.dns') }}
</FormHeading>
<FormNullArrayField v-model="data.dns" name="dns" />
<p
v-if="!isGroupManagedPolicy('dns')"
class="col-span-2 text-sm text-gray-500 dark:text-neutral-300"
>
{{ $t(effectivePolicyMessage('dns')) }}
</p>
<FormNullArrayField
v-if="!isGroupManagedPolicy('dns')"
v-model="data.dns"
name="dns"
/>
<ClientGroupsManagedPolicyValue
v-else
:message-key="effectivePolicyMessage('dns')"
:groups="draftEffectivePolicy?.dns.groups ?? []"
:value="draftEffectivePolicy?.dns.value ?? []"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('form.sectionAdvanced') }}</FormHeading>
@ -210,7 +280,23 @@
</template>
<script lang="ts" setup>
import {
apiErrorMessage,
effectivePolicyMessageKey,
groupSelectionFromMembership,
saveClientGroupMembership,
selectedClientGroups,
type ClientGroupPolicyKey,
} from '../../utils/clientGroups';
import { resolveClientEffectivePolicy } from '#shared/utils/clientPolicy';
import { hasPermissions } from '#shared/utils/permissions';
const globalStore = useGlobalStore();
const authStore = useAuthStore();
const groupsStore = useClientGroupsStore();
const toast = useToast();
const { t } = useI18n();
const route = useRoute();
const id = route.params.id as string;
@ -219,31 +305,93 @@ const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
method: 'get',
});
const data = toRef(_data.value);
const selectedGroupIds = ref<string[]>([]);
const initialSelectedGroupIds = ref<string[]>([]);
const groupsLoading = ref(false);
const groupLoadError = ref('');
const userConfig = ref<{
defaultAllowedIps: string[];
defaultDns: string[];
} | null>(null);
const selectedGroups = computed(() =>
selectedClientGroups(groupsStore.groups, selectedGroupIds.value)
);
const draftEffectivePolicy = computed(() => {
if (!data.value || !userConfig.value) {
return null;
}
const _submit = useSubmit(
(data) =>
$fetch(`/api/client/${id}`, {
method: 'post',
body: data,
}),
{
revert: async (success) => {
if (success) {
await navigateTo('/');
} else {
await revert();
}
return resolveClientEffectivePolicy({
client: {
allowedIps: data.value.allowedIps,
dns: data.value.dns,
firewallIps: data.value.firewallIps,
},
groups: selectedGroups.value,
userConfig: userConfig.value,
firewallEnabled: globalStore.information?.firewallEnabled === true,
}).fields;
});
const canManageGroups = computed(() => {
return (
authStore.userData && hasPermissions(authStore.userData, 'admin', 'any')
);
});
if (canManageGroups.value && data.value) {
await loadClientGroups();
}
async function submit() {
try {
await $fetch(`/api/client/${id}`, {
method: 'post',
body: data.value,
});
} catch (error) {
toast.showToast({
type: 'error',
message: apiErrorMessage(error, t('toast.unknown')),
});
await revert();
return;
}
);
function submit() {
return _submit(data.value);
try {
if (canManageGroups.value && data.value) {
await saveClientGroupMembership(
data.value.id,
initialSelectedGroupIds.value,
selectedGroupIds.value,
{
setGroups: groupsStore.setClientGroups,
}
);
await groupsStore.refreshMembership();
syncSelectedGroup();
}
} catch (error) {
toast.showToast({
type: 'error',
message: t('clientGroup.membershipSaveError', {
message: apiErrorMessage(error, t('toast.unknown')),
}),
});
return;
}
toast.showToast({
type: 'success',
message: t('toast.saved'),
});
await navigateTo('/');
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
syncSelectedGroup();
}
const _deleteClient = useSubmit(
@ -262,4 +410,63 @@ const _deleteClient = useSubmit(
function deleteClient() {
return _deleteClient(undefined);
}
async function loadClientGroups() {
groupsLoading.value = true;
groupLoadError.value = '';
try {
await Promise.all([
groupsStore.refresh(),
groupsStore.refreshMembership(),
refreshUserConfig(),
]);
syncSelectedGroup();
} catch (error) {
groupLoadError.value = apiErrorMessage(error, t('clientGroup.loadError'));
} finally {
groupsLoading.value = false;
}
}
function syncSelectedGroup() {
if (!data.value) {
selectedGroupIds.value = [];
initialSelectedGroupIds.value = [];
return;
}
const currentSelection = groupSelectionFromMembership(
groupsStore.membership ?? [],
data.value.id
);
selectedGroupIds.value = currentSelection;
initialSelectedGroupIds.value = currentSelection;
}
function isGroupManagedPolicy(key: ClientGroupPolicyKey) {
if (
key === 'firewallIps' &&
globalStore.information?.firewallEnabled !== true
) {
return false;
}
return selectedGroupIds.value.length > 0;
}
function effectivePolicyMessage(key: ClientGroupPolicyKey) {
const field = draftEffectivePolicy.value?.[key];
return field
? effectivePolicyMessageKey(field)
: 'clientGroup.inheritedFromGlobal';
}
async function refreshUserConfig() {
userConfig.value = await $fetch<{
defaultAllowedIps: string[];
defaultDns: string[];
}>('/api/admin/userconfig');
}
</script>

383
src/app/pages/groups/[id].vue

@ -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>

162
src/app/pages/groups/index.vue

@ -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>

85
src/app/pages/groups/new.vue

@ -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>

137
src/app/stores/clientGroups.ts

@ -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,
};
});

365
src/app/utils/clientGroups.ts

@ -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());
}

74
src/i18n/locales/en.json

@ -2,6 +2,7 @@
"pages": {
"me": "Account",
"clients": "Clients",
"groups": "Groups",
"admin": {
"panel": "Admin Panel",
"general": "General",
@ -130,11 +131,74 @@
"search": "Search clients...",
"config": "Configuration",
"viewConfig": "View Configuration",
"firewallIps": "Firewall Allowed IPs",
"firewallIps": "Firewall IPs",
"firewallIpsDesc": "Destination IPs/CIDRs this client can access (server-side enforcement). Leave empty to use Allowed IPs. Supports optional port and protocol filtering. See docs for syntax.",
"downloadPng": "Download PNG",
"copyPng": "Copy PNG"
},
"clientGroup": {
"title": "Client Groups",
"create": "Create Group",
"created": "Client group created",
"deleted": "Client group deleted",
"details": "Details",
"edit": "Edit",
"delete": "Delete",
"description": "Description",
"noDescription": "No description",
"empty": "There are no client groups yet.",
"loadError": "Client groups could not be loaded.",
"nameRequired": "Group name is required.",
"policyValues": "Group Policy Values",
"policyDescription": "Defined values override assigned clients at runtime without changing stored client fields.",
"notDefined": "Not defined",
"defined": "Defined for this group",
"manageAllowedIps": "Manage Allowed IPs in this group",
"manageDns": "Manage DNS in this group",
"manageFirewallIps": "Manage Firewall IPs in this group",
"notDefinedShort": "Not defined",
"entryCountOne": "{count} entry",
"entryCountOther": "{count} entries",
"clientCountOne": "{count} client",
"clientCountOther": "{count} clients",
"removeEntry": "Remove",
"removeAllowedIp": "Remove Allowed IP",
"removeDns": "Remove DNS entry",
"removeFirewallIp": "Remove Firewall IP",
"clients": "Clients",
"membership": "Membership",
"assignedClientCountOne": "{count} assigned client",
"assignedClientCountOther": "{count} assigned clients",
"assignClient": "Assign Client",
"selectClient": "Select a client",
"selectGroup": "Select a group",
"unassignClient": "Unassign Client",
"unassignClientConfirm": "{name} will be removed from this group. The client and its other group memberships will not be deleted.",
"clientAssigned": "Client group membership updated",
"clientUnassigned": "Client unassigned",
"noAssignedClients": "No clients are assigned to this group.",
"deleteGroup": "Delete Group",
"editGroupLabel": "Edit {name}",
"deleteGroupLabel": "Delete {name}",
"deleteConfirm": "Delete {name}?",
"deleteAssignedConfirmOne": "{count} membership to this group will be removed. The client itself and any other group memberships will not be deleted.",
"deleteAssignedConfirmOther": "{count} memberships to this group will be removed. The clients themselves and any other group memberships will not be deleted.",
"backToGroups": "Back to Groups",
"disabled": "Disabled",
"noGroup": "No groups",
"clientSelector": "Client Groups",
"clientSelectorDesc": "Assign this client to zero, one, or multiple ordered groups.",
"noGroupsSelected": "No groups selected",
"removeGroupLabel": "Remove {name}",
"policyRequired": "Enabled group policy fields require at least one value.",
"managedByGroup": "Managed by {group}",
"managedByGroupLinked": "Managed by {groups} group",
"managedByOneGroupLinked": "Managed by {groups} group",
"managedByManyGroupsLinked": "Managed by {groups} groups",
"inheritedFromGlobal": "Inherited from global configuration",
"usingClientValue": "Using client value",
"membershipSaveError": "Client settings were saved, but group membership could not be updated: {message}"
},
"dialog": {
"change": "Change",
"cancel": "Cancel",
@ -216,9 +280,15 @@
"address4": "IPv4 Address",
"address6": "IPv6 Address",
"serverAllowedIps": "Server Allowed IPs",
"firewallIps": "Firewall Allowed IPs",
"firewallIps": "Firewall IPs",
"firewallIpsInvalid": "Invalid firewall IP entry. See docs for supported syntax."
},
"clientGroup": {
"id": "Client Group ID",
"name": "Name",
"description": "Description",
"policyRequired": "Group policy value"
},
"user": {
"username": "Username",
"password": "Password",

32
src/i18n/locales/tr.json

@ -121,11 +121,33 @@
"search": "İstemci ara...",
"config": "Config",
"viewConfig": "Config Görüntüle",
"firewallIps": "Firewall Allowed IPs",
"firewallIps": "Firewall IPs",
"firewallIpsDesc": "İstemcinin erişebileceği hedef IP/CIDR'ler (isteğe bağlı port/protokol filtreleme ile). Boş bırakılırsa Allowed IPs kullanılır. Ayrıntılı söz dizimi için dokümantasyona bakın.",
"downloadPng": "PNG İndir",
"copyPng": "PNG Kopyala"
},
"clientGroup": {
"edit": "Düzenle",
"delete": "Sil",
"editGroupLabel": "{name} grubunu düzenle",
"deleteGroupLabel": "{name} grubunu sil",
"notDefined": "Tanımlı değil",
"manageAllowedIps": "Bu grupta Allowed IPs yönet",
"manageDns": "Bu grupta DNS yönet",
"manageFirewallIps": "Bu grupta Firewall IPs yönet",
"removeAllowedIp": "Allowed IP'yi kaldır",
"removeDns": "DNS kaydını kaldır",
"removeFirewallIp": "Firewall IP'yi kaldır",
"selectGroup": "Grup seç",
"noGroupsSelected": "Seçili grup yok",
"removeGroupLabel": "{name} grubunu kaldır",
"policyRequired": "Etkin grup politikaları en az bir değer gerektirir.",
"managedByGroupLinked": "{groups} grubu tarafından yönetiliyor",
"managedByOneGroupLinked": "{groups} grubu tarafından yönetiliyor",
"managedByManyGroupsLinked": "{groups} grupları tarafından yönetiliyor",
"inheritedFromGlobal": "Genel yapılandırmadan devralındı",
"usingClientValue": "İstemci değeri kullanılıyor"
},
"dialog": {
"change": "Değiştir",
"cancel": "İptal",
@ -205,9 +227,15 @@
"address4": "IPv4 Adresi",
"address6": "IPv6 Adresi",
"serverAllowedIps": "Server Allowed IPs",
"firewallIps": "Firewall Allowed IPs",
"firewallIps": "Firewall IPs",
"firewallIpsInvalid": "Geçersiz Firewall IP formatı. Desteklenen söz dizimi için dokümantasyona bakın. See docs for supported syntax."
},
"clientGroup": {
"id": "Client Group ID",
"name": "Name",
"description": "Description",
"policyRequired": "Group policy value"
},
"user": {
"username": "Kullanıcı Adı",
"password": "Şifre",

29
src/server/api/client-group/[groupId]/index.delete.ts

@ -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 };
}
);

28
src/server/api/client-group/[groupId]/index.get.ts

@ -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;
}
);

48
src/server/api/client-group/[groupId]/index.post.ts

@ -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;
}
}
);

6
src/server/api/client-group/index.get.ts

@ -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();
});

34
src/server/api/client-group/index.post.ts

@ -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;
}
}
);

6
src/server/api/client-group/membership.get.ts

@ -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();
});

43
src/server/api/client/[clientId]/effective-policy.get.ts

@ -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;
}
);

33
src/server/api/client/[clientId]/groups/[groupId]/index.delete.ts

@ -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 };
}
);

48
src/server/api/client/[clientId]/groups/[groupId]/index.post.ts

@ -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 };
}
);

29
src/server/api/client/[clientId]/groups/index.get.ts

@ -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);
}
);

65
src/server/api/client/[clientId]/groups/index.put.ts

@ -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 };
}
);

7
src/server/api/client/[clientId]/index.get.ts

@ -4,7 +4,10 @@ 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 { ClientGetSchema } from '#db/repositories/client/types';
import {
ClientGetSchema,
toCurrentPublicClient,
} from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
@ -29,7 +32,7 @@ export default definePermissionEventHandler(
const data = await WireGuard.dumpByPublicKey(result.publicKey);
return {
...result,
...toCurrentPublicClient(result),
endpoint: data?.endpoint,
};
}

4
src/server/api/client/index.post.ts

@ -10,12 +10,12 @@ export default definePermissionEventHandler(
'clients',
'create',
async ({ event }) => {
const { name, expiresAt } = await readValidatedBody(
const { name, expiresAt, groupIds } = await readValidatedBody(
event,
validateZod(ClientCreateSchema, event)
);
const result = await Database.clients.create({ name, expiresAt });
const result = await Database.clients.create({ name, expiresAt, groupIds });
await WireGuard.saveConfig();
const clientId = result[0]!.clientId;

14
src/server/database/migrations/0007_amused_madame_web.sql

@ -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`);

63
src/server/database/migrations/0008_sweet_firelord.sql

@ -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`);

1127
src/server/database/migrations/meta/0007_snapshot.json

File diff suppressed because it is too large

1197
src/server/database/migrations/meta/0008_snapshot.json

File diff suppressed because it is too large

14
src/server/database/migrations/meta/_journal.json

@ -50,6 +50,20 @@
"when": 1782122902196,
"tag": "0006_clear_leech",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1782574033706,
"tag": "0007_amused_madame_web",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1782649656986,
"tag": "0008_sweet_firelord",
"breakpoints": true
}
]
}

58
src/server/database/repositories/client/service.ts

@ -1,7 +1,8 @@
import { eq, sql, or, like, and } from 'drizzle-orm';
import { eq, sql, or, like, and, inArray } from 'drizzle-orm';
import { containsCidr, parseCidr } from 'cidr-tools';
import { client } from './schema';
import { CurrentPublicClientColumns } from './types';
import type {
ClientCreateFromExistingType,
ClientCreateType,
@ -14,7 +15,12 @@ import { nextIP } from '#server/utils/ip';
import type { ID } from '#server/utils/types';
import { wg } from '#server/utils/wgHelper';
import type { DBType } from '#db/sqlite';
import { wgInterface, userConfig } from '#db/schema';
import {
clientGroup,
clientGroupMembership,
wgInterface,
userConfig,
} from '#db/schema';
function createPreparedStatement(db: DBType) {
return {
@ -85,6 +91,7 @@ export class ClientService {
},
where: and(...filters),
columns: {
...CurrentPublicClientColumns,
privateKey: false,
preSharedKey: false,
},
@ -128,6 +135,7 @@ export class ClientService {
where: and(eq(client.userId, userId), ...filters),
with: { oneTimeLink: true },
columns: {
...CurrentPublicClientColumns,
privateKey: false,
preSharedKey: false,
},
@ -153,12 +161,33 @@ export class ClientService {
return this.#statements.findById.execute({ id });
}
async create({ name, expiresAt }: ClientCreateType) {
async create({ name, expiresAt, groupIds }: ClientCreateType) {
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
const preSharedKey = await wg.generatePreSharedKey();
return this.#db.transaction(async (tx) => {
if (new Set(groupIds).size !== groupIds.length) {
throw new Error('Duplicate client group');
}
if (groupIds.length > 0) {
const groups = await tx.query.clientGroup
.findMany({
where: inArray(clientGroup.id, groupIds),
columns: { id: true },
})
.execute();
const existingGroupIds = new Set(groups.map((group) => group.id));
const missingGroupId = groupIds.find(
(groupId) => !existingGroupIds.has(groupId)
);
if (missingGroupId) {
throw new Error('Client group not found');
}
}
const clients = await tx.query.client.findMany().execute();
const clientInterface = await tx.query.wgInterface
.findFirst({
@ -185,7 +214,7 @@ export class ClientService {
const ipv6Cidr = parseCidr(clientInterface.ipv6Cidr);
const ipv6Address = nextIP(6, ipv6Cidr, clients);
return await tx
const result = await tx
.insert(client)
.values({
name,
@ -213,6 +242,27 @@ export class ClientService {
})
.returning({ clientId: client.id })
.execute();
const clientId = result[0]?.clientId;
if (!clientId) {
throw new Error('Client was not created');
}
if (groupIds.length > 0) {
await tx
.insert(clientGroupMembership)
.values(
groupIds.map((groupId, position) => ({
clientId,
groupId,
position,
}))
)
.execute();
}
return result;
});
}

7
src/server/database/repositories/client/types.ts

@ -25,6 +25,12 @@ import {
export type ClientType = InferSelectModel<typeof client>;
export const CurrentPublicClientColumns = {} as const;
export function toCurrentPublicClient<T>(client: T) {
return client;
}
export type ClientNextIpType = Pick<ClientType, 'ipv4Address' | 'ipv6Address'>;
export type CreateClientType = Omit<
@ -71,6 +77,7 @@ const serverAllowedIps = z.array(AddressSchema, {
export const ClientCreateSchema = z.object({
name: name,
expiresAt: expiresAt,
groupIds: z.array(z.coerce.number().int().positive()).default([]),
});
export type ClientCreateType = z.infer<typeof ClientCreateSchema>;

85
src/server/database/repositories/clientGroup/schema.ts

@ -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),
}));

466
src/server/database/repositories/clientGroup/service.ts

@ -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));
}
}

157
src/server/database/repositories/clientGroup/types.ts

@ -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,
});

1
src/server/database/schema.ts

@ -1,5 +1,6 @@
// ! Do not use Path Aliases in this or any of these files
export * from './repositories/client/schema';
export * from './repositories/clientGroup/schema';
export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';

3
src/server/database/sqlite.ts

@ -11,6 +11,7 @@ import { InterfaceService } from '#db/repositories/interface/service';
import { HooksService } from '#db/repositories/hooks/service';
import { OneTimeLinkService } from '#db/repositories/oneTimeLink/service';
import { ClientService } from '#db/repositories/client/service';
import { ClientGroupService } from '#db/repositories/clientGroup/service';
import * as schema from '#db/schema';
import { WG_ENV, WG_INITIAL_ENV } from '#server/utils/config';
@ -37,6 +38,7 @@ export async function connect() {
class DBService {
clients: ClientService;
clientGroups: ClientGroupService;
general: GeneralService;
users: UserService;
userConfigs: UserConfigService;
@ -46,6 +48,7 @@ class DBService {
constructor(db: DBType) {
this.clients = new ClientService(db);
this.clientGroups = new ClientGroupService(db);
this.general = new GeneralService(db);
this.users = new UserService(db);
this.userConfigs = new UserConfigService(db);

15
src/server/utils/WireGuard.ts

@ -8,6 +8,7 @@ import { firewall } from '#server/utils/firewall';
import { encodeQRCode } from '#server/utils/qr';
import type { ID } from '#server/utils/types';
import { wg } from '#server/utils/wgHelper';
import { resolveClientEffectivePolicy } from '#shared/utils/clientPolicy';
import { setIntervalImmediately } from '#shared/utils/time';
import type { InterfaceType } from '#db/repositories/interface/types';
import type { ClientQueryType } from '#db/repositories/client/types';
@ -33,12 +34,16 @@ class WireGuard {
*/
async #applyFirewallRules(wgInterface: InterfaceType) {
const clients = await Database.clients.getAll();
const memberships = await Database.clientGroups.listMembership();
const groups = await Database.clientGroups.list();
const userConfig = await Database.userConfigs.get();
await firewall.rebuildRules(
wgInterface,
clients,
userConfig,
!WG_ENV.DISABLE_IPV6
!WG_ENV.DISABLE_IPV6,
groups,
memberships
);
}
@ -173,8 +178,16 @@ class WireGuard {
throw new Error('Client not found');
}
const groups = await Database.clientGroups.getGroupsForClient(client.id);
return wg.generateClientConfig(wgInterface, userConfig, client, {
enableIpv6: !WG_ENV.DISABLE_IPV6,
effectivePolicy: resolveClientEffectivePolicy({
client,
groups,
userConfig,
firewallEnabled: wgInterface.firewallEnabled,
}),
});
}

62
src/server/utils/firewall.ts

@ -2,6 +2,10 @@ import { createDebug } from 'obug';
import { isIPv6 } from 'is-ip';
import { exec } from '#server/utils/cmd';
import {
resolveClientEffectivePolicy,
type ClientPolicyGroup,
} from '#shared/utils/clientPolicy';
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
import type { UserConfigType } from '#db/repositories/userConfig/types';
@ -29,10 +33,17 @@ type FirewallClient = Pick<
| 'ipv4Address'
| 'ipv6Address'
| 'allowedIps'
| 'dns'
| 'firewallIps'
| 'enabled'
>;
type ClientGroupMembership = {
clientId: number;
groupId: number;
position: number;
};
/**
* Sanitize a client identifier for use in an iptables comment.
* Strips all characters except ASCII alphanumeric, space, underscore, hyphen, and dot.
@ -160,6 +171,19 @@ function generateRuleArgs(
return rules;
}
function resolveFirewallClientIps(
client: FirewallClient,
userConfig: Pick<UserConfigType, 'defaultAllowedIps' | 'defaultDns'>,
groups: ClientPolicyGroup[] = []
) {
return resolveClientEffectivePolicy({
client,
groups,
userConfig,
firewallEnabled: true,
}).firewallIps;
}
export const firewall = {
/**
* Initialize the custom chain if it doesn't exist
@ -197,15 +221,11 @@ export const firewall = {
*/
async applyClientRules(
client: FirewallClient,
defaultAllowedIps: string[],
enableIpv6: boolean
userConfig: Pick<UserConfigType, 'defaultAllowedIps' | 'defaultDns'>,
enableIpv6: boolean,
groups: ClientPolicyGroup[] = []
): Promise<void> {
// Determine which IPs to use for firewall rules
// Priority: firewallIps > allowedIps > defaultAllowedIps
const effectiveIps =
client.firewallIps && client.firewallIps.length > 0
? client.firewallIps
: (client.allowedIps ?? defaultAllowedIps);
const effectiveIps = resolveFirewallClientIps(client, userConfig, groups);
FW_DEBUG(
`Applying firewall rules for client ${client.name} (${client.id}): ${effectiveIps.join(', ')}`
@ -241,7 +261,9 @@ export const firewall = {
wgInterface: InterfaceType,
clients: FirewallClient[],
userConfig: UserConfigType,
enableIpv6: boolean
enableIpv6: boolean,
groups: ClientPolicyGroup[] = [],
memberships: ClientGroupMembership[] = []
): Promise<void> {
if (!wgInterface.firewallEnabled) {
FW_DEBUG('Firewall filtering disabled, removing any existing rules');
@ -270,10 +292,18 @@ export const firewall = {
// Apply rules for each enabled client
for (const client of clients) {
if (!client.enabled) continue;
const clientGroups = memberships
.filter((membership) => membership.clientId === client.id)
.sort((a, b) => a.position - b.position)
.map((membership) =>
groups.find((candidate) => candidate.id === membership.groupId)
)
.filter((group): group is ClientPolicyGroup => !!group);
await this.applyClientRules(
client,
userConfig.defaultAllowedIps,
enableIpv6
userConfig,
enableIpv6,
clientGroups
);
}
@ -291,7 +321,14 @@ export const firewall = {
if (rebuildQueued) {
rebuildQueued = false;
FW_DEBUG('Processing queued rebuild');
await this.rebuildRules(wgInterface, clients, userConfig, enableIpv6);
await this.rebuildRules(
wgInterface,
clients,
userConfig,
enableIpv6,
groups,
memberships
);
}
}
},
@ -361,4 +398,5 @@ export const firewallTestExports = {
parseFirewallEntry,
generateRuleArgs,
sanitizeComment,
resolveFirewallClientIps,
};

16
src/server/utils/wgHelper.ts

@ -4,6 +4,10 @@ import { stringifyIp } from 'ip-bigint';
import { removeNewlines, iptablesTemplate } from '#server/utils/template';
import { exec } from '#server/utils/cmd';
import { WG_ENV } from '#server/utils/config';
import {
resolveClientEffectivePolicy,
type EffectiveClientPolicy,
} from '#shared/utils/clientPolicy';
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
import type { UserConfigType } from '#db/repositories/userConfig/types';
@ -11,6 +15,7 @@ import type { HooksType } from '#db/repositories/hooks/types';
type Options = {
enableIpv6?: boolean;
effectivePolicy?: EffectiveClientPolicy;
};
// needed to support cli
@ -110,6 +115,13 @@ PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`;
options: Options = {}
) => {
const { enableIpv6 = true } = options;
const effectivePolicy =
options.effectivePolicy ??
resolveClientEffectivePolicy({
client,
userConfig,
firewallEnabled: wgInterface.firewallEnabled,
});
const address =
`${client.ipv4Address}/32` +
@ -122,7 +134,7 @@ PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`;
client.postDown ? `PostDown = ${removeNewlines(client.postDown)}` : null,
];
const dnsServers = client.dns ?? userConfig.defaultDns;
const dnsServers = effectivePolicy.dns;
const dnsLine =
dnsServers.length > 0 ? `DNS = ${dnsServers.join(', ')}` : null;
@ -165,7 +177,7 @@ ${extraLines.length ? `${extraLines.join('\n')}\n` : ''}
[Peer]
PublicKey = ${wgInterface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${(client.allowedIps ?? userConfig.defaultAllowedIps).join(', ')}
AllowedIPs = ${effectivePolicy.allowedIps.join(', ')}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${userConfig.host}:${userConfig.port}`;
},

183
src/shared/utils/clientPolicy.ts

@ -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) ?? [];
}

664
src/test/unit/clientGroup.spec.ts

@ -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 },
]);
});
});

522
src/test/unit/clientGroupFrontend.spec.ts

@ -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);
});
});

234
src/test/unit/clientGroupPolicy.spec.ts

@ -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);
});
});

295
src/test/unit/clientGroupRoutes.spec.ts

@ -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');
});
});

2
src/vitest.config.ts

@ -8,7 +8,9 @@ export default defineConfig({
{
resolve: {
alias: {
'#db': fileURLToPath(new URL('./server/database', import.meta.url)),
'#server': fileURLToPath(new URL('./server', import.meta.url)),
'#shared': fileURLToPath(new URL('./shared', import.meta.url)),
},
},
test: {

Loading…
Cancel
Save