Merge branch 'master' into feat/bulk-client-selection

This commit is contained in:
Evgenii Khristoforov 2026-09-27 13:41:30 +05:00 • committed by GitHub
commit b4e2fba2ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 6164 additions and 1767 deletions

14
.github/renovate.json vendored
View file

@ -15,6 +15,20 @@
"matchDatasources": ["docker"],
"matchUpdateTypes": ["digest"]
},
{
"groupName": "github-actions-digest",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["digest"]
},
{
"matchManagers": ["npm"],
"rangeStrategy": "bump"
},
{
"groupName": "npm-non-major",
"matchManagers": ["npm"],
"matchUpdateTypes": ["patch", "minor", "lockFileMaintenance"]
},
{
"matchManagers": ["docker-compose"],
"pinDigests": false

View file

@ -30,14 +30,14 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
with:
category: "/language:${{matrix.language}}"

View file

@ -45,14 +45,14 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: ${{ matrix.arch.platform }}
@ -106,7 +106,7 @@ jobs:
password: ${{ secrets.CODEBERG_PASS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Docker meta
id: meta

View file

@ -54,14 +54,14 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: ${{ matrix.arch.platform }}
@ -115,7 +115,7 @@ jobs:
password: ${{ secrets.CODEBERG_PASS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Docker meta
id: meta

View file

@ -32,10 +32,10 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@ -45,7 +45,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
push: false

View file

@ -53,14 +53,14 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: ${{ matrix.arch.platform }}
@ -116,7 +116,7 @@ jobs:
password: ${{ secrets.CODEBERG_PASS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Docker meta
id: meta

1
.node-version Normal file
View file

@ -0,0 +1 @@
lts/krypton

View file

@ -1,4 +1,4 @@
FROM docker.io/library/node:krypton-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
FROM docker.io/library/node:krypton-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81 AS build
WORKDIR /app
# update corepack
@ -17,7 +17,7 @@ RUN pnpm build
# renovate: datasource=github-releases depName=amnezia-vpn/amneziawg-tools
ARG AWGTOOLS_BRANCH=v3.1.20260812
# renovate: datasource=github-tags depName=amnezia-vpn/amneziawg-go
ARG AWGGO_BRANCH=v3.1.20260814
ARG AWGGO_BRANCH=v3.1.20260828
# Build amneziawg-tools
RUN apk add linux-headers build-base go git && \
@ -29,13 +29,13 @@ RUN apk add linux-headers build-base go git && \
make && \
sed -i 's|\[\[ $proto == -4 \]\] && cmd sysctl -q net\.ipv4\.conf\.all\.src_valid_mark=1|[[ $proto == -4 ]] \&\& [[ $(sysctl -n net.ipv4.conf.all.src_valid_mark) != 1 ]] \&\& cmd sysctl -q net.ipv4.conf.all.src_valid_mark=1|' ./wg-quick/linux.bash
FROM docker.io/library/node:krypton-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build-libsql
FROM docker.io/library/node:krypton-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81 AS build-libsql
WORKDIR /app
RUN npm install --no-save --omit=dev libsql
# Copy build result to a new image.
# This saves a lot of disk space.
FROM docker.io/library/node:krypton-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
FROM docker.io/library/node:krypton-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81
WORKDIR /app
HEALTHCHECK --interval=1m --timeout=5s --retries=3 CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q interface || exit 1"

View file

@ -1,4 +1,4 @@
FROM docker.io/library/node:krypton-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
FROM docker.io/library/node:krypton-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81
WORKDIR /app
ENV PNPM_HOME="/pnpm"

View file

@ -39,14 +39,33 @@ If a parameter is not set, it will not be added to the configuration. If all Amn
### Parameter Compatibility Table
| Parameter | Can differ between server and client | Configurable on server | Configurable on client |
| --------- | ------------------------------------ | ---------------------- | ------------------------ |
| Jc | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmin | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmax | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| S1-S4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| H1-H4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| I1-I5 | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Parameter | Can differ between server and client | Configurable on server | Configurable on client |
| ---------------------- | ------------------------------------ | ---------------------- | ------------------------ |
| Jc | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmin | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmax | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| S1-S4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| H1-H4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| I1-I5 | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| HeaderProtectionKey | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| RandomTrailers | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| ContentPaddingAddition | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| RekeyAfterTime | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| RekeyTimeout | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| RejectAfterTime | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| KeepaliveTimeout | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| MaxHandshakeAttempts | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| DisableCookies | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
### AmneziaWG 3.0 and 3.1 Parameters
These parameters require AmneziaWG 3.0 or newer on both ends of the tunnel. Older clients reject configurations that contain them, so leave them unset if you still support AmneziaWG 2.x clients.
`HeaderProtectionKey` is a 32 byte key in base64, in the same format as a WireGuard key. It encrypts the packet headers, so it has to be identical on both sides and is copied into every client configuration. Header protection uses the first 12 bytes of each junk prefix as its nonce, which means **S1, S2, S3 and S4 all have to be set to at least 12** while a key is configured.
`ContentPaddingAddition`, `RekeyAfterTime`, `RekeyTimeout`, `RejectAfterTime`, `KeepaliveTimeout` and `MaxHandshakeAttempts` accept either a single number or an inclusive range written as `lower-upper` (for example `30-90`, both bounds between 0 and 65535). When a range is given, AmneziaWG picks a random value from it, which removes the fixed timings a DPI system could otherwise fingerprint.
`RandomTrailers` appends a random amount of bytes to handshake packets and has to be enabled on both sides. `DisableCookies` stops this side from answering handshakes with cookie messages when it is under load and only affects the peer it is set on.
## Client Applications

View file

@ -11,7 +11,18 @@ You can set these environment variables to configure the container. They are not
| `INSECURE` | `false` | `true` | If access over http is allowed |
| `DISABLE_IPV6` | `false` | `true` | If IPv6 support should be disabled |
| `DISABLE_VERSION_CHECK` | `false` | `true` | If wg-easy should check for new updates |
| `TRUSTED_PROXIES` | | `172.18.0.2,fd00:1234::/64` | Proxy IP addresses or CIDRs allowed to forward the request host |
| `TRUSTED_PROXIES` | | `172.18.0.2,fd00:1234::/64` | Proxy IP addresses or CIDRs allowed to forward request metadata |
| `WG_INTERFACE` | `wg0` | `wg1` | Name of the WireGuard interface |
/// note | IPv6 Caveats
Disabling IPv6 will disable the creation of the default IPv6 firewall rules and won't add a IPv6 address to the interface and clients.
You will however still see a IPv6 address in the Web UI, but it won't be used.
This option can be removed in the future, as more devices support IPv6.
///
## Trusted Proxies
@ -25,17 +36,18 @@ environment:
```
Only add the source addresses used by your reverse proxy. wg-easy only uses
`X-Forwarded-Host` when the request comes from one of these addresses. The
`X-Forwarded-Host` and `X-Forwarded-For` when the request comes from one of
these addresses. The forwarded client address is included in security logs so
that tools such as CrowdSec can identify failed authentication attempts. The
request protocol remains controlled by `INSECURE`. Invalid addresses prevent
wg-easy from starting so that configuration errors are not silently ignored.
Restart the container after changing this setting.
/// note | IPv6 Caveats
## WireGuard Interface
Disabling IPv6 will disable the creation of the default IPv6 firewall rules and won't add a IPv6 address to the interface and clients.
Set `WG_INTERFACE` to specify a custom interface name, useful if you are running multiple wg-easy instances on the same host with `network_mode: host`:
You will however still see a IPv6 address in the Web UI, but it won't be used.
This option can be removed in the future, as more devices support IPv6.
///
```yaml
environment:
- WG_INTERFACE=wg1
```

View file

@ -127,13 +127,13 @@ networks:
**_PostUp_**
```shell
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -t nat -A PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination 10.42.42.43; iptables -t nat -A PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43; ip6tables -t nat -A PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; ip6tables -t nat -A PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -t nat -A PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination 10.42.42.43; iptables -t nat -A PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43; ip6tables -t nat -A PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; ip6tables -t nat -A PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; iptables -A FORWARD -i {{interface}} -j ACCEPT; iptables -A FORWARD -o {{interface}} -j ACCEPT; ip6tables -A FORWARD -i {{interface}} -j ACCEPT; ip6tables -A FORWARD -o {{interface}} -j ACCEPT; iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE;
```
**_PostDown_**
```shell
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; iptables -t nat -D PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; iptables -t nat -D PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; ip6tables -t nat -D PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; ip6tables -t nat -D PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; iptables -D FORWARD -i wg0 -j ACCEPT || true; iptables -D FORWARD -o wg0 -j ACCEPT || true; ip6tables -D FORWARD -i wg0 -j ACCEPT || true; ip6tables -D FORWARD -o wg0 -j ACCEPT || true; iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE || true; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE || true;
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; iptables -t nat -D PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; iptables -t nat -D PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; ip6tables -t nat -D PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; ip6tables -t nat -D PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; iptables -D FORWARD -i {{interface}} -j ACCEPT || true; iptables -D FORWARD -o {{interface}} -j ACCEPT || true; ip6tables -D FORWARD -i {{interface}} -j ACCEPT || true; ip6tables -D FORWARD -o {{interface}} -j ACCEPT || true; iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE || true; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE || true;
```
3. Restart `wg-easy` to apply changes:

View file

@ -101,3 +101,17 @@ sudo docker compose up -d
```
You can now access `wg-easy` at [https://wg-easy.example.com](https://wg-easy.example.com) and start the setup.
## Rate Limiting
wg-easy does not implement rate limiting. Configure Caddy, a Caddy rate
limiting module, or an external security tool such as CrowdSec to limit requests
to these paths:
- `/api/auth/password`
- `/api/auth/verify-2fa`
- `/cnf/*`
Choose limits appropriate for your deployment. When using wg-easy security logs
for detection, configure [`TRUSTED_PROXIES`](../../advanced/config/optional-config.md#trusted-proxies)
so that logged events contain the original client IP address.

View file

@ -88,13 +88,13 @@ In the Admin Panel of your WireGuard server, go to the `Hooks` tab and add the f
1. PostUp
```shell
nft add table inet wg_table; nft add chain inet wg_table prerouting { type nat hook prerouting priority 100 \; }; nft add chain inet wg_table postrouting { type nat hook postrouting priority 100 \; }; nft add rule inet wg_table postrouting ip saddr {{ipv4Cidr}} oifname {{device}} masquerade; nft add rule inet wg_table postrouting ip6 saddr {{ipv6Cidr}} oifname {{device}} masquerade; nft add chain inet wg_table input { type filter hook input priority 0 \; policy accept \; }; nft add rule inet wg_table input udp dport {{port}} accept; nft add rule inet wg_table input tcp dport {{uiPort}} accept; nft add chain inet wg_table forward { type filter hook forward priority 0 \; policy accept \; }; nft add rule inet wg_table forward iifname "wg0" accept; nft add rule inet wg_table forward oifname "wg0" accept;
nft add table inet wg_table_{{interface}}; nft add chain inet wg_table_{{interface}} prerouting { type nat hook prerouting priority 100 \; }; nft add chain inet wg_table_{{interface}} postrouting { type nat hook postrouting priority 100 \; }; nft add rule inet wg_table_{{interface}} postrouting ip saddr {{ipv4Cidr}} oifname {{device}} masquerade; nft add rule inet wg_table_{{interface}} postrouting ip6 saddr {{ipv6Cidr}} oifname {{device}} masquerade; nft add chain inet wg_table_{{interface}} input { type filter hook input priority 0 \; policy accept \; }; nft add rule inet wg_table_{{interface}} input udp dport {{port}} accept; nft add rule inet wg_table_{{interface}} input tcp dport {{uiPort}} accept; nft add chain inet wg_table_{{interface}} forward { type filter hook forward priority 0 \; policy accept \; }; nft add rule inet wg_table_{{interface}} forward iifname "{{interface}}" accept; nft add rule inet wg_table_{{interface}} forward oifname "{{interface}}" accept;
```
2. PostDown
```shell
nft delete table inet wg_table
nft delete table inet wg_table_{{interface}}
```
If you don't have iptables loaded on your server, you could see many errors in the logs or in the UI. You can ignore them.

View file

@ -86,13 +86,13 @@ Then go to Admin → Hooks and add:
PostUp
```shell
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i {{interface}} -j ACCEPT; iptables -A FORWARD -o {{interface}} -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i {{interface}} -j ACCEPT; ip6tables -A FORWARD -o {{interface}} -j ACCEPT
```
PostDown
```shell
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i {{interface}} -j ACCEPT; iptables -D FORWARD -o {{interface}} -j ACCEPT; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i {{interface}} -j ACCEPT; ip6tables -D FORWARD -o {{interface}} -j ACCEPT
```
/// warning | Important: When using nftables use the following hooks instead.
@ -100,13 +100,13 @@ iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD
PostUp
```shell
nft add chain ip filter WG_EASY; nft add rule ip filter DOCKER-USER jump WG_EASY; nft add rule ip filter WG_EASY iifname {{device}} accept; nft add rule ip filter WG_EASY oifname {{device}} accept; nft add chain ip6 filter WG_EASY; nft add rule ip6 filter DOCKER-USER jump WG_EASY; nft add rule ip6 filter WG_EASY iifname {{device}} accept; nft add rule ip6 filter WG_EASY oifname {{device}} accept;
nft add chain ip filter WG_EASY_{{interface}}; nft add rule ip filter DOCKER-USER jump WG_EASY_{{interface}}; nft add rule ip filter WG_EASY_{{interface}} iifname {{device}} accept; nft add rule ip filter WG_EASY_{{interface}} oifname {{device}} accept; nft add chain ip6 filter WG_EASY_{{interface}}; nft add rule ip6 filter DOCKER-USER jump WG_EASY_{{interface}}; nft add rule ip6 filter WG_EASY_{{interface}} iifname {{device}} accept; nft add rule ip6 filter WG_EASY_{{interface}} oifname {{device}} accept;
```
PostDown
```shell
nft delete rule ip filter DOCKER-USER handle $(nft -a list chain ip filter DOCKER-USER | awk '/jump WG_EASY/ {print $NF}'); nft flush chain ip filter WG_EASY; nft delete chain ip filter WG_EASY; nft delete rule ip6 filter DOCKER-USER handle $(nft -a list chain ip6 filter DOCKER-USER | awk '/jump WG_EASY/ {print $NF}'); nft flush chain ip6 filter WG_EASY; nft delete chain ip6 filter WG_EASY
nft delete rule ip filter DOCKER-USER handle $(nft -a list chain ip filter DOCKER-USER | awk '/jump WG_EASY_{{interface}}/ {print $NF}'); nft flush chain ip filter WG_EASY_{{interface}}; nft delete chain ip filter WG_EASY_{{interface}}; nft delete rule ip6 filter DOCKER-USER handle $(nft -a list chain ip6 filter DOCKER-USER | awk '/jump WG_EASY_{{interface}}/ {print $NF}'); nft flush chain ip6 filter WG_EASY_{{interface}}; nft delete chain ip6 filter WG_EASY_{{interface}}
```
///

View file

@ -22,7 +22,7 @@ File: `/etc/docker/containers/traefik/docker-compose.yml`
```yaml
services:
traefik:
image: traefik:3.3
image: traefik:3.7
container_name: traefik
restart: unless-stopped
ports:
@ -184,3 +184,36 @@ sudo docker compose up -d
```
You can now access `wg-easy` at `https://wg-easy.$example.com$` and start the setup.
## Rate Limiting
wg-easy does not implement rate limiting. Configure a Traefik rate limit
middleware or an external security tool such as CrowdSec to limit requests to
these paths:
- `/api/auth/password`
- `/api/auth/verify-2fa`
- `/cnf/*`
Choose limits appropriate for your deployment. When using wg-easy security logs
for detection, configure [`TRUSTED_PROXIES`](../../advanced/config/optional-config.md#trusted-proxies)
so that logged events contain the original client IP address.
For example, add these labels to the `wg-easy` service to allow an average of
five requests per minute per client IP, with a burst of five requests:
```yaml
labels:
- 'traefik.http.routers.wg-easy-rate-limit.rule=Host(`wg-easy.$example.com$`) && (Path(`/api/auth/password`) || Path(`/api/auth/verify-2fa`) || PathPrefix(`/cnf/`))'
- 'traefik.http.routers.wg-easy-rate-limit.entrypoints=websecure'
- 'traefik.http.routers.wg-easy-rate-limit.service=wg-easy'
- 'traefik.http.routers.wg-easy-rate-limit.middlewares=wg-easy-rate-limit'
- 'traefik.http.routers.wg-easy-rate-limit.priority=100'
- 'traefik.http.middlewares.wg-easy-rate-limit.ratelimit.average=5'
- 'traefik.http.middlewares.wg-easy-rate-limit.ratelimit.period=1m'
- 'traefik.http.middlewares.wg-easy-rate-limit.ratelimit.burst=5'
```
The dedicated router applies the [rate limit middleware](https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/middlewares/ratelimit/)
only to the sensitive paths. Adjust `average`, `period`, and `burst` for your
deployment.

View file

@ -1,2 +1,2 @@
zensical==0.0.57
zensical==0.0.62
git+https://github.com/squidfunk/mike.git

View file

@ -13,5 +13,5 @@
"devDependencies": {
"prettier": "^3.9.6"
},
"packageManager": "pnpm@11.22.0"
"packageManager": "pnpm@12.4.1"
}

158
pnpm-lock.yaml generated
View file

@ -1,3 +1,161 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.4.1
version: 12.4.1
packages:
'@pnpm/exe.android-arm64@12.4.1':
resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.4.1':
resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.4.1':
resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.4.1':
resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.4.1':
resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.4.1':
resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.4.1':
resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.4.1':
resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.4.1':
resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.4.1':
resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.4.1':
resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.4.1':
resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.4.1':
resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.4.1':
resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==}
cpu: [x64]
os: [win32]
pnpm@12.4.1:
resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.4.1':
optional: true
'@pnpm/exe.android-x64@12.4.1':
optional: true
'@pnpm/exe.darwin-arm64@12.4.1':
optional: true
'@pnpm/exe.darwin-x64@12.4.1':
optional: true
'@pnpm/exe.freebsd-x64@12.4.1':
optional: true
'@pnpm/exe.linux-arm64-musl@12.4.1':
optional: true
'@pnpm/exe.linux-arm64@12.4.1':
optional: true
'@pnpm/exe.linux-ppc64@12.4.1':
optional: true
'@pnpm/exe.linux-riscv64@12.4.1':
optional: true
'@pnpm/exe.linux-s390x@12.4.1':
optional: true
'@pnpm/exe.linux-x64-musl@12.4.1':
optional: true
'@pnpm/exe.linux-x64@12.4.1':
optional: true
'@pnpm/exe.win32-arm64@12.4.1':
optional: true
'@pnpm/exe.win32-x64@12.4.1':
optional: true
pnpm@12.4.1:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.4.1
'@pnpm/exe.android-x64': 12.4.1
'@pnpm/exe.darwin-arm64': 12.4.1
'@pnpm/exe.darwin-x64': 12.4.1
'@pnpm/exe.freebsd-x64': 12.4.1
'@pnpm/exe.linux-arm64': 12.4.1
'@pnpm/exe.linux-arm64-musl': 12.4.1
'@pnpm/exe.linux-ppc64': 12.4.1
'@pnpm/exe.linux-riscv64': 12.4.1
'@pnpm/exe.linux-s390x': 12.4.1
'@pnpm/exe.linux-x64': 12.4.1
'@pnpm/exe.linux-x64-musl': 12.4.1
'@pnpm/exe.win32-arm64': 12.4.1
'@pnpm/exe.win32-x64': 12.4.1
---
lockfileVersion: '9.0'
settings:

View file

@ -1 +1 @@
setups.@nuxt/test-utils="4.1.0"
setups.@nuxt/test-utils="4.3.2"

View file

@ -0,0 +1,48 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<div class="my-auto flex items-center gap-3">
<template v-if="data === null">
<span class="text-gray-500 dark:text-neutral-300">
{{ $t('form.nullNotSet') }}
</span>
<BaseSecondaryButton
type="button"
class="rounded-lg"
@click="data = false"
>
{{ $t('form.set') }}
</BaseSecondaryButton>
</template>
<template v-else>
<BaseSwitch :id="id" v-model="enabled" />
<BaseSecondaryButton
type="button"
class="rounded-lg"
@click="data = null"
>
{{ $t('form.unset') }}
</BaseSecondaryButton>
</template>
</div>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<boolean | null>();
/** `null` is rendered as its own state, so the switch is only ever a boolean */
const enabled = computed({
get: () => data.value === true,
set: (value: boolean) => {
data.value = value;
},
});
</script>

View file

@ -129,6 +129,63 @@
:label="$t('awg.i5Label')"
:description="$t('awg.i5Description')"
/>
<FormNullTextField
id="headerProtectionKey"
v-model="data.headerProtectionKey"
:label="$t('awg.headerProtectionKeyLabel')"
:description="$t('awg.headerProtectionKeyDescription')"
/>
<div class="col-span-full text-sm">{{ $t('awg.rangeNote') }}</div>
<FormNullTextField
id="contentPaddingAddition"
v-model="data.contentPaddingAddition"
:label="$t('awg.contentPaddingAdditionLabel')"
:description="$t('awg.contentPaddingAdditionDescription')"
/>
<FormNullTextField
id="rekeyAfterTime"
v-model="data.rekeyAfterTime"
:label="$t('awg.rekeyAfterTimeLabel')"
:description="$t('awg.rekeyAfterTimeDescription')"
/>
<FormNullTextField
id="rekeyTimeout"
v-model="data.rekeyTimeout"
:label="$t('awg.rekeyTimeoutLabel')"
:description="$t('awg.rekeyTimeoutDescription')"
/>
<FormNullTextField
id="rejectAfterTime"
v-model="data.rejectAfterTime"
:label="$t('awg.rejectAfterTimeLabel')"
:description="$t('awg.rejectAfterTimeDescription')"
/>
<FormNullTextField
id="keepaliveTimeout"
v-model="data.keepaliveTimeout"
:label="$t('awg.keepaliveTimeoutLabel')"
:description="$t('awg.keepaliveTimeoutDescription')"
/>
<FormNullTextField
id="maxHandshakeAttempts"
v-model="data.maxHandshakeAttempts"
:label="$t('awg.maxHandshakeAttemptsLabel')"
:description="$t('awg.maxHandshakeAttemptsDescription')"
/>
<FormNullSwitchField
id="randomTrailers"
v-model="data.randomTrailers"
:label="$t('awg.randomTrailersLabel')"
:description="$t('awg.randomTrailersDescription')"
/>
<FormNullSwitchField
id="disableCookies"
v-model="data.disableCookies"
:label="$t('awg.disableCookiesLabel')"
:description="$t('awg.disableCookiesDescription')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('admin.interface.firewall') }}</FormHeading>

View file

@ -144,6 +144,53 @@
:label="$t('awg.i5Label')"
:description="$t('awg.i5Description')"
/>
<div class="col-span-full text-sm">
{{ $t('awg.rangeNote') }}
</div>
<FormNullTextField
id="contentPaddingAddition"
v-model="data.contentPaddingAddition"
:label="$t('awg.contentPaddingAdditionLabel')"
:description="$t('awg.contentPaddingAdditionDescription')"
/>
<FormNullTextField
id="rekeyAfterTime"
v-model="data.rekeyAfterTime"
:label="$t('awg.rekeyAfterTimeLabel')"
:description="$t('awg.rekeyAfterTimeDescription')"
/>
<FormNullTextField
id="rekeyTimeout"
v-model="data.rekeyTimeout"
:label="$t('awg.rekeyTimeoutLabel')"
:description="$t('awg.rekeyTimeoutDescription')"
/>
<FormNullTextField
id="rejectAfterTime"
v-model="data.rejectAfterTime"
:label="$t('awg.rejectAfterTimeLabel')"
:description="$t('awg.rejectAfterTimeDescription')"
/>
<FormNullTextField
id="keepaliveTimeout"
v-model="data.keepaliveTimeout"
:label="$t('awg.keepaliveTimeoutLabel')"
:description="$t('awg.keepaliveTimeoutDescription')"
/>
<FormNullTextField
id="maxHandshakeAttempts"
v-model="data.maxHandshakeAttempts"
:label="$t('awg.maxHandshakeAttemptsLabel')"
:description="$t('awg.maxHandshakeAttemptsDescription')"
/>
<FormNullSwitchField
id="disableCookies"
v-model="data.disableCookies"
:label="$t('awg.disableCookiesLabel')"
:description="$t('awg.disableCookiesDescription')"
/>
</FormGroup>
<FormGroup>
<FormHeading :description="$t('client.hooksDescription')">

View file

@ -3,6 +3,7 @@ import { consola } from 'consola';
import { eq } from 'drizzle-orm';
import { db, schema } from '#cli/db';
import { WG_ENV } from '#server/utils/config';
import { wg } from '#server/utils/wgHelper';
import { encodeQRCodeTerm } from '#server/utils/qr';
@ -34,7 +35,7 @@ export default defineCommand({
consola.info('Generating QR code for client...');
const wgInterface = await db.query.wgInterface.findFirst({
where: eq(schema.wgInterface.name, 'wg0'),
where: eq(schema.wgInterface.name, WG_ENV.WG_INTERFACE),
});
if (!wgInterface) {
consola.error('WireGuard interface not found');
@ -42,7 +43,7 @@ export default defineCommand({
}
const userConfig = await db.query.userConfig.findFirst({
where: eq(schema.userConfig.id, 'wg0'),
where: eq(schema.userConfig.id, wgInterface.name),
});
if (!userConfig) {
consola.error('User config not found');

View file

@ -22,6 +22,8 @@ import hi from './locales/hi.json';
import gl from './locales/gl.json';
import cs from './locales/cs.json';
import vi from './locales/vi.json';
import sv from './locales/sv.json';
import az from './locales/az.json';
export default defineI18nConfig(() => ({
legacy: false,
@ -51,5 +53,7 @@ export default defineI18nConfig(() => ({
gl,
cs,
vi,
sv,
az,
},
}));

334
src/i18n/locales/az.json Normal file
View file

@ -0,0 +1,334 @@
{
"pages": {
"me": "Hesab",
"clients": "Müştərilər",
"admin": {
"panel": "İdarəetmə paneli",
"general": "Ümumi",
"config": "Konfiqurasiya",
"interface": "İnterfeys",
"hooks": "Hook-lar"
}
},
"user": {
"email": "E-poçt"
},
"me": {
"currentPassword": "Cari parol",
"enable2fa": "İkifaktorlu doğrulamanı aktivləşdir",
"enable2faDesc": "Doğrulama tətbiqinizlə QR kodu skan edin və ya açarı əl ilə daxil edin.",
"2faKey": "TOTP açarı",
"2faCodeDesc": "Doğrulama tətbiqinizdəki kodu daxil edin.",
"disable2fa": "İkifaktorlu doğrulamanı söndür",
"disable2faDesc": "İkifaktorlu doğrulamanı söndürmək üçün parolunuzu daxil edin.",
"linkOauth": "Hesabını xarici provayderlə əlaqələndir",
"unlinkOauth": "Əlaqəni kəs",
"linkedWith": "{provider} ilə əlaqələndirilib",
"providerDisabled": "Hazırda hesabınızla əlaqələndirilmiş provayder aktiv deyil"
},
"general": {
"name": "Ad",
"username": "İstifadəçi adı",
"password": "Parol",
"newPassword": "Yeni parol",
"updatePassword": "Parolu yenilə",
"addPassword": "Parol əlavə et",
"mtu": "MTU",
"allowedIps": "İcazə verilən IP-lər",
"dns": "DNS",
"persistentKeepalive": "Daimi keepalive",
"logout": "Çıxış",
"continue": "Davam et",
"host": "Host",
"port": "Port",
"yes": "Bəli",
"no": "Xeyr",
"confirmPassword": "Parolu təsdiqlə",
"loading": "Yüklənir...",
"2fa": "İkifaktorlu doğrulama",
"2faCode": "TOTP kodu",
"externalAuth": "Xarici doğrulama"
},
"setup": {
"welcome": "wg-easy proqramının ilkin quraşdırılmasına xoş gəlmisiniz",
"welcomeDesc": "İstənilən Linux hostunda WireGuard quraşdırmağın və idarə etməyin ən asan yolunu tapmısınız",
"existingSetup": "Mövcud quraşdırmanız varmı?",
"createAdminDesc": "Əvvəlcə inzibatçı üçün istifadəçi adı və güclü, təhlükəsiz parol daxil edin. Bu məlumatlar idarəetmə panelinizə daxil olmaq üçün istifadə ediləcək.",
"setupConfigDesc": "Host və port məlumatlarını daxil edin. Bu məlumatlar müştərilərin cihazlarında WireGuard quraşdırılarkən müştəri konfiqurasiyası üçün istifadə ediləcək.",
"setupMigrationDesc": "Əvvəlki wg-easy versiyasındakı məlumatlarınızı yeni quraşdırmaya köçürmək istəyirsinizsə, ehtiyat nüsxə faylını təqdim edin.",
"upload": "Yüklə",
"migration": "Ehtiyat nüsxəni bərpa et:",
"createAccount": "Hesab yarat",
"successful": "Quraşdırma uğurla tamamlandı",
"hostDesc": "Müştərilərin qoşulacağı ictimai host adı",
"portDesc": "Müştərilərin qoşulacağı və WireGuard-ın dinləyəcəyi ictimai UDP portu"
},
"update": {
"updateAvailable": "Yeniləmə mövcuddur!",
"update": "Yenilə"
},
"theme": {
"dark": "Tünd mövzu",
"light": "Açıq mövzu",
"system": "Sistem mövzusu"
},
"layout": {
"toggleCharts": "Qrafikləri göstər/gizlət",
"donate": "İanə et"
},
"login": {
"signIn": "Daxil ol",
"signInWith": "{provider} ilə daxil ol",
"or": "və ya",
"rememberMe": "Məni xatırla",
"rememberMeDesc": "Brauzeri bağladıqdan sonra da hesabınızda qalın",
"insecure": "Təhlükəsiz olmayan qoşulma ilə daxil ola bilməzsiniz. HTTPS istifadə edin.",
"2faRequired": "İkifaktorlu doğrulama tələb olunur",
"2faWrong": "İkifaktorlu doğrulama kodu yanlışdır",
"loginExpired": "Giriş müddəti bitib. Yenidən cəhd edin"
},
"client": {
"empty": "Hələ müştəri yoxdur.",
"newShort": "Yeni",
"sort": "Sırala",
"create": "Müştəri yarat",
"created": "Müştəri yaradıldı",
"new": "Yeni müştəri",
"name": "Ad",
"expireDate": "Bitmə tarixi",
"expireDateDesc": "Müştərinin söndürüləcəyi tarix. Daimi olması üçün boş saxlayın",
"delete": "Sil",
"deleteClient": "Müştərini sil",
"deleteDialog": "{name} adlı müştərini silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
"enabled": "Aktivdir",
"address": "Ünvan",
"serverAllowedIps": "Serverdə icazə verilən IP-lər",
"otlDesc": "Qısa birdəfəlik keçid yarat",
"permanent": "Daimi",
"createdOn": "Yaradılma tarixi: {date}",
"lastSeen": "Son görünmə tarixi: {date}",
"totalDownload": "Cəmi endirilən: {total}",
"totalUpload": "Cəmi yüklənən: {total}",
"newClient": "Yeni müştəri",
"disableClient": "Müştərini söndür",
"enableClient": "Müştərini aktivləşdir",
"noPrivKey": "Bu müştərinin məlum məxfi açarı yoxdur. Konfiqurasiya yaratmaq mümkün deyil.",
"showQR": "QR kodu göstər",
"downloadConfig": "Konfiqurasiyanı endir",
"allowedIpsDesc": "VPN vasitəsilə yönləndiriləcək IP-lər (ümumi konfiqurasiyanı əvəz edir)",
"serverAllowedIpsDesc": "Serverin müştəriyə yönləndirəcəyi IP-lər",
"mtuDesc": "VPN tuneli üçün maksimum ötürmə vahidini (paket ölçüsünü) təyin edir",
"persistentKeepaliveDesc": "Keepalive paketləri üçün intervalı (saniyə ilə) təyin edir. 0 onu söndürür",
"hooks": "Hook-lar",
"hooksDescription": "Hook-lar yalnız wg-quick ilə işləyir",
"hooksLeaveEmpty": "Yalnız wg-quick üçündür. Əks halda boş saxlayın",
"dnsDesc": "Müştərilərin istifadə edəcəyi DNS server (ümumi konfiqurasiyanı əvəz edir)",
"notConnected": "Müştəri qoşulmayıb",
"endpoint": "Ünvan",
"endpointDesc": "WireGuard qoşulmasının qurulduğu müştərinin IP ünvanı",
"search": "Müştəriləri axtar...",
"config": "Konfiqurasiya",
"viewConfig": "Konfiqurasiyaya bax",
"firewallIps": "Firewall üçün icazə verilən IP-lər",
"firewallIpsDesc": "Bu müştərinin çıxışına icazə verilən təyinat IP-ləri/CIDR-ləri (server tərəfində tətbiq olunur). İcazə verilən IP-lərdən istifadə etmək üçün boş saxlayın. İstəyə bağlı port və protokol filtrləməsi dəstəklənir. Sintaksis üçün sənədlərə baxın.",
"downloadPng": "PNG endir",
"copyPng": "PNG kopyala"
},
"dialog": {
"change": "Dəyişdir",
"cancel": "Ləğv et",
"create": "Yarat"
},
"toast": {
"success": "Uğurlu",
"saved": "Yadda saxlanıldı",
"error": "Xəta",
"unknown": "Naməlum xəta. Ətraflı məlumat üçün konsola baxın"
},
"form": {
"actions": "Əməliyyatlar",
"save": "Yadda saxla",
"revert": "Geri qaytar",
"sectionGeneral": "Ümumi",
"sectionAdvanced": "Əlavə",
"noItems": "Element yoxdur",
"nullNoItems": "Element yoxdur. Ümumi konfiqurasiya istifadə olunur",
"nullNotSet": "Təyin edilməyib",
"set": "Təyin et",
"unset": "Təyinatı sil",
"add": "Əlavə et"
},
"admin": {
"general": {
"sessionTimeout": "Sessiya vaxtı bitməsi",
"sessionTimeoutDesc": "Məni xatırla seçimi üçün sessiya müddəti (saniyə)",
"metrics": "Metrikalar",
"metricsPassword": "Parol",
"metricsPasswordDesc": "Metrika ünvanı üçün Bearer parolu (parol və ya argon2 heşi)",
"json": "JSON",
"jsonDesc": "JSON formatında metrikalar üçün yol",
"prometheus": "Prometheus",
"prometheusDesc": "Prometheus metrikaları üçün yol"
},
"config": {
"connection": "Qoşulma",
"hostDesc": "Müştərilərin qoşulacağı ictimai host adı (konfiqurasiyanı etibarsız edir)",
"portDesc": "Müştərilərin qoşulacağı ictimai UDP portu (konfiqurasiyanı etibarsız edir, interfeys portunu da dəyişdirməyiniz lazım ola bilər)",
"allowedIpsDesc": "Müştərilərin istifadə edəcəyi icazə verilən IP-lər (ümumi konfiqurasiya)",
"dnsDesc": "Müştərilərin istifadə edəcəyi DNS server (ümumi konfiqurasiya)",
"mtuDesc": "Müştərilərin istifadə edəcəyi MTU (yalnız yeni müştərilər üçün)",
"persistentKeepaliveDesc": "Serverə keepalive paketlərinin göndərilmə intervalı, saniyə ilə. 0 = deaktiv (yalnız yeni müştərilər üçün)",
"suggest": "Təklif et",
"suggestDesc": "Host sahəsi üçün IP ünvanı və ya host adı seçin"
},
"interface": {
"cidrSuccess": "CIDR dəyişdirildi",
"device": "Cihaz",
"deviceDesc": "WireGuard trafikinin yönləndiriləcəyi Ethernet cihazı",
"routingTable": "Marşrutlaşdırma cədvəli",
"routingTableDesc": "WireGuard marşrutları üçün marşrutlaşdırma cədvəli. auto, off və ya cədvəl nömrəsi təyin edin.",
"mtuDesc": "WireGuard-ın istifadə edəcəyi MTU",
"portDesc": "WireGuard-ın dinləyəcəyi UDP portu (konfiqurasiya portunu da dəyişdirməyiniz lazım ola bilər)",
"changeCidr": "CIDR dəyişdir",
"restart": "İnterfeysi yenidən başlat",
"restartDesc": "WireGuard interfeysini yenidən başladın",
"restartWarn": "İnterfeysi yenidən başlatmaq istədiyinizə əminsiniz? Bu, bütün müştərilərin qoşulmasını kəsəcək.",
"restartSuccess": "İnterfeys yenidən başladıldı",
"firewall": "Trafikin filtrlənməsi",
"firewallEnabled": "Hər müştəri üçün firewall aktivləşdir",
"firewallEnabledDesc": "iptables vasitəsilə müştəri trafikini müəyyən təyinat IP-ləri ilə məhdudlaşdırın. Aktiv olduqda hər müştəri üçün icazə verilən təyinat ünvanları konfiqurasiya edilə bilər."
},
"introText": "İdarəetmə panelinə xoş gəlmisiniz.\n\nBurada ümumi parametrləri, konfiqurasiyanı, interfeys parametrlərini və hook-ları idarə edə bilərsiniz.\n\nBaşlamaq üçün yan paneldəki bölmələrdən birini seçin."
},
"zod": {
"generic": {
"required": "{field} tələb olunur",
"validNumber": "{field} etibarlı ədəd olmalıdır",
"validNumberRange": "{field} etibarlı ədəd və ya ədəd aralığı olmalıdır",
"validString": "{field} etibarlı mətn sətri olmalıdır",
"validBoolean": "{field} etibarlı məntiqi dəyər olmalıdır",
"validArray": "{field} etibarlı massiv olmalıdır",
"stringMin": "{field} ən azı {min} simvoldan ibarət olmalıdır",
"stringMax": "{field} ən çoxu {max} simvoldan ibarət olmalıdır",
"numberMin": "{field} ən azı {min} olmalıdır",
"numberMax": "{field} ən çoxu {max} olmalıdır"
},
"client": {
"id": "Müştəri ID-si",
"name": "Ad",
"expiresAt": "Bitmə vaxtı",
"address4": "IPv4 ünvanı",
"address6": "IPv6 ünvanı",
"serverAllowedIps": "Serverdə icazə verilən IP-lər",
"firewallIps": "Firewall üçün icazə verilən IP-lər",
"firewallIpsInvalid": "Firewall IP qeydi etibarsızdır. Dəstəklənən sintaksis üçün sənədlərə baxın."
},
"user": {
"username": "İstifadəçi adı",
"password": "Parol",
"remember": "Xatırla",
"name": "Ad",
"email": "E-poçt",
"emailInvalid": "E-poçt ünvanı etibarlı olmalıdır",
"passwordMatch": "Parollar uyğun gəlməlidir",
"totpEnable": "TOTP aktivləşdir",
"totpEnableTrue": "TOTP aktivləşdir seçimi aktiv olmalıdır",
"totpCode": "TOTP kodu"
},
"userConfig": {
"host": "Host"
},
"general": {
"sessionTimeout": "Sessiya vaxtı bitməsi",
"metricsEnabled": "Metrikalar",
"metricsPassword": "Metrika parolu"
},
"interface": {
"cidr": "CIDR",
"device": "Cihaz",
"cidrValid": "CIDR etibarlı olmalıdır",
"routingTable": "Marşrutlaşdırma cədvəli auto, off və ya cədvəl nömrəsi olmalıdır",
"headerProtectionJunkSize": "Başlığı qoruma açarı təyin edildikdə S1-S4 saxta məlumat ölçüləri ən azı 12 olmalıdır"
},
"otl": "Birdəfəlik keçid",
"stringMalformed": "Mətn sətrinin formatı yanlışdır",
"keyMalformed": "Açar base64 ilə kodlaşdırılmış 32 baytlıq açar olmalıdır",
"body": "Sorğunun gövdəsi etibarlı obyekt olmalıdır",
"hook": "Hook",
"enabled": "Aktivdir",
"mtu": "MTU",
"port": "Port",
"persistentKeepalive": "Daimi keepalive",
"address": "IP ünvanı",
"dns": "DNS",
"allowedIps": "İcazə verilən IP-lər",
"file": "Fayl"
},
"hooks": {
"preUp": "PreUp",
"postUp": "PostUp",
"preDown": "PreDown",
"postDown": "PostDown"
},
"copy": {
"notSupported": "Kopyalama dəstəklənmir",
"copied": "Kopyalandı!",
"failed": "Kopyalama alınmadı",
"copy": "Kopyala"
},
"awg": {
"jCLabel": "Saxta paketlərin sayı (Jc)",
"jCDescription": "Göndəriləcək saxta paketlərin sayı (1-128, tövsiyə olunur: 4-12)",
"jMinLabel": "Saxta paketlərin minimum ölçüsü (Jmin)",
"jMinDescription": "Saxta paketlərin minimum ölçüsü (0-1279*, tövsiyə olunur: 8, < Jmax olmalıdır)",
"jMaxLabel": "Saxta paketlərin maksimum ölçüsü (Jmax)",
"jMaxDescription": "Saxta paketlərin maksimum ölçüsü (1-1280*, tövsiyə olunur: 80, > Jmin olmalıdır)",
"s1Label": "Başlatma paketindəki saxta məlumatların ölçüsü (S1)",
"s1Description": "Başlatma paketindəki saxta məlumatların ölçüsü (0-1132[1280* - 148 = 1132], tövsiyə olunur: 15-150, S1+56 ≠ S2)",
"s2Label": "Cavab paketindəki saxta məlumatların ölçüsü (S2)",
"s2Description": "Cavab paketindəki saxta məlumatların ölçüsü (0-1188[1280* - 92 = 1188], tövsiyə olunur: 15-150)",
"s3Label": "Kuki cavab paketindəki saxta məlumatların ölçüsü (S3)",
"s3Description": "Kuki cavab paketindəki saxta məlumatların ölçüsü",
"s4Label": "Nəql paketindəki saxta məlumatların ölçüsü (S4)",
"s4Description": "Nəql paketindəki saxta məlumatların ölçüsü",
"h1Label": "Başlatma paketinin xüsusi başlığı (H1)",
"h1Description": "Başlatma paketinin başlıq dəyəri və ya aralığı (X və ya X-Y, burada X<Y. Minimum 5, maksimum 2147483647. Dəyər və ya aralıq digər başlıqlarla üst-üstə düşməməlidir)",
"h2Label": "Cavab paketinin xüsusi başlığı (H2)",
"h2Description": "Cavab paketinin başlıq dəyəri və ya aralığı (X və ya X-Y, burada X<Y. Minimum 5, maksimum 2147483647. Dəyər və ya aralıq digər başlıqlarla üst-üstə düşməməlidir)",
"h3Label": "Kuki cavab paketinin xüsusi başlığı (H3)",
"h3Description": "Kuki cavab paketinin başlıq dəyəri və ya aralığı (X və ya X-Y, burada X<Y. Minimum 5, maksimum 2147483647. Dəyər və ya aralıq digər başlıqlarla üst-üstə düşməməlidir)",
"h4Label": "Nəql paketinin xüsusi başlığı (H4)",
"h4Description": "Nəql paketinin başlıq dəyəri və ya aralığı (X və ya X-Y, burada X<Y. Minimum 5, maksimum 2147483647. Dəyər və ya aralıq digər başlıqlarla üst-üstə düşməməlidir)",
"i1Label": "Xüsusi saxta paket 1 (I1)",
"i1Description": "Protokolu təqlid edən paket, onaltılıq formatda: <b 0x...>",
"i2Label": "Xüsusi saxta paket 2 (I2)",
"i2Description": "Protokolu təqlid edən paket, onaltılıq formatda: <b 0x...>",
"i3Label": "Xüsusi saxta paket 3 (I3)",
"i3Description": "Protokolu təqlid edən paket, onaltılıq formatda: <b 0x...>",
"i4Label": "Xüsusi saxta paket 4 (I4)",
"i4Description": "Protokolu təqlid edən paket, onaltılıq formatda: <b 0x...>",
"i5Label": "Xüsusi saxta paket 5 (I5)",
"i5Description": "Protokolu təqlid edən paket, onaltılıq formatda: <b 0x...>",
"rangeNote": "Aşağıdakı parametrlər tək ədəd və ya sərhəd dəyərləri daxil olan aralıq (0-65535) qəbul edir, məsələn, 30 və ya 30-90",
"headerProtectionKeyLabel": "Başlığı qoruma açarı",
"headerProtectionKeyDescription": "Paket başlıqlarını şifrələmək üçün istifadə olunan 32 baytlıq base64 açarı. Hər iki tərəfdə eyni olmalıdır, buna görə hər müştəriyə kopyalanır. S1-S4 ölçülərinin ən azı 12 olmasını tələb edir",
"contentPaddingAdditionLabel": "Məzmun üçün doldurma baytlarının əlavə edilməsi",
"contentPaddingAdditionDescription": "Şifrələmədən əvvəl nəql paketlərinin sonuna əlavə olunan təsadüfi sayda bayt",
"rekeyAfterTimeLabel": "Açarın yenilənməsinə qədər müddət",
"rekeyAfterTimeDescription": "Sessiyanın yenidən razılaşdırılmasına qədər saniyə sayı (WireGuard standartı: 120)",
"rekeyTimeoutLabel": "Açarın yenilənməsi üçün gözləmə müddəti",
"rekeyTimeoutDescription": "Əl sıxma üçün yenidən cəhdlər arasındakı saniyə sayı (WireGuard standartı: 5)",
"rejectAfterTimeLabel": "Rədd edilməyə qədər müddət",
"rejectAfterTimeDescription": "Sessiyanın saniyə ilə ömrü (WireGuard standartı: 180)",
"keepaliveTimeoutLabel": "Keepalive üçün gözləmə müddəti",
"keepaliveTimeoutDescription": "Keepalive göndərilməsinə qədər fəaliyyətsiz keçən saniyə sayı (WireGuard standartı: 10)",
"maxHandshakeAttemptsLabel": "Əl sıxma cəhdlərinin maksimum sayı",
"maxHandshakeAttemptsDescription": "İmtina edilənə qədər əl sıxma cəhdlərinin sayı (WireGuard standartı: 18)",
"randomTrailersLabel": "Təsadüfi sonluqlar",
"randomTrailersDescription": "Əl sıxma paketlərinin sonuna təsadüfi sayda bayt əlavə edir. Hər iki tərəfdə eyni olmalıdır, buna görə hər müştəriyə kopyalanır",
"disableCookiesLabel": "Kukiləri söndür",
"disableCookiesDescription": "Yük altında olduqda əl sıxmalara kuki mesajları ilə cavab vermir. Yalnız tunelin bu tərəfinə təsir edir",
"mtuNote": "Dəyərlər MTU-dan asılıdır",
"obfuscationParameters": "AmneziaWG gizlətmə parametrləri"
}
}

View file

@ -160,6 +160,9 @@
"sectionAdvanced": "Advanced",
"noItems": "No items",
"nullNoItems": "No items. Using global config",
"nullNotSet": "Not set",
"set": "Set",
"unset": "Unset",
"add": "Add"
},
"admin": {
@ -251,10 +254,12 @@
"cidr": "CIDR",
"device": "Device",
"cidrValid": "CIDR must be valid",
"routingTable": "Routing table must be auto, off, or a table number"
"routingTable": "Routing table must be auto, off, or a table number",
"headerProtectionJunkSize": "Junk sizes S1-S4 must be at least 12 when a Header Protection Key is set"
},
"otl": "One Time link",
"stringMalformed": "String is malformed",
"keyMalformed": "Key must be a 32 byte base64 encoded key",
"body": "Body must be a valid object",
"hook": "Hook",
"enabled": "Enabled",
@ -311,6 +316,25 @@
"i4Description": "Protocol mimic packet in hex format: <b 0x...>",
"i5Label": "Special junk packet 5 (I5)",
"i5Description": "Protocol mimic packet in hex format: <b 0x...>",
"rangeNote": "The following parameters accept a single number or an inclusive range (0-65535), for example 30 or 30-90",
"headerProtectionKeyLabel": "Header protection key",
"headerProtectionKeyDescription": "32 byte base64 key used to encrypt packet headers. Must match on both sides, so it is copied to every client. Requires S1-S4 to be at least 12",
"contentPaddingAdditionLabel": "Content padding addition",
"contentPaddingAdditionDescription": "Random amount of bytes appended to transport packets before encryption",
"rekeyAfterTimeLabel": "Rekey after time",
"rekeyAfterTimeDescription": "Seconds before a session is renegotiated (WireGuard default: 120)",
"rekeyTimeoutLabel": "Rekey timeout",
"rekeyTimeoutDescription": "Seconds between handshake retries (WireGuard default: 5)",
"rejectAfterTimeLabel": "Reject after time",
"rejectAfterTimeDescription": "Session lifetime in seconds (WireGuard default: 180)",
"keepaliveTimeoutLabel": "Keepalive timeout",
"keepaliveTimeoutDescription": "Idle seconds before a keepalive is sent (WireGuard default: 10)",
"maxHandshakeAttemptsLabel": "Max handshake attempts",
"maxHandshakeAttemptsDescription": "Handshake attempts before giving up (WireGuard default: 18)",
"randomTrailersLabel": "Random trailers",
"randomTrailersDescription": "Append a random amount of bytes to handshake packets. Must match on both sides, so it is copied to every client",
"disableCookiesLabel": "Disable cookies",
"disableCookiesDescription": "Do not answer handshakes with cookie messages when under load. Only affects this side of the tunnel",
"mtuNote": "Values depend on the MTU",
"obfuscationParameters": "AmneziaWG Obfuscation Parameters"
}

310
src/i18n/locales/sv.json Normal file
View file

@ -0,0 +1,310 @@
{
"pages": {
"me": "Konto",
"clients": "Klienter",
"admin": {
"panel": "Adminpanel",
"general": "Allmänt",
"config": "Konfiguration",
"interface": "Gränssnitt",
"hooks": "Hooks"
}
},
"user": {
"email": "E-post"
},
"me": {
"currentPassword": "Nuvarande lösenord",
"enable2fa": "Aktivera tvåfaktorsautentisering",
"enable2faDesc": "Skanna QR-koden med din autentiseringsapp eller ange nyckeln manuellt.",
"2faKey": "TOTP-nyckel",
"2faCodeDesc": "Ange koden från din autentiseringsapp.",
"disable2fa": "Inaktivera tvåfaktorsautentisering",
"disable2faDesc": "Ange ditt lösenord för att inaktivera tvåfaktorsautentisering.",
"linkOauth": "Länka ditt konto till en extern leverantör",
"unlinkOauth": "Ta bort länk",
"linkedWith": "Länkat till {provider}",
"providerDisabled": "Din länkade leverantör är inte aktiverad"
},
"general": {
"name": "Namn",
"username": "Användarnamn",
"password": "Lösenord",
"newPassword": "Nytt lösenord",
"updatePassword": "Uppdatera lösenord",
"addPassword": "Lägg till lösenord",
"mtu": "MTU",
"allowedIps": "Tillåtna IP-adresser",
"dns": "DNS",
"persistentKeepalive": "Persistent Keepalive",
"logout": "Logga ut",
"continue": "Fortsätt",
"host": "Värd",
"port": "Port",
"yes": "Ja",
"no": "Nej",
"confirmPassword": "Bekräfta lösenord",
"loading": "Laddar...",
"2fa": "Tvåfaktorsautentisering",
"2faCode": "TOTP-kod",
"externalAuth": "Extern autentisering"
},
"setup": {
"welcome": "Välkommen till den första konfigurationen av wg-easy",
"welcomeDesc": "Du har hittat det enklaste sättet att installera och hantera WireGuard på en Linux-värd",
"existingSetup": "Har du en befintlig konfiguration?",
"createAdminDesc": "Ange först ett administratörsanvändarnamn och ett starkt, säkert lösenord. Dessa uppgifter används för att logga in på adminpanelen.",
"setupConfigDesc": "Ange värd- och portinformation. Den används i klientkonfigurationen när WireGuard konfigureras på klienternas enheter.",
"setupMigrationDesc": "Ange säkerhetskopian om du vill migrera data från din tidigare version av wg-easy till den nya installationen.",
"upload": "Ladda upp",
"migration": "Återställ säkerhetskopian:",
"createAccount": "Skapa konto",
"successful": "Konfigurationen slutfördes",
"hostDesc": "Publikt värdnamn som klienterna ansluter till",
"portDesc": "Publik UDP-port som klienterna ansluter till och som WireGuard lyssnar på"
},
"update": {
"updateAvailable": "Det finns en tillgänglig uppdatering!",
"update": "Uppdatera"
},
"theme": {
"dark": "Mörkt tema",
"light": "Ljust tema",
"system": "Systemtema"
},
"layout": {
"toggleCharts": "Visa/dölj diagram",
"donate": "Donera"
},
"login": {
"signIn": "Logga in",
"signInWith": "Logga in med {provider}",
"or": "eller",
"rememberMe": "Kom ihåg mig",
"rememberMeDesc": "Fortsätt vara inloggad efter att webbläsaren stängts",
"insecure": "Du kan inte logga in via en osäker anslutning. Använd HTTPS.",
"2faRequired": "Tvåfaktorsautentisering krävs",
"2faWrong": "Koden för tvåfaktorsautentisering är felaktig",
"loginExpired": "Inloggningen har löpt ut. Försök igen"
},
"client": {
"empty": "Det finns inga klienter ännu.",
"newShort": "Ny",
"sort": "Sortera",
"create": "Skapa klient",
"created": "Klienten skapades",
"new": "Ny klient",
"name": "Namn",
"expireDate": "Utgångsdatum",
"expireDateDesc": "Datum då klienten inaktiveras. Lämna tomt för permanent åtkomst",
"delete": "Ta bort",
"deleteClient": "Ta bort klient",
"deleteDialog": "Är du säker på att du vill ta bort {name}? Åtgärden kan inte ångras.",
"enabled": "Aktiverad",
"address": "Adress",
"serverAllowedIps": "Serverns tillåtna IP-adresser",
"otlDesc": "Generera en kort engångslänk",
"permanent": "Permanent",
"createdOn": "Skapad {date}",
"lastSeen": "Senast sedd {date}",
"totalDownload": "Totalt nedladdat: {total}",
"totalUpload": "Totalt uppladdat: {total}",
"newClient": "Ny klient",
"disableClient": "Inaktivera klient",
"enableClient": "Aktivera klient",
"noPrivKey": "Klienten saknar en känd privat nyckel. Konfigurationen kan inte skapas.",
"showQR": "Visa QR-kod",
"downloadConfig": "Ladda ner konfiguration",
"allowedIpsDesc": "Vilka IP-adresser som ska routas genom VPN-anslutningen (åsidosätter den globala konfigurationen)",
"serverAllowedIpsDesc": "Vilka IP-adresser servern ska routa till klienten",
"mtuDesc": "Anger maximal överföringsenhet (paketstorlek) för VPN-tunneln",
"persistentKeepaliveDesc": "Anger intervallet i sekunder för keepalive-paket. 0 inaktiverar funktionen",
"hooks": "Hooks",
"hooksDescription": "Hooks fungerar endast med wg-quick",
"hooksLeaveEmpty": "Endast för wg-quick. Lämna annars tomt",
"dnsDesc": "DNS-server som klienterna ska använda (åsidosätter den globala konfigurationen)",
"notConnected": "Klienten är inte ansluten",
"endpoint": "Slutpunkt",
"endpointDesc": "Klientens IP-adress från vilken WireGuard-anslutningen upprättas",
"search": "Sök klienter...",
"config": "Konfiguration",
"viewConfig": "Visa konfiguration",
"firewallIps": "Tillåtna IP-adresser i brandväggen",
"firewallIpsDesc": "Mål-IP-adresser/CIDR som klienten får åtkomst till (tillämpas på serversidan). Lämna tomt för att använda Tillåtna IP-adresser. Valfri filtrering efter port och protokoll stöds. Se dokumentationen för syntax.",
"downloadPng": "Ladda ner PNG",
"copyPng": "Kopiera PNG"
},
"dialog": {
"change": "Ändra",
"cancel": "Avbryt",
"create": "Skapa"
},
"toast": {
"success": "Klart",
"saved": "Sparat",
"error": "Fel",
"unknown": "Okänt fel. Se konsolen för mer information"
},
"form": {
"actions": "Åtgärder",
"save": "Spara",
"revert": "Återställ",
"sectionGeneral": "Allmänt",
"sectionAdvanced": "Avancerat",
"noItems": "Inga objekt",
"nullNoItems": "Inga objekt. Den globala konfigurationen används",
"add": "Lägg till"
},
"admin": {
"general": {
"sessionTimeout": "Sessionstimeout",
"sessionTimeoutDesc": "Sessionslängd för Kom ihåg mig (sekunder)",
"metrics": "Mätvärden",
"metricsPassword": "Lösenord",
"metricsPasswordDesc": "Bearer-lösenord för endpointen för mätvärden (lösenord eller Argon2-hash)",
"json": "JSON",
"jsonDesc": "Sökväg för mätvärden i JSON-format",
"prometheus": "Prometheus",
"prometheusDesc": "Sökväg för Prometheus-mätvärden"
},
"config": {
"connection": "Anslutning",
"hostDesc": "Publikt värdnamn som klienterna ansluter till (ogiltigförklarar konfigurationen)",
"portDesc": "Publik UDP-port som klienterna ansluter till (ogiltigförklarar konfigurationen, du bör sannolikt även ändra gränssnittets port)",
"allowedIpsDesc": "Tillåtna IP-adresser som klienterna använder (global konfiguration)",
"dnsDesc": "DNS-server som klienterna använder (global konfiguration)",
"mtuDesc": "MTU som klienterna använder (endast för nya klienter)",
"persistentKeepaliveDesc": "Intervall i sekunder för att skicka keepalive-paket till servern. 0 = inaktiverat (endast för nya klienter)",
"suggest": "Föreslå",
"suggestDesc": "Välj en IP-adress eller ett värdnamn för värdfältet"
},
"interface": {
"cidrSuccess": "CIDR ändrades",
"device": "Enhet",
"deviceDesc": "Ethernet-enhet som WireGuard-trafiken ska vidarebefordras genom",
"routingTable": "Routingtabell",
"routingTableDesc": "Routingtabell för WireGuard-rutter. Ange auto, off eller ett tabellnummer.",
"mtuDesc": "MTU som WireGuard använder",
"portDesc": "UDP-port som WireGuard lyssnar på (du bör sannolikt även ändra porten under Konfiguration)",
"changeCidr": "Ändra CIDR",
"restart": "Starta om gränssnitt",
"restartDesc": "Starta om WireGuard-gränssnittet",
"restartWarn": "Är du säker på att du vill starta om gränssnittet? Alla klienter kopplas från.",
"restartSuccess": "Gränssnittet startades om",
"firewall": "Trafikfiltrering",
"firewallEnabled": "Aktivera brandvägg per klient",
"firewallEnabledDesc": "Begränsa klienttrafik till specifika mål-IP-adresser med iptables. När funktionen är aktiverad kan varje klient konfigureras med tillåtna destinationer."
},
"introText": "Välkommen till adminpanelen.\n\nHär kan du hantera allmänna inställningar, konfigurationen, gränssnittsinställningar och hooks.\n\nBörja genom att välja ett av avsnitten i sidofältet."
},
"zod": {
"generic": {
"required": "{field} krävs",
"validNumber": "{field} måste vara ett giltigt tal",
"validNumberRange": "{field} måste vara ett giltigt tal eller talintervall",
"validString": "{field} måste vara en giltig sträng",
"validBoolean": "{field} måste vara ett giltigt booleskt värde",
"validArray": "{field} måste vara en giltig array",
"stringMin": "{field} måste innehålla minst {min} tecken",
"stringMax": "{field} får innehålla högst {max} tecken",
"numberMin": "{field} måste vara minst {min}",
"numberMax": "{field} får vara högst {max}"
},
"client": {
"id": "Klient-ID",
"name": "Namn",
"expiresAt": "Går ut",
"address4": "IPv4-adress",
"address6": "IPv6-adress",
"serverAllowedIps": "Serverns tillåtna IP-adresser",
"firewallIps": "Tillåtna IP-adresser i brandväggen",
"firewallIpsInvalid": "Ogiltig IP-post för brandväggen. Se dokumentationen för syntax som stöds."
},
"user": {
"username": "Användarnamn",
"password": "Lösenord",
"remember": "Kom ihåg",
"name": "Namn",
"email": "E-post",
"emailInvalid": "E-postadressen måste vara giltig",
"passwordMatch": "Lösenorden måste matcha",
"totpEnable": "Aktivera TOTP",
"totpEnableTrue": "TOTP måste vara aktiverat",
"totpCode": "TOTP-kod"
},
"userConfig": {
"host": "Värd"
},
"general": {
"sessionTimeout": "Sessionstimeout",
"metricsEnabled": "Mätvärden",
"metricsPassword": "Lösenord för mätvärden"
},
"interface": {
"cidr": "CIDR",
"device": "Enhet",
"cidrValid": "CIDR måste vara giltigt",
"routingTable": "Routingtabellen måste vara auto, off eller ett tabellnummer"
},
"otl": "Engångslänk",
"stringMalformed": "Strängen har felaktigt format",
"body": "Innehållet måste vara ett giltigt objekt",
"hook": "Hook",
"enabled": "Aktiverad",
"mtu": "MTU",
"port": "Port",
"persistentKeepalive": "Persistent Keepalive",
"address": "IP-adress",
"dns": "DNS",
"allowedIps": "Tillåtna IP-adresser",
"file": "Fil"
},
"hooks": {
"preUp": "PreUp",
"postUp": "PostUp",
"preDown": "PreDown",
"postDown": "PostDown"
},
"copy": {
"notSupported": "Kopiering stöds inte",
"copied": "Kopierat!",
"failed": "Kopieringen misslyckades",
"copy": "Kopiera"
},
"awg": {
"jCLabel": "Antal skräppaket (Jc)",
"jCDescription": "Antal skräppaket som ska skickas (1–128, rekommenderat: 4–12)",
"jMinLabel": "Minsta storlek på skräppaket (Jmin)",
"jMinDescription": "Minsta storlek på skräppaket (0–1279*, rekommenderat: 8, måste vara < Jmax)",
"jMaxLabel": "Största storlek på skräppaket (Jmax)",
"jMaxDescription": "Största storlek på skräppaket (1–1280*, rekommenderat: 80, måste vara > Jmin)",
"s1Label": "Skräpstorlek för initieringspaket (S1)",
"s1Description": "Skräpstorlek för initieringspaket (0–1132[1280* - 148 = 1132], rekommenderat: 15–150, S1+56 ≠ S2)",
"s2Label": "Skräpstorlek för svarspaket (S2)",
"s2Description": "Skräpstorlek för svarspaket (0–1188[1280* - 92 = 1188], rekommenderat: 15–150)",
"s3Label": "Skräpstorlek för cookie-svarspaket (S3)",
"s3Description": "Skräpstorlek för cookie-svarspaket",
"s4Label": "Skräpstorlek för transportpaket (S4)",
"s4Description": "Skräpstorlek för transportpaket",
"h1Label": "Magisk header för initieringspaket (H1)",
"h1Description": "Headervärde eller intervall för initieringspaket (X eller X-Y, där X<Y. Min 5, max 2147483647. Värdet eller intervallet får inte överlappa andra headers)",
"h2Label": "Magisk header för svarspaket (H2)",
"h2Description": "Headervärde eller intervall för svarspaket (X eller X-Y, där X<Y. Min 5, max 2147483647. Värdet eller intervallet får inte överlappa andra headers)",
"h3Label": "Magisk header för cookie-svarspaket (H3)",
"h3Description": "Headervärde eller intervall för cookie-svarspaket (X eller X-Y, där X<Y. Min 5, max 2147483647. Värdet eller intervallet får inte överlappa andra headers)",
"h4Label": "Magisk header för transportpaket (H4)",
"h4Description": "Headervärde eller intervall för transportpaket (X eller X-Y, där X<Y. Min 5, max 2147483647. Värdet eller intervallet får inte överlappa andra headers)",
"i1Label": "Särskilt skräppaket 1 (I1)",
"i1Description": "Protokollefterliknande paket i hexformat: <b 0x...>",
"i2Label": "Särskilt skräppaket 2 (I2)",
"i2Description": "Protokollefterliknande paket i hexformat: <b 0x...>",
"i3Label": "Särskilt skräppaket 3 (I3)",
"i3Description": "Protokollefterliknande paket i hexformat: <b 0x...>",
"i4Label": "Särskilt skräppaket 4 (I4)",
"i4Description": "Protokollefterliknande paket i hexformat: <b 0x...>",
"i5Label": "Särskilt skräppaket 5 (I5)",
"i5Description": "Protokollefterliknande paket i hexformat: <b 0x...>",
"mtuNote": "Värdena beror på MTU",
"obfuscationParameters": "Obfuskeringsparametrar för AmneziaWG"
}
}

View file

@ -159,6 +159,9 @@
"sectionAdvanced": "Gelişmiş",
"noItems": "Öge yok",
"nullNoItems": "Öge yok. Genel yapılandırma kullanılıyor.",
"nullNotSet": "Ayarlanmadı",
"set": "Ayarla",
"unset": "Kaldır",
"add": "Ekle"
},
"admin": {
@ -250,10 +253,12 @@
"cidr": "CIDR",
"device": "Aygıt",
"cidrValid": "Geçersiz CIDR",
"routingTable": "Yönlendirme tablosu auto, off veya bir tablo numarası olmalıdır"
"routingTable": "Yönlendirme tablosu auto, off veya bir tablo numarası olmalıdır",
"headerProtectionJunkSize": "Header Protection Key ayarlandığında S1-S4 junk boyutları en az 12 olmalıdır"
},
"otl": "OTL (One Time Link)",
"stringMalformed": "Hatalı format",
"keyMalformed": "Anahtar, 32 baytlık base64 kodlu bir anahtar olmalıdır",
"body": "Body geçerli bir obje olmalıdır",
"hook": "Hook",
"enabled": "Aktif",
@ -310,6 +315,25 @@
"i4Description": "Hex formatında protocol mimic paketi: <b 0x...>",
"i5Label": "Özel junk 5 (I5)",
"i5Description": "Hex formatında protocol mimic paketi: <b 0x...>",
"rangeNote": "Aşağıdaki parametreler tek bir sayı veya kapsayıcı bir aralık (0-65535) kabul eder, örneğin 30 veya 30-90",
"headerProtectionKeyLabel": "Header protection anahtarı",
"headerProtectionKeyDescription": "Paket başlıklarını şifrelemek için kullanılan 32 baytlık base64 anahtar. Her iki tarafta da aynı olmalıdır, bu yüzden her istemciye kopyalanır. S1-S4'ün en az 12 olmasını gerektirir",
"contentPaddingAdditionLabel": "İçerik dolgu ekleme",
"contentPaddingAdditionDescription": "Şifrelemeden önce transport paketlerine eklenen rastgele miktarda bayt",
"rekeyAfterTimeLabel": "Yeniden anahtarlama süresi",
"rekeyAfterTimeDescription": "Oturum yeniden müzakere edilmeden önce geçecek saniye (WireGuard varsayılanı: 120)",
"rekeyTimeoutLabel": "Yeniden anahtarlama zaman aşımı",
"rekeyTimeoutDescription": "Handshake yeniden denemeleri arasındaki saniye (WireGuard varsayılanı: 5)",
"rejectAfterTimeLabel": "Reddetme süresi",
"rejectAfterTimeDescription": "Saniye cinsinden oturum ömrü (WireGuard varsayılanı: 180)",
"keepaliveTimeoutLabel": "Keepalive zaman aşımı",
"keepaliveTimeoutDescription": "Keepalive gönderilmeden önce boşta geçecek saniye (WireGuard varsayılanı: 10)",
"maxHandshakeAttemptsLabel": "Maks handshake denemesi",
"maxHandshakeAttemptsDescription": "Vazgeçmeden önceki handshake denemesi sayısı (WireGuard varsayılanı: 18)",
"randomTrailersLabel": "Rastgele son ekler",
"randomTrailersDescription": "Handshake paketlerine rastgele miktarda bayt ekler. Her iki tarafta da aynı olmalıdır, bu yüzden her istemciye kopyalanır",
"disableCookiesLabel": "Cookie'leri devre dışı bırak",
"disableCookiesDescription": "Yük altındayken handshake'lere cookie mesajlarıyla yanıt verme. Yalnızca tünelin bu tarafını etkiler",
"mtuNote": "Değerler MTU'ya bağlıdır",
"obfuscationParameters": "AmneziaWG Obfuscation Ayarları"
}

View file

@ -159,6 +159,9 @@
"sectionAdvanced": "Додатково",
"noItems": "Немає елементів",
"nullNoItems": "Немає елементів. Використовується глобальна конфігурація",
"nullNotSet": "Не встановлено",
"set": "Встановити",
"unset": "Скасувати встановлення",
"add": "Додати"
},
"admin": {
@ -250,10 +253,12 @@
"cidr": "CIDR",
"device": "Пристрій",
"cidrValid": "CIDR має бути дійсним",
"routingTable": "Таблиця маршрутизації має бути auto, off або номером таблиці"
"routingTable": "Таблиця маршрутизації має бути auto, off або номером таблиці",
"headerProtectionJunkSize": "Розміри сміття S1-S4 мають бути щонайменше 12, якщо встановлено ключ захисту заголовків"
},
"otl": "Одноразове посилання",
"stringMalformed": "Рядок має неправильний формат",
"keyMalformed": "Ключ має бути ключем довжиною 32 байти, закодованим у Base64",
"body": "Тіло має бути коректним об'єктом",
"hook": "Hook",
"enabled": "Увімкнено",
@ -310,6 +315,25 @@
"i4Description": "Пакет-імітація протоколу у hex-форматі: <b 0x...>",
"i5Label": "Спеціальний сміттєвий пакет 5 (I5)",
"i5Description": "Пакет-імітація протоколу у hex-форматі: <b 0x...>",
"rangeNote": "Наведені нижче параметри приймають одне число або включний діапазон (0–65535), наприклад 30 або 30–90",
"headerProtectionKeyLabel": "Ключ захисту заголовків",
"headerProtectionKeyDescription": "Ключ Base64 довжиною 32 байти, який використовується для шифрування заголовків пакетів. Має збігатися з обох боків, тому копіюється до кожного клієнта. Потребує значень S1-S4 не менше 12",
"contentPaddingAdditionLabel": "Додавання доповнення до вмісту",
"contentPaddingAdditionDescription": "Випадкова кількість байтів, що додається до транспортних пакетів перед шифруванням",
"rekeyAfterTimeLabel": "Повторне створення ключа через",
"rekeyAfterTimeDescription": "Кількість секунд до повторного узгодження сеансу (типове значення WireGuard: 120)",
"rekeyTimeoutLabel": "Час очікування повторного створення ключа",
"rekeyTimeoutDescription": "Кількість секунд між повторними спробами рукостискання (типове значення WireGuard: 5)",
"rejectAfterTimeLabel": "Відхиляти після",
"rejectAfterTimeDescription": "Тривалість сеансу в секундах (типове значення WireGuard: 180)",
"keepaliveTimeoutLabel": "Час очікування Keepalive",
"keepaliveTimeoutDescription": "Кількість секунд бездіяльності перед надсиланням Keepalive (типове значення WireGuard: 10)",
"maxHandshakeAttemptsLabel": "Максимальна кількість спроб рукостискання",
"maxHandshakeAttemptsDescription": "Кількість спроб рукостискання перед припиненням спроб (типове значення WireGuard: 18)",
"randomTrailersLabel": "Випадкові кінцеві дані",
"randomTrailersDescription": "Додавати до пакетів рукостискання випадкову кількість байтів. Має збігатися з обох боків, тому копіюється до кожного клієнта",
"disableCookiesLabel": "Вимкнути cookie",
"disableCookiesDescription": "Не відповідати на рукостискання повідомленнями cookie під час високого навантаження. Впливає лише на цей бік тунелю",
"mtuNote": "Значення залежать від MTU",
"obfuscationParameters": "Параметри обфускації AmneziaWG"
}

View file

@ -148,6 +148,16 @@ export default defineNuxtConfig({
language: 'vi-VN',
name: 'Tiếng Việt',
},
{
code: 'sv',
language: 'sv-SE',
name: 'Svenska',
},
{
code: 'az',
language: 'az-AZ',
name: 'Azərbaycan',
},
],
defaultLocale: 'en',
vueI18n: './i18n.config.ts',

View file

@ -23,56 +23,56 @@
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@libsql/client": "^0.17.4",
"@libsql/client": "^0.18.0",
"@nuxtjs/color-mode": "^4.0.1",
"@nuxtjs/i18n": "^10.6.0",
"@nuxtjs/tailwindcss": "^6.14.0",
"@phc/format": "^1.0.0",
"@pinia/nuxt": "^1.0.1",
"@pinia/nuxt": "^1.0.2",
"@tailwindcss/forms": "^0.5.11",
"@vueuse/core": "^14.4.0",
"@vueuse/nuxt": "^14.4.0",
"apexcharts": "^6.8.0",
"apexcharts": "^7.5.1",
"argon2": "^0.45.1",
"cidr-tools": "^13.0.0",
"cidr-tools": "^13.0.1",
"citty": "^0.2.2",
"consola": "^3.4.2",
"crc-32": "^1.2.2",
"drizzle-orm": "^0.45.2",
"ip-bigint": "^9.0.8",
"ip-bigint": "^10.0.1",
"is-cidr": "^7.0.1",
"is-ip": "^5.0.1",
"js-sha256": "^1.0.0",
"nuxt": "^4.5.2",
"obug": "^2.1.4",
"openid-client": "^6.8.4",
"otpauth": "^9.5.1",
"pinia": "^4.0.2",
"qr": "^0.6.0",
"reka-ui": "^2.10.3",
"obug": "^3.0.0",
"openid-client": "^6.8.8",
"otpauth": "^9.5.2",
"pinia": "^4.0.3",
"qr": "^0.7.0",
"reka-ui": "^2.10.4",
"semver": "^7.8.5",
"tailwindcss": "^3.4.19",
"vue": "latest",
"vue3-apexcharts": "^1.11.1",
"zod": "^4.4.3"
"zod": "^4.6.4"
},
"devDependencies": {
"@nuxt/eslint": "^1.17.0",
"@nuxt/test-utils": "^4.1.0",
"@nuxt/test-utils": "^4.3.2",
"@types/phc__format": "^1.0.1",
"@types/semver": "^7.8.0",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.28.2",
"eslint": "^10.8.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tsx": "^4.23.12",
"tsx": "^4.23.13",
"typescript": "^6.0.3",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.9"
"vitest": "^4.1.11",
"vue-tsc": "^3.3.11"
},
"packageManager": "pnpm@11.22.0"
"packageManager": "pnpm@12.4.1"
}

3257
src/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { cachedGetIpInformation } from '#server/utils/ip';
import { getIpInformation } from '#server/utils/ip';
export default definePermissionEventHandler('admin', 'any', async () => {
const result = await cachedGetIpInformation();
const wgInterface = await Database.interfaces.get();
const result = await getIpInformation(wgInterface.name);
return result;
});

View file

@ -2,6 +2,7 @@ import { createError, defineEventHandler, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { SERVER_DEBUG, WG_ENV } from '#server/utils/config';
import { logSecurityEvent } from '#server/utils/securityLogger';
import { useWGSession } from '#server/utils/session';
import { assertUnreachable, validateZod } from '#server/utils/types';
import { UserLoginSchema } from '#db/repositories/user/types';
@ -28,6 +29,7 @@ export default defineEventHandler(async (event) => {
if (!result.success) {
switch (result.error) {
case 'INCORRECT_CREDENTIALS':
logSecurityEvent(event, 'password', username);
throw createError({
statusCode: 401,
statusMessage: 'Invalid username or password',

View file

@ -1,6 +1,7 @@
import { createError, defineEventHandler, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { logSecurityEvent } from '#server/utils/securityLogger';
import { useWGSession } from '#server/utils/session';
import { assertUnreachable, validateZod } from '#server/utils/types';
import { Verify2faSchema } from '#db/repositories/user/types';
@ -34,6 +35,7 @@ export default defineEventHandler(async (event) => {
switch (totpStatus) {
case 'INVALID_TOTP_CODE':
logSecurityEvent(event, '2fa');
return { status: 'INVALID_TOTP_CODE' as const };
case 'USER_DISABLED':
throw createError({

View file

@ -15,7 +15,7 @@ export default definePermissionEventHandler(
validateZod(ClientGetSchema, event)
);
const result = await Database.clients.get(clientId);
const result = await Database.clients.getPublic(clientId);
checkPermissions(result);
if (!result) {

View file

@ -1,7 +1,9 @@
import Database from '#server/utils/Database';
import { defineSetupEventHandler } from '#server/utils/handler';
import { cachedGetIpInformation } from '#server/utils/ip';
import { getIpInformation } from '#server/utils/ip';
export default defineSetupEventHandler(4, async () => {
const result = await cachedGetIpInformation();
const wgInterface = await Database.interfaces.get();
const result = await getIpInformation(wgInterface.name);
return result;
});

View file

@ -0,0 +1,16 @@
ALTER TABLE `clients_table` ADD `content_padding_addition` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `rekey_after_time` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `rekey_timeout` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `reject_after_time` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `keepalive_timeout` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `max_handshake_attempts` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `disable_cookies` integer;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `header_protection_key` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `content_padding_addition` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `rekey_after_time` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `rekey_timeout` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `reject_after_time` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `keepalive_timeout` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `max_handshake_attempts` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `random_trailers` integer;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `disable_cookies` integer;

View file

@ -0,0 +1,5 @@
UPDATE `hooks_table` SET
`pre_up` = replace(`pre_up`, 'wg0', '{{interface}}'),
`post_up` = replace(`post_up`, 'wg0', '{{interface}}'),
`pre_down` = replace(`pre_down`, 'wg0', '{{interface}}'),
`post_down` = replace(`post_down`, 'wg0', '{{interface}}');

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,20 @@
"when": 1785630544322,
"tag": "0007_blushing_electro",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1788760134302,
"tag": "0008_thin_patch",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790082534310,
"tag": "0009_dusty_prodigy",
"breakpoints": true
}
]
}

View file

@ -52,6 +52,15 @@ export const client = sqliteTable(
i3: text(),
i4: text(),
i5: text(),
// HeaderProtectionKey and RandomTrailers have to match the interface and
// are therefore only configurable on the server
contentPaddingAddition: text('content_padding_addition'),
rekeyAfterTime: text('rekey_after_time'),
rekeyTimeout: text('rekey_timeout'),
rejectAfterTime: text('reject_after_time'),
keepaliveTimeout: text('keepalive_timeout'),
maxHandshakeAttempts: text('max_handshake_attempts'),
disableCookies: int('disable_cookies', { mode: 'boolean' }),
dns: text({ mode: 'json' }).$type<string[]>(),
serverEndpoint: text('server_endpoint'),
enabled: int({ mode: 'boolean' }).notNull(),

View file

@ -9,6 +9,7 @@ import type {
UpdateClientType,
} from './types';
import { WG_ENV } from '#server/utils/config';
import Database from '#server/utils/Database';
import { nextIP } from '#server/utils/ip';
import type { ID } from '#server/utils/types';
@ -28,6 +29,15 @@ function createPreparedStatement(db: DBType) {
findById: db.query.client
.findFirst({ where: eq(client.id, sql.placeholder('id')) })
.prepare(),
findByIdPublic: db.query.client
.findFirst({
where: eq(client.id, sql.placeholder('id')),
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
toggle: db
.update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean })
@ -149,10 +159,20 @@ export class ClientService {
}));
}
/**
* Includes WireGuard secrets. JSON API responses should use {@link getPublic}.
*/
get(id: ID) {
return this.#statements.findById.execute({ id });
}
/**
* Returns one client without sensitive data
*/
getPublic(id: ID) {
return this.#statements.findByIdPublic.execute({ id });
}
async create({ name, expiresAt }: ClientCreateType) {
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
@ -162,7 +182,7 @@ export class ClientService {
const clients = await tx.query.client.findMany().execute();
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.WG_INTERFACE),
})
.execute();
@ -191,7 +211,7 @@ export class ClientService {
name,
// TODO: properly assign user id
userId: 1,
interfaceId: 'wg0',
interfaceId: WG_ENV.WG_INTERFACE,
expiresAt,
privateKey,
publicKey,
@ -236,7 +256,7 @@ export class ClientService {
return this.#db.transaction(async (tx) => {
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.WG_INTERFACE),
})
.execute();
@ -272,7 +292,7 @@ export class ClientService {
.values({
name,
userId: 1,
interfaceId: 'wg0',
interfaceId: WG_ENV.WG_INTERFACE,
privateKey,
publicKey,
preSharedKey,

View file

@ -7,6 +7,7 @@ import type { client } from './schema';
import {
AddressSchema,
AllowedIpsSchema,
AwgRangeSchema,
DnsSchema,
EnabledSchema,
FirewallIpsSchema,
@ -109,6 +110,13 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
i3: ISchema,
i4: ISchema,
i5: ISchema,
contentPaddingAddition: AwgRangeSchema,
rekeyAfterTime: AwgRangeSchema,
rekeyTimeout: AwgRangeSchema,
rejectAfterTime: AwgRangeSchema,
keepaliveTimeout: AwgRangeSchema,
maxHandshakeAttempts: AwgRangeSchema,
disableCookies: z.boolean().nullable(),
persistentKeepalive: PersistentKeepaliveSchema,
serverEndpoint: AddressSchema.nullable(),
dns: DnsSchema.nullable(),

View file

@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { hooks } from './schema';
import type { HooksUpdateType } from './types';
import { WG_ENV } from '#server/utils/config';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
@ -23,7 +24,9 @@ export class HooksService {
}
async get() {
const hooks = await this.#statements.get.execute({ interface: 'wg0' });
const hooks = await this.#statements.get.execute({
interface: WG_ENV.WG_INTERFACE,
});
if (!hooks) {
throw new Error('Hooks not found');
}
@ -34,7 +37,7 @@ export class HooksService {
return this.#db
.update(hooks)
.set(data)
.where(eq(hooks.id, 'wg0'))
.where(eq(hooks.id, WG_ENV.WG_INTERFACE))
.execute();
}
}

View file

@ -31,6 +31,15 @@ export const wgInterface = sqliteTable('interfaces_table', {
i3: text(),
i4: text(),
i5: text(),
headerProtectionKey: text('header_protection_key'),
contentPaddingAddition: text('content_padding_addition'),
rekeyAfterTime: text('rekey_after_time'),
rekeyTimeout: text('rekey_timeout'),
rejectAfterTime: text('reject_after_time'),
keepaliveTimeout: text('keepalive_timeout'),
maxHandshakeAttempts: text('max_handshake_attempts'),
randomTrailers: int('random_trailers', { mode: 'boolean' }),
disableCookies: int('disable_cookies', { mode: 'boolean' }),
// does nothing yet
enabled: int({ mode: 'boolean' }).notNull(),
// Enable per-client firewall filtering via iptables

View file

@ -4,6 +4,7 @@ import { parseCidr } from 'cidr-tools';
import { wgInterface } from './schema';
import type { InterfaceCidrUpdateType, InterfaceUpdateType } from './types';
import { WG_ENV } from '#server/utils/config';
import { nextIPFromUsedAddresses } from '#server/utils/ip';
import { client as clientSchema } from '#db/schema';
import type { DBType } from '#db/sqlite';
@ -42,7 +43,7 @@ export class InterfaceService {
async get() {
const wgInterface = await this.#statements.get.execute({
interface: 'wg0',
interface: WG_ENV.WG_INTERFACE,
});
if (!wgInterface) {
throw new Error('Interface not found');
@ -52,7 +53,7 @@ export class InterfaceService {
updateKeyPair(privateKey: string, publicKey: string) {
return this.#statements.updateKeyPair.execute({
interface: 'wg0',
interface: WG_ENV.WG_INTERFACE,
privateKey,
publicKey,
});
@ -62,13 +63,13 @@ export class InterfaceService {
return this.#db
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.WG_INTERFACE))
.execute();
}
setFirewallEnabled(firewallEnabled: boolean) {
return this.#statements.setFirewallEnabled.execute({
interface: 'wg0',
interface: WG_ENV.WG_INTERFACE,
firewallEnabled,
});
}
@ -77,7 +78,7 @@ export class InterfaceService {
return this.#db.transaction(async (tx) => {
const oldCidr = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.WG_INTERFACE),
columns: { ipv4Cidr: true, ipv6Cidr: true },
})
.execute();
@ -89,7 +90,7 @@ export class InterfaceService {
await tx
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.WG_INTERFACE))
.execute();
const clients = await tx.query.client.findMany().execute();

View file

@ -5,12 +5,14 @@ import isCidr from 'is-cidr';
import type { wgInterface } from './schema';
import {
AwgRangeSchema,
EnabledSchema,
HSchema,
ISchema,
JcSchema,
JmaxSchema,
JminSchema,
KeySchema,
MtuSchema,
PortSchema,
RoutingTableSchema,
@ -29,7 +31,13 @@ export type InterfaceCreateType = Omit<
export type InterfaceUpdateType = Omit<
InterfaceCreateType,
'name' | 'createdAt' | 'updatedAt' | 'privateKey' | 'publicKey'
| 'name'
| 'createdAt'
| 'updatedAt'
| 'privateKey'
| 'publicKey'
| 'ipv4Cidr'
| 'ipv6Cidr'
>;
const device = z
@ -37,39 +45,79 @@ const device = z
.min(1, t('zod.interface.device'))
.pipe(safeStringRefine);
const cidr = z
const cidr4 = z
.string({ message: t('zod.interface.cidr') })
.min(1, { message: t('zod.interface.cidr') })
.refine((value) => isCidr(value), { message: t('zod.interface.cidrValid') })
.refine((value) => isCidr.v4(value), {
message: t('zod.interface.cidrValid'),
})
.pipe(safeStringRefine);
export const InterfaceUpdateSchema = schemaForType<InterfaceUpdateType>()(
z.object({
ipv4Cidr: cidr,
ipv6Cidr: cidr,
mtu: MtuSchema,
routingTable: RoutingTableSchema,
jC: JcSchema,
jMin: JminSchema,
jMax: JmaxSchema,
s1: SSchema,
s2: SSchema,
s3: SSchema,
s4: SSchema,
h1: HSchema,
h2: HSchema,
h3: HSchema,
h4: HSchema,
i1: ISchema,
i2: ISchema,
i3: ISchema,
i4: ISchema,
i5: ISchema,
port: PortSchema,
device: device,
enabled: EnabledSchema,
firewallEnabled: EnabledSchema,
const cidr6 = z
.string({ message: t('zod.interface.cidr') })
.min(1, { message: t('zod.interface.cidr') })
.refine((value) => isCidr.v6(value), {
message: t('zod.interface.cidrValid'),
})
.pipe(safeStringRefine);
/**
* Header protection uses the first 12 bytes of every junk prefix as the nonce
* for its cipher, so all four junk sizes have to be at least that large.
*/
const HEADER_PROTECTION_MIN_JUNK_SIZE = 12;
export const InterfaceUpdateSchema = schemaForType<InterfaceUpdateType>()(
z
.object({
device: device,
port: PortSchema,
mtu: MtuSchema,
routingTable: RoutingTableSchema,
jC: JcSchema,
jMin: JminSchema,
jMax: JmaxSchema,
s1: SSchema,
s2: SSchema,
s3: SSchema,
s4: SSchema,
h1: HSchema,
h2: HSchema,
h3: HSchema,
h4: HSchema,
i1: ISchema,
i2: ISchema,
i3: ISchema,
i4: ISchema,
i5: ISchema,
headerProtectionKey: KeySchema,
contentPaddingAddition: AwgRangeSchema,
rekeyAfterTime: AwgRangeSchema,
rekeyTimeout: AwgRangeSchema,
rejectAfterTime: AwgRangeSchema,
keepaliveTimeout: AwgRangeSchema,
maxHandshakeAttempts: AwgRangeSchema,
randomTrailers: z.boolean().nullable(),
disableCookies: z.boolean().nullable(),
enabled: EnabledSchema,
firewallEnabled: EnabledSchema,
})
.superRefine((data, ctx) => {
if (data.headerProtectionKey === null) return;
for (const junk of ['s1', 's2', 's3', 's4'] as const) {
const size = data[junk];
if (size === null || size < HEADER_PROTECTION_MIN_JUNK_SIZE) {
ctx.addIssue({
code: 'custom',
path: [junk],
message: t('zod.interface.headerProtectionJunkSize'),
});
}
}
})
);
export type InterfaceCidrUpdateType = {
@ -80,7 +128,7 @@ export type InterfaceCidrUpdateType = {
export const InterfaceCidrUpdateSchema =
schemaForType<InterfaceCidrUpdateType>()(
z.object({
ipv4Cidr: cidr,
ipv6Cidr: cidr,
ipv4Cidr: cidr4,
ipv6Cidr: cidr6,
})
);

View file

@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { userConfig } from './schema';
import type { UserConfigUpdateType } from './types';
import { WG_ENV } from '#server/utils/config';
import { wgInterface } from '#db/schema';
import type { DBType } from '#db/sqlite';
@ -24,7 +25,9 @@ export class UserConfigService {
}
async get() {
const userConfig = await this.#statements.get.execute({ interface: 'wg0' });
const userConfig = await this.#statements.get.execute({
interface: WG_ENV.WG_INTERFACE,
});
if (!userConfig) {
throw new Error('User config not found');
@ -33,8 +36,6 @@ export class UserConfigService {
return userConfig;
}
// TODO: wrap ipv6 host in square brackets
/**
* sets host of user config
*
@ -45,13 +46,13 @@ export class UserConfigService {
await tx
.update(userConfig)
.set({ host, port })
.where(eq(userConfig.id, 'wg0'))
.where(eq(userConfig.id, WG_ENV.WG_INTERFACE))
.execute();
await tx
.update(wgInterface)
.set({ port })
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.WG_INTERFACE))
.execute();
});
}
@ -60,7 +61,7 @@ export class UserConfigService {
return this.#db
.update(userConfig)
.set(data)
.where(eq(userConfig.id, 'wg0'))
.where(eq(userConfig.id, WG_ENV.WG_INTERFACE))
.execute();
}
}

View file

@ -21,6 +21,7 @@ const db = drizzle({ client, schema });
export async function connect() {
await migrate();
await renameInterface(db);
const dbService = new DBService(db);
if (WG_INITIAL_ENV.ENABLED) {
@ -122,16 +123,38 @@ async function initialSetup(db: DBServiceType) {
}
}
// Renames the interface to the one specified in `WG_INTERFACE` then hooks, users, and clients via `ON UPDATE CASCADE`
async function renameInterface(db: DBType) {
const wgInterface = await db.query.wgInterface.findFirst();
if (!wgInterface) {
throw new Error('Interface not found');
}
if (wgInterface.name === WG_ENV.WG_INTERFACE) {
return;
}
DB_DEBUG(
`Renaming Interface ${wgInterface.name} to ${WG_ENV.WG_INTERFACE}...`
);
await db
.update(schema.wgInterface)
.set({ name: WG_ENV.WG_INTERFACE })
.where(eq(schema.wgInterface.name, wgInterface.name))
.execute();
}
async function disableIpv6(db: DBType) {
// This should match the initial value migration
const postUpMatch =
' ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT;';
' ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i {{interface}} -j ACCEPT; ip6tables -A FORWARD -o {{interface}} -j ACCEPT;';
const postDownMatch =
' ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT;';
' ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i {{interface}} -j ACCEPT; ip6tables -D FORWARD -o {{interface}} -j ACCEPT;';
await db.transaction(async (tx) => {
const hooks = await tx.query.hooks.findFirst({
where: eq(schema.hooks.id, 'wg0'),
where: eq(schema.hooks.id, WG_ENV.WG_INTERFACE),
});
if (!hooks) {
@ -146,7 +169,7 @@ async function disableIpv6(db: DBType) {
postUp: hooks.postUp.replace(postUpMatch, ''),
postDown: hooks.postDown.replace(postDownMatch, ''),
})
.where(eq(schema.hooks.id, 'wg0'))
.where(eq(schema.hooks.id, WG_ENV.WG_INTERFACE))
.execute();
} else {
DB_DEBUG('IPv6 Post Up hooks already disabled, skipping...');
@ -159,7 +182,7 @@ async function disableIpv6(db: DBType) {
postUp: hooks.postUp.replace(postUpMatch, ''),
postDown: hooks.postDown.replace(postDownMatch, ''),
})
.where(eq(schema.hooks.id, 'wg0'))
.where(eq(schema.hooks.id, WG_ENV.WG_INTERFACE))
.execute();
} else {
DB_DEBUG('IPv6 Post Down hooks already disabled, skipping...');

View file

@ -7,6 +7,7 @@ import {
import Database from '#server/utils/Database';
import WireGuard from '#server/utils/WireGuard';
import { logSecurityEvent } from '#server/utils/securityLogger';
import { validateZod } from '#server/utils/types';
import { OneTimeLinkGetSchema } from '#db/repositories/oneTimeLink/types';
@ -18,6 +19,7 @@ export default defineEventHandler(async (event) => {
const otl = await Database.oneTimeLinks.getByOtl(oneTimeLink);
if (!otl) {
logSecurityEvent(event, 'one-time-link');
throw createError({
statusCode: 404,
statusMessage: 'Invalid One Time Link',

View file

@ -19,6 +19,7 @@ async function getPrometheusResponse() {
const wireguardSentBytes = [];
const wireguardReceivedBytes = [];
const wireguardLatestHandshakeSeconds = [];
const wireguardEndpointInfo = [];
for (const client of clients) {
if (client.enabled === true) {
wireguardEnabledPeersCount++;
@ -46,6 +47,17 @@ async function getPrometheusResponse() {
wireguardLatestHandshakeSeconds.push(
`wireguard_latest_handshake_seconds{${id}} ${client.latestHandshakeAt ? (Date.now() - client.latestHandshakeAt.getTime()) / 1000 : 0}`
);
if (client.endpoint) {
const endpointId = formatPrometheusLabels({
interface: wgInterface.name,
name: client.name,
endpoint: client.endpoint,
});
wireguardEndpointInfo.push(
`wireguard_peer_endpoint_info{${endpointId}} 1`
);
}
}
const id = formatPrometheusLabels({ interface: wgInterface.name });
@ -75,6 +87,10 @@ async function getPrometheusResponse() {
'# TYPE wireguard_latest_handshake_seconds gauge',
`${wireguardLatestHandshakeSeconds.join('\n')}`,
'',
'# HELP wireguard_peer_endpoint_info Current endpoint (IP:port) of a connected peer',
'# TYPE wireguard_peer_endpoint_info gauge',
`${wireguardEndpointInfo.join('\n')}`,
'',
];
return returnText.join('\n');

View file

@ -18,9 +18,9 @@ const nullObject = new Proxy(
let provider = nullObject as never as DBServiceType;
connect()
.then((db) => {
.then(async (db) => {
provider = db;
WireGuard.Startup();
await WireGuard.Startup();
})
.catch((err) => {
console.log('Failed to connect to Database:', err);

154
src/server/utils/awg.ts Normal file
View file

@ -0,0 +1,154 @@
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
/**
* A parameter written as `Key = value` into the `[Interface]` section of a
* configuration file. `null` means "not set" and is left out entirely.
*/
type AwgParameters = Record<string, string | number | boolean | null>;
type AwgInterface = Pick<
InterfaceType,
| 'jC'
| 'jMin'
| 'jMax'
| 's1'
| 's2'
| 's3'
| 's4'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'i1'
| 'i2'
| 'i3'
| 'i4'
| 'i5'
| 'headerProtectionKey'
| 'contentPaddingAddition'
| 'rekeyAfterTime'
| 'rekeyTimeout'
| 'rejectAfterTime'
| 'keepaliveTimeout'
| 'maxHandshakeAttempts'
| 'randomTrailers'
| 'disableCookies'
>;
type AwgClient = Pick<
ClientType,
| 'jC'
| 'jMin'
| 'jMax'
| 'i1'
| 'i2'
| 'i3'
| 'i4'
| 'i5'
| 'contentPaddingAddition'
| 'rekeyAfterTime'
| 'rekeyTimeout'
| 'rejectAfterTime'
| 'keepaliveTimeout'
| 'maxHandshakeAttempts'
| 'disableCookies'
>;
/**
* amneziawg-tools parses booleans with `parse_bool`, which only accepts
* `on`, `off` or a plain number - not `true` / `false`.
*/
function formatBoolean(value: boolean) {
return value ? 'on' : 'off';
}
export function buildAwgLines(parameters: AwgParameters) {
const lines: string[] = [];
for (const [key, value] of Object.entries(parameters)) {
if (typeof value === 'boolean') {
lines.push(`${key} = ${formatBoolean(value)}`);
continue;
}
if (!value) continue;
lines.push(`${key} = ${value}`);
}
return lines;
}
/** Obfuscation parameters for the server's own interface */
export function interfaceAwgParameters(
wgInterface: AwgInterface
): AwgParameters {
return {
Jc: wgInterface.jC,
Jmin: wgInterface.jMin,
Jmax: wgInterface.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: wgInterface.i1,
I2: wgInterface.i2,
I3: wgInterface.i3,
I4: wgInterface.i4,
I5: wgInterface.i5,
HeaderProtectionKey: wgInterface.headerProtectionKey,
ContentPaddingAddition: wgInterface.contentPaddingAddition,
RekeyAfterTime: wgInterface.rekeyAfterTime,
RekeyTimeout: wgInterface.rekeyTimeout,
RejectAfterTime: wgInterface.rejectAfterTime,
KeepaliveTimeout: wgInterface.keepaliveTimeout,
MaxHandshakeAttempts: wgInterface.maxHandshakeAttempts,
RandomTrailers: wgInterface.randomTrailers,
DisableCookies: wgInterface.disableCookies,
};
}
/**
* Obfuscation parameters for a client's interface.
*
* S1-S4, H1-H4, HeaderProtectionKey and RandomTrailers have to be identical on
* both ends of the tunnel and are therefore copied from the server, everything
* else may differ per client.
*/
export function clientAwgParameters(
wgInterface: AwgInterface,
client: AwgClient
): AwgParameters {
return {
Jc: client.jC,
Jmin: client.jMin,
Jmax: client.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: client.i1,
I2: client.i2,
I3: client.i3,
I4: client.i4,
I5: client.i5,
HeaderProtectionKey: wgInterface.headerProtectionKey,
ContentPaddingAddition: client.contentPaddingAddition,
RekeyAfterTime: client.rekeyAfterTime,
RekeyTimeout: client.rekeyTimeout,
RejectAfterTime: client.rejectAfterTime,
KeepaliveTimeout: client.keepaliveTimeout,
MaxHandshakeAttempts: client.maxHandshakeAttempts,
RandomTrailers: wgInterface.randomTrailers,
DisableCookies: client.disableCookies,
};
}

View file

@ -2,6 +2,7 @@ import { createDebug } from 'obug';
import packageJson from '@@/package.json';
import { exec } from '#server/utils/cmd';
import { parseInterfaceName } from '#server/utils/interfaceName';
import { parseTrustedProxies } from '#server/utils/trustedProxy';
import {
OAUTH_PROVIDERS,
@ -50,6 +51,8 @@ export const WG_ENV = {
PORT: assertEnv('PORT'),
/** If IPv6 should be disabled */
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
/** Name of the WireGuard interface */
WG_INTERFACE: parseInterfaceName(process.env.WG_INTERFACE),
WG_EXECUTABLE: await detectAwg(),
DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true',
/** List of enabled and configured OAuth providers */

View file

@ -0,0 +1,15 @@
import { isIPv6 } from 'is-ip';
/**
* WireGuard Endpoint host:port. IPv6 hosts need brackets.
*/
export function formatEndpoint(host: string, port: number): string {
const bare =
host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
if (isIPv6(bare)) {
return `[${bare}]:${port}`;
}
return `${bare}:${port}`;
}

View file

@ -9,6 +9,14 @@ import type { UserConfigType } from '#db/repositories/userConfig/types';
const FW_DEBUG = createDebug('Firewall');
const CHAIN_NAME = 'WG_CLIENTS';
/**
* Tie chains to a specific interface
* Prevents instances in the same network namespace from wiping each other's rules
*/
function chainName(interfaceName: string) {
return `${CHAIN_NAME}_${interfaceName}`;
}
// Mutex to prevent concurrent rule rebuilds
let rebuildInProgress = false;
let rebuildQueued = false;
@ -131,6 +139,7 @@ function parseFirewallEntry(entry: string): ParsedEntry {
* Generate iptables rule arguments for a single firewall entry
*/
function generateRuleArgs(
interfaceName: string,
clientIp: string,
entry: ParsedEntry,
comment?: string,
@ -138,7 +147,7 @@ function generateRuleArgs(
): string[] {
const rules: string[] = [];
const commentArg = comment ? ` -m comment --comment "${comment}"` : '';
const baseArgs = `-${action} ${CHAIN_NAME} -s ${clientIp} -d ${entry.ip}`;
const baseArgs = `-${action} ${chainName(interfaceName)} -s ${clientIp} -d ${entry.ip}`;
if (entry.port) {
// Port-specific rules
@ -165,24 +174,25 @@ export const firewall = {
* Initialize the custom chain if it doesn't exist
*/
async initChain(interfaceName: string, enableIpv6: boolean): Promise<void> {
const chain = chainName(interfaceName);
FW_DEBUG(
`Initializing firewall chain ${CHAIN_NAME} for interface ${interfaceName}`
`Initializing firewall chain ${chain} for interface ${interfaceName}`
);
// Create chain if not exists (iptables returns error if exists, so we ignore)
await exec(`iptables -N ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`iptables -N ${chain} 2>/dev/null || true`);
if (enableIpv6) {
await exec(`ip6tables -N ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -N ${chain} 2>/dev/null || true`);
}
// Ensure chain is referenced from FORWARD (if not already)
// Insert at position 1 to process before generic ACCEPT rules
await exec(
`iptables -C FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || iptables -I FORWARD 1 -i ${interfaceName} -j ${CHAIN_NAME}`
`iptables -C FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || iptables -I FORWARD 1 -i ${interfaceName} -j ${chain}`
);
if (enableIpv6) {
await exec(
`ip6tables -C FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || ip6tables -I FORWARD 1 -i ${interfaceName} -j ${CHAIN_NAME}`
`ip6tables -C FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || ip6tables -I FORWARD 1 -i ${interfaceName} -j ${chain}`
);
}
},
@ -190,11 +200,12 @@ export const firewall = {
/**
* Flush all rules in the custom chain
*/
async flushChain(enableIpv6: boolean): Promise<void> {
FW_DEBUG(`Flushing firewall chain ${CHAIN_NAME}`);
await exec(`iptables -F ${CHAIN_NAME} 2>/dev/null || true`);
async flushChain(interfaceName: string, enableIpv6: boolean): Promise<void> {
const chain = chainName(interfaceName);
FW_DEBUG(`Flushing firewall chain ${chain}`);
await exec(`iptables -F ${chain} 2>/dev/null || true`);
if (enableIpv6) {
await exec(`ip6tables -F ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -F ${chain} 2>/dev/null || true`);
}
},
@ -202,6 +213,7 @@ export const firewall = {
* Apply firewall rules for a single client
*/
async applyClientRules(
interfaceName: string,
client: FirewallClient,
defaultAllowedIps: string[],
enableIpv6: boolean
@ -226,13 +238,23 @@ export const firewall = {
if (destIsIpv6) {
if (enableIpv6) {
const rules = generateRuleArgs(client.ipv6Address, parsed, comment);
const rules = generateRuleArgs(
interfaceName,
client.ipv6Address,
parsed,
comment
);
for (const rule of rules) {
await exec(`ip6tables ${rule}`);
}
}
} else {
const rules = generateRuleArgs(client.ipv4Address, parsed, comment);
const rules = generateRuleArgs(
interfaceName,
client.ipv4Address,
parsed,
comment
);
for (const rule of rules) {
await exec(`iptables ${rule}`);
}
@ -271,12 +293,13 @@ export const firewall = {
await this.initChain(wgInterface.name, enableIpv6);
// Flush existing rules
await this.flushChain(enableIpv6);
await this.flushChain(wgInterface.name, enableIpv6);
// Apply rules for each enabled client
for (const client of clients) {
if (!client.enabled) continue;
await this.applyClientRules(
wgInterface.name,
client,
userConfig.defaultAllowedIps,
enableIpv6
@ -284,9 +307,10 @@ export const firewall = {
}
// Add final DROP for any traffic not explicitly allowed
await exec(`iptables -A ${CHAIN_NAME} -j DROP`);
const chain = chainName(wgInterface.name);
await exec(`iptables -A ${chain} -j DROP`);
if (enableIpv6) {
await exec(`ip6tables -A ${CHAIN_NAME} -j DROP`);
await exec(`ip6tables -A ${chain} -j DROP`);
}
FW_DEBUG('Firewall rules rebuilt successfully');
@ -309,24 +333,33 @@ export const firewall = {
interfaceName: string,
enableIpv6: boolean
): Promise<void> {
const chain = chainName(interfaceName);
FW_DEBUG(`Removing firewall filtering for interface ${interfaceName}`);
// Remove jump rules from FORWARD chain
// This disconnects the interface from the old shared chain, but it won't delete the chain itself.
// Other wg-easy instances on the same host may still rely on it.
await exec(
`iptables -D FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || true`
);
await exec(
`iptables -D FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || true`
);
if (enableIpv6) {
await exec(
`ip6tables -D FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || true`
);
await exec(
`ip6tables -D FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || true`
);
}
// Flush and delete the chain
await exec(`iptables -F ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`iptables -X ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`iptables -F ${chain} 2>/dev/null || true`);
await exec(`iptables -X ${chain} 2>/dev/null || true`);
if (enableIpv6) {
await exec(`ip6tables -F ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -X ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -F ${chain} 2>/dev/null || true`);
await exec(`ip6tables -X ${chain} 2>/dev/null || true`);
}
},

View file

@ -0,0 +1,21 @@
/**
* Default interface name
*/
const DEFAULT_INTERFACE_NAME = 'wg0';
/**
* `wg-quick` compliant interface naming
*/
const INTERFACE_NAME_REGEX = /^[a-zA-Z0-9_=+.-]{1,15}$/;
export function parseInterfaceName(value: string | undefined): string {
if (!value) {
return DEFAULT_INTERFACE_NAME;
}
if (!INTERFACE_NAME_REGEX.test(value)) {
throw new Error(`Invalid interface name: ${value}`);
}
return value;
}

View file

@ -96,7 +96,7 @@ async function getReverseDns(ip: string) {
}
}
function getPrivateInformation() {
function getPrivateInformation(interfaceName: string) {
const interfaces = networkInterfaces();
const interfaceNames = Object.keys(interfaces);
@ -104,7 +104,7 @@ function getPrivateInformation() {
const obj: Record<string, { ipv4: string[]; ipv6: string[] }> = {};
for (const name of interfaceNames) {
if (name === 'wg0') {
if (name === interfaceName) {
continue;
}
@ -132,10 +132,10 @@ function getPrivateInformation() {
return obj;
}
async function getIpInformation() {
export async function getIpInformation(interfaceName: string) {
const results = [];
const publicInfo = await getPublicInformation();
const publicInfo = await cachedGetPublicInformation();
if (publicInfo.ipv4) {
results.push({
value: publicInfo.ipv4,
@ -155,7 +155,7 @@ async function getIpInformation() {
});
}
const privateInfo = getPrivateInformation();
const privateInfo = getPrivateInformation(interfaceName);
for (const [name, { ipv4, ipv6 }] of Object.entries(privateInfo)) {
for (const ip of ipv4) {
results.push({
@ -175,9 +175,9 @@ async function getIpInformation() {
}
/**
* Fetch IP Information
* Fetch public IP Information
* @cache Response is cached for 15 min
*/
export const cachedGetIpInformation = cacheFunction(getIpInformation, {
const cachedGetPublicInformation = cacheFunction(getPublicInformation, {
expiry: 15 * 60 * 1000,
});

View file

@ -0,0 +1,17 @@
import type { H3Event } from 'h3';
import { WG_ENV } from '#server/utils/config';
import { getTrustedRequestIP } from '#server/utils/trustedProxy';
type SecurityEvent = 'password' | '2fa' | 'one-time-link';
export function logSecurityEvent(
event: H3Event,
type: SecurityEvent,
username?: string
) {
const user = username ? ` username=${JSON.stringify(username)}` : '';
const ip = getTrustedRequestIP(event, WG_ENV.TRUSTED_PROXIES) ?? 'unknown';
console.warn(`Security failure: type=${type}${user} ip=${ip}`);
}

View file

@ -19,6 +19,7 @@ export function removeNewlines(templ: string) {
* - ipv4Cidr: IPv4 CIDR
* - ipv6Cidr: IPv6 CIDR
* - device: Network device
* - interface: WireGuard interface
* - port: Port number
* - uiPort: UI port number
*/
@ -27,6 +28,7 @@ export function iptablesTemplate(templ: string, wgInterface: InterfaceType) {
ipv4Cidr: wgInterface.ipv4Cidr,
ipv6Cidr: wgInterface.ipv6Cidr,
device: wgInterface.device,
interface: wgInterface.name,
port: wgInterface.port.toString(),
uiPort: WG_ENV.PORT,
});

View file

@ -61,6 +61,15 @@ export function getTrustedRequestURL(
});
}
export function getTrustedRequestIP(
event: H3Event,
trustedProxies: readonly string[]
): string | undefined {
return getRequestIP(event, {
xForwardedFor: isRequestFromTrustedProxy(event, trustedProxies),
});
}
function isRequestFromTrustedProxy(
event: H3Event,
trustedProxies: readonly string[]

View file

@ -46,38 +46,87 @@ export const JmaxSchema = z.number().max(1280).nullable();
export const SSchema = z.number().max(1132).nullable();
const RANGE_REGEX = /^(\d+)(?:-(\d+))?$/;
function parseRange(value: string) {
const match = RANGE_REGEX.exec(value);
if (!match) return null;
const lower = Number(match[1]);
const upper = match[2] === undefined ? lower : Number(match[2]);
if (!Number.isSafeInteger(lower) || !Number.isSafeInteger(upper)) return null;
return { lower, upper };
}
/**
* AmneziaWG accepts either a single number or an inclusive `lower-upper` range
* for several parameters (see `u32_range_from_string` / `u16_range_from_string`
* in amneziawg-tools). Values outside of `[min, max]` are silently truncated by
* the parser, so they are rejected here instead.
*/
function rangeSchema(min: number, max: number) {
return z
.string()
.transform((v) => v.replace(/\s+/g, ''))
.refine(
(v) => {
const range = parseRange(v);
if (!range) return false;
return (
range.lower >= min && range.upper <= max && range.lower <= range.upper
);
},
{
message: t('zod.generic.validNumberRange'),
}
)
.transform((v) => {
const range = parseRange(v);
// cannot happen, the refine above already rejected unparsable values
if (!range) return v;
return range.lower === range.upper
? `${range.lower}`
: `${range.lower}-${range.upper}`;
})
.nullable();
}
const H_MIN = 5;
const H_MAX = 2 ** 31 - 1;
export const HSchema = z
/** H1-H4 packet header types, parsed as a uint32 range */
export const HSchema = rangeSchema(H_MIN, H_MAX);
/**
* AmneziaWG 3.0+ parameters that are parsed as a uint16 range:
* ContentPaddingAddition, RekeyAfterTime, RekeyTimeout, RejectAfterTime,
* KeepaliveTimeout and MaxHandshakeAttempts.
*/
export const AwgRangeSchema = rangeSchema(0, 65535);
/** Length of a WireGuard key in bytes */
const KEY_LENGTH = 32;
/**
* A 32 byte key in canonical base64, the same format WireGuard uses for its
* own keys. Used by the AmneziaWG 3.0+ `HeaderProtectionKey` parameter.
*/
export const KeySchema = z
.string()
.transform((v) => v.replace(/\s+/g, ''))
.pipe(safeStringRefine)
.pipe(controlStringRefine)
.refine(
(v) => {
if (!v) return false;
const key = Buffer.from(v, 'base64');
if (!/^\d+(-\d+)?$/.test(v)) return false;
if (!v.includes('-')) {
const num = Number(v);
return num >= H_MIN && num <= H_MAX;
}
const [min, max] = v.split('-').map(Number);
return min && max && min >= H_MIN && max <= H_MAX && min <= max;
return false;
return key.length === KEY_LENGTH && key.toString('base64') === v;
},
{
message: t('zod.generic.validNumberRange'),
}
{ message: t('zod.keyMalformed') }
)
.transform((v) => {
if (!v.includes('-')) return `${Number(v)}`;
const [min, max] = v.split('-').map(Number);
return min === max ? `${min}` : `${min}-${max}`;
})
.nullable();
export const ISchema = z

View file

@ -1,9 +1,15 @@
import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import {
buildAwgLines,
clientAwgParameters,
interfaceAwgParameters,
} from '#server/utils/awg';
import { removeNewlines, iptablesTemplate } from '#server/utils/template';
import { exec } from '#server/utils/cmd';
import { WG_ENV } from '#server/utils/config';
import { formatEndpoint } from '#server/utils/endpoint';
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
import type { UserConfigType } from '#db/repositories/userConfig/types';
@ -58,34 +64,10 @@ AllowedIPs = ${allowedIps.join(', ')}${extraLines.length ? `\n${extraLines.join(
`${ipv4Addr}/${cidr4.prefix}` +
(enableIpv6 ? `, ${ipv6Addr}/${cidr6.prefix}` : '');
let awgLines: string[] = [];
if (wgExecutable === 'awg') {
const parameters = {
Jc: wgInterface.jC,
Jmin: wgInterface.jMin,
Jmax: wgInterface.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: wgInterface.i1,
I2: wgInterface.i2,
I3: wgInterface.i3,
I4: wgInterface.i4,
I5: wgInterface.i5,
} as const;
awgLines = Object.entries(parameters)
.filter(([_, value]) => !!value)
.map(([key, value]) => `${key} = ${value}`);
}
const extraLines = [...awgLines].filter((v) => v !== null);
const extraLines =
wgExecutable === 'awg'
? buildAwgLines(interfaceAwgParameters(wgInterface))
: [];
return `# Note: Do not edit this file directly.
# Your changes will be overwritten!
@ -127,32 +109,10 @@ PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`;
const dnsLine =
dnsServers.length > 0 ? `DNS = ${dnsServers.join(', ')}` : null;
let awgLines: string[] = [];
if (wgExecutable === 'awg') {
const parameters = {
Jc: client.jC,
Jmin: client.jMin,
Jmax: client.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: client.i1,
I2: client.i2,
I3: client.i3,
I4: client.i4,
I5: client.i5,
} as const;
awgLines = Object.entries(parameters)
.filter(([_, value]) => !!value)
.map(([key, value]) => `${key} = ${value}`);
}
const awgLines =
wgExecutable === 'awg'
? buildAwgLines(clientAwgParameters(wgInterface, client))
: [];
const extraLines = [dnsLine, ...hookLines, ...awgLines].filter(
(v) => v !== null
@ -168,7 +128,7 @@ PublicKey = ${wgInterface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${(client.allowedIps ?? userConfig.defaultAllowedIps).join(', ')}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${userConfig.host}:${userConfig.port}`;
Endpoint = ${formatEndpoint(userConfig.host, userConfig.port)}`;
},
generatePrivateKey: () => {

222
src/test/unit/awg.spec.ts Normal file
View file

@ -0,0 +1,222 @@
import { describe, expect, test } from 'vitest';
import {
buildAwgLines,
clientAwgParameters,
interfaceAwgParameters,
} from '#server/utils/awg';
import { InterfaceUpdateSchema } from '#db/repositories/interface/types';
import { AwgRangeSchema, HSchema, KeySchema } from '#server/utils/types';
/** Any valid 32 byte base64 key, generated with `wg genkey` */
const KEY = 'JPJ2DWzJbwrRkY6cPbZaJasL6bHQOM6J2Ollx4D/OHU=';
const wgInterface = {
jC: 7,
jMin: 10,
jMax: 1000,
s1: 128,
s2: 56,
s3: null,
s4: null,
h1: '5',
h2: null,
h3: null,
h4: null,
i1: null,
i2: null,
i3: null,
i4: null,
i5: null,
headerProtectionKey: null,
contentPaddingAddition: null,
rekeyAfterTime: null,
rekeyTimeout: null,
rejectAfterTime: null,
keepaliveTimeout: null,
maxHandshakeAttempts: null,
randomTrailers: null,
disableCookies: null,
};
const client = {
jC: null,
jMin: null,
jMax: null,
i1: null,
i2: null,
i3: null,
i4: null,
i5: null,
contentPaddingAddition: null,
rekeyAfterTime: null,
rekeyTimeout: null,
rejectAfterTime: null,
keepaliveTimeout: null,
maxHandshakeAttempts: null,
disableCookies: null,
};
describe('AmneziaWG config lines', () => {
test('leaves out parameters that are not set', () => {
expect(buildAwgLines({ Jc: null, Jmin: null })).toEqual([]);
});
test('writes booleans as on/off, not true/false', () => {
expect(
buildAwgLines({ RandomTrailers: true, DisableCookies: false })
).toEqual(['RandomTrailers = on', 'DisableCookies = off']);
});
test('keeps ranges verbatim', () => {
expect(buildAwgLines({ RekeyAfterTime: '90-150' })).toEqual([
'RekeyAfterTime = 90-150',
]);
});
test('builds the interface parameters', () => {
expect(
buildAwgLines(
interfaceAwgParameters({
...wgInterface,
headerProtectionKey: KEY,
maxHandshakeAttempts: '5',
randomTrailers: true,
disableCookies: false,
})
)
).toEqual([
'Jc = 7',
'Jmin = 10',
'Jmax = 1000',
'S1 = 128',
'S2 = 56',
'H1 = 5',
`HeaderProtectionKey = ${KEY}`,
'MaxHandshakeAttempts = 5',
'RandomTrailers = on',
'DisableCookies = off',
]);
});
test('copies parameters that must match from the interface to the client', () => {
const lines = buildAwgLines(
clientAwgParameters(
{ ...wgInterface, headerProtectionKey: KEY, randomTrailers: true },
{ ...client, rekeyTimeout: '4-8', disableCookies: true }
)
);
expect(lines).toContain(`HeaderProtectionKey = ${KEY}`);
expect(lines).toContain('RandomTrailers = on');
expect(lines).toContain('S1 = 128');
expect(lines).toContain('H1 = 5');
expect(lines).toContain('RekeyTimeout = 4-8');
expect(lines).toContain('DisableCookies = on');
});
test('does not inherit per-client parameters from the interface', () => {
const lines = buildAwgLines(
clientAwgParameters(
{ ...wgInterface, jC: 7, maxHandshakeAttempts: '9' },
client
)
);
expect(lines).not.toContain('Jc = 7');
expect(lines).not.toContain('MaxHandshakeAttempts = 9');
});
});
describe('AwgRangeSchema', () => {
test('accepts a single number and an inclusive range', () => {
expect(AwgRangeSchema.parse('30')).toBe('30');
expect(AwgRangeSchema.parse('30-90')).toBe('30-90');
expect(AwgRangeSchema.parse(' 30 - 90 ')).toBe('30-90');
expect(AwgRangeSchema.parse('0')).toBe('0');
expect(AwgRangeSchema.parse(null)).toBeNull();
});
test('collapses a range with identical bounds', () => {
expect(AwgRangeSchema.parse('30-30')).toBe('30');
});
test('rejects values the awg parser would truncate or refuse', () => {
expect(() => AwgRangeSchema.parse('65536')).toThrow();
expect(() => AwgRangeSchema.parse('90-30')).toThrow();
expect(() => AwgRangeSchema.parse('-5')).toThrow();
expect(() => AwgRangeSchema.parse('1-2-3')).toThrow();
expect(() => AwgRangeSchema.parse('abc')).toThrow();
expect(() => AwgRangeSchema.parse('')).toThrow();
});
test('does not change the H1-H4 bounds', () => {
expect(HSchema.parse('5')).toBe('5');
expect(() => HSchema.parse('4')).toThrow();
expect(HSchema.parse('5-2147483647')).toBe('5-2147483647');
expect(() => HSchema.parse('5-2147483648')).toThrow();
});
});
describe('KeySchema', () => {
test('accepts a 32 byte base64 key', () => {
expect(KeySchema.parse(KEY)).toBe(KEY);
expect(KeySchema.parse(null)).toBeNull();
});
test('rejects keys of the wrong length or encoding', () => {
expect(() => KeySchema.parse('too-short')).toThrow();
expect(() => KeySchema.parse(KEY.slice(0, -1))).toThrow();
expect(() => KeySchema.parse(`${KEY}${KEY}`)).toThrow();
// non canonical: the trailing bits of the last character are not zero
expect(() => KeySchema.parse(`${KEY.slice(0, 42)}B=`)).toThrow();
});
test('rejects newlines that would inject extra config lines', () => {
expect(() =>
KeySchema.parse(`${KEY}\nPostUp = touch /tmp/pwned`)
).toThrow();
});
});
describe('InterfaceUpdateSchema', () => {
const base = {
ipv4Cidr: '10.8.0.0/24',
ipv6Cidr: 'fdcc:ad94:bacf:61a3::/64',
mtu: 1420,
routingTable: 'auto',
...wgInterface,
port: 51820,
device: 'eth0',
enabled: true,
firewallEnabled: false,
};
test('accepts an interface without header protection', () => {
expect(() => InterfaceUpdateSchema.parse(base)).not.toThrow();
});
test('requires S1-S4 to be at least 12 when a key is set', () => {
const result = InterfaceUpdateSchema.safeParse({
...base,
headerProtectionKey: KEY,
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path[0])).toEqual([
's3',
's4',
]);
});
test('accepts header protection with large enough junk sizes', () => {
expect(() =>
InterfaceUpdateSchema.parse({
...base,
headerProtectionKey: KEY,
s3: 12,
s4: 40,
})
).not.toThrow();
});
});

View file

@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { formatEndpoint } from '#server/utils/endpoint';
describe('formatEndpoint', () => {
test('keeps IPv4 and hostnames unbracketed', () => {
expect(formatEndpoint('203.0.113.10', 51820)).toBe('203.0.113.10:51820');
expect(formatEndpoint('vpn.example.com', 51820)).toBe(
'vpn.example.com:51820'
);
});
test('wraps IPv6 hosts in brackets', () => {
expect(formatEndpoint('2001:db8::1', 51820)).toBe('[2001:db8::1]:51820');
expect(formatEndpoint('[2001:db8::1]', 51820)).toBe('[2001:db8::1]:51820');
});
});

View file

@ -18,7 +18,7 @@ describe('firewall', () => {
describe('IPv4-only chain management', () => {
test('does not invoke ip6tables when initializing and flushing', async () => {
await firewall.initChain('wg0', false);
await firewall.flushChain(false);
await firewall.flushChain('wg0', false);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -C FORWARD -i wg0')
@ -40,6 +40,30 @@ describe('firewall', () => {
});
});
describe('interface scoped chains', () => {
test('uses one chain per interface', async () => {
await firewall.initChain('wg1', false);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -N WG_CLIENTS_wg1')
);
expect(execMock).not.toHaveBeenCalledWith(
expect.stringContaining('iptables -N WG_CLIENTS ')
);
});
test('stops referencing the legacy chain when removing filtering', async () => {
await firewall.removeFiltering('wg1', false);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -D FORWARD -i wg1 -j WG_CLIENTS ')
);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -X WG_CLIENTS_wg1')
);
});
});
describe('isValidFirewallEntry', () => {
test('invalid ips', () => {
expect(() => typesTestExports.FirewallIpEntrySchema.parse('')).toThrow();
@ -290,41 +314,43 @@ describe('firewall', () => {
describe('generateRuleArgs', () => {
test('includes comment when provided', () => {
const rules = firewallTestExports.generateRuleArgs(
'wg0',
'10.8.0.2',
{ ip: '10.0.0.1' },
'client 1: test'
);
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -m comment --comment "client 1: test" -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -m comment --comment "client 1: test" -j ACCEPT',
]);
});
test('omits comment when not provided', () => {
const rulesTcp = firewallTestExports.generateRuleArgs('10.8.0.2', {
const rulesTcp = firewallTestExports.generateRuleArgs('wg0', '10.8.0.2', {
ip: '10.0.0.1',
port: 80,
proto: 'tcp',
});
expect(rulesTcp).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 80 -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 80 -j ACCEPT',
]);
const rulesUdp = firewallTestExports.generateRuleArgs('10.8.0.2', {
const rulesUdp = firewallTestExports.generateRuleArgs('wg0', '10.8.0.2', {
ip: '10.0.0.1',
port: 80,
proto: 'udp',
});
expect(rulesUdp).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 80 -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 80 -j ACCEPT',
]);
});
test('comment with port generates two rules for both proto', () => {
const rules = firewallTestExports.generateRuleArgs(
'wg0',
'10.8.0.2',
{ ip: '10.0.0.1', port: 443, proto: 'both' },
'client 2: phone'
);
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
]);
});
});

View file

@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { parseInterfaceName } from '#server/utils/interfaceName';
describe('parseInterfaceName', () => {
test('returns the default interface when a custom name is not specified', () => {
expect(parseInterfaceName(undefined)).toBe('wg0');
expect(parseInterfaceName('')).toBe('wg0');
});
test('accepts names compliant with wg-quick', () => {
expect(parseInterfaceName('wg1')).toBe('wg1');
expect(parseInterfaceName('wg-home')).toBe('wg-home');
expect(parseInterfaceName('a'.repeat(15))).toBe('a'.repeat(15));
});
test('rejects invalid names', () => {
expect(() => parseInterfaceName('wg 0')).toThrow(
'Invalid interface name: wg 0'
);
expect(() => parseInterfaceName('wg0; id')).toThrow();
expect(() => parseInterfaceName('wg/0')).toThrow();
expect(() => parseInterfaceName('a'.repeat(16))).toThrow();
});
});

View file

@ -4,6 +4,7 @@ import { createEvent, type H3Event } from 'h3';
import { describe, expect, test } from 'vitest';
import {
getTrustedRequestIP,
getTrustedRequestHost,
getTrustedRequestURL,
parseTrustedProxies,
@ -35,9 +36,11 @@ describe('trusted request helpers', () => {
const event = createTestEvent('192.0.2.10', {
host: 'wg-easy:51821',
'x-forwarded-host': 'vpn.example.com',
'x-forwarded-for': '198.51.100.25',
'x-forwarded-proto': 'https',
});
expect(getTrustedRequestIP(event, ['10.0.0.0/8'])).toBe('192.0.2.10');
expect(getTrustedRequestHost(event, ['10.0.0.0/8'])).toBe('wg-easy:51821');
expect(getTrustedRequestURL(event, ['10.0.0.0/8']).origin).toBe(
'http://wg-easy:51821'
@ -50,9 +53,11 @@ describe('trusted request helpers', () => {
const event = createTestEvent('10.0.0.2', {
host: 'wg-easy:51821',
'x-forwarded-host': 'vpn.example.com:8443, wg-easy:51821',
'x-forwarded-for': '198.51.100.25',
'x-forwarded-proto': 'HTTPS, http',
});
expect(getTrustedRequestIP(event, ['10.0.0.0/8'])).toBe('198.51.100.25');
expect(getTrustedRequestHost(event, ['10.0.0.0/8'])).toBe(
'vpn.example.com:8443'
);
@ -64,10 +69,16 @@ describe('trusted request helpers', () => {
);
expect(event.node.req.headers['x-forwarded-proto']).toBe('HTTPS, http');
});
test('returns undefined when no request address is available', () => {
const event = createTestEvent(undefined, {});
expect(getTrustedRequestIP(event, ['10.0.0.0/8'])).toBeUndefined();
});
});
function createTestEvent(
remoteAddress: string,
remoteAddress: string | undefined,
headers: IncomingMessage['headers']
): H3Event {
const request = {

View file

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