Compare commits

..

21 Commits

Author SHA1 Message Date
Christoph Haas
ee454c5d60 allow to override embedded frontend (#533) 2025-12-09 23:42:32 +01:00
Christoph Haas
fb607a26b7 allow custom mail templates (#533) 2025-12-09 23:07:58 +01:00
rwjack
54ca1d8aed Add Pfsense backend (ALPHA) (#585)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
* Add pfSense backend domain types and configuration

This adds the necessary domain types and configuration structures
for the pfSense backend support. Includes PfsenseInterfaceExtras and
PfsensePeerExtras structs, and the BackendPfsense configuration
with API URL, key, and timeout settings.

* Add low-level pfSense REST API client

Implements the HTTP client for interacting with the pfSense REST API.
Handles authentication via X-API-Key header, request/response parsing,
and error handling. Uses the pfSense REST API v2 endpoints as documented
at https://pfrest.org/.

* Implement pfSense WireGuard controller

This implements the InterfaceController interface for pfSense firewalls.
Handles WireGuard tunnel and peer management through the pfSense REST API.
Includes proper filtering of peers by interface (since API filtering doesn't
work) and parsing of the allowedips array structure with address/mask fields.

* Register pfSense controllers and update configuration

Registers the pfSense backend controllers in the controller manager
and adds example configuration to config.yml.sample. Also updates
README to mention pfSense backend support.

* Fix peer filtering and allowedips parsing for pfSense backend

The pfSense REST API doesn't support filtering peers by interface
via query parameters, so all peers are returned regardless of the
filter. This caused peers from all interfaces to be randomly assigned
to a single interface in wg-portal.

Additionally, the API returns allowedips as an array of objects with
"address" and "mask" fields instead of a comma-separated string,
which caused parsing failures.

Changes:
- Remove API filter from GetPeers() since it doesn't work
- Add client-side filtering by checking the "tun" field in peer responses
- Update convertWireGuardPeer() to parse allowedips array structure
- Add parseAddressArray() helper for parsing address objects
- Attempt to fetch interface addresses from /tunnel/{id}/address endpoint
  (endpoint may not be available in all pfSense versions)
- Add debug logging for peer filtering and address loading operations

Note: Interface addresses may still be empty if the address endpoint
is not available. Public Endpoint and Default DNS Servers are typically
configured manually in wg-portal as the pfSense API doesn't provide
this information.

* Extract endpoint, DNS, and peer names from pfSense peer data

The pfSense API provides endpoint, port, and description (descr) fields
in peer responses that can be used to populate interface defaults and
peer display names.

Changes:
- Extract endpoint and port from peers and combine them properly
- Fix peer name/description extraction to check "descr" field first
  (pfSense API uses "descr" instead of "description" or "comment")
- Add extractPfsenseDefaultsFromPeers() helper to extract common
  endpoint and DNS from peers during interface import
- Set PeerDefEndpoint and PeerDefDnsStr from peer data for pfSense
  backends during interface import
- Use most common endpoint/DNS values when multiple peers are present

* Fix interface display name to use descr field from pfSense API

The pfSense API uses "descr" field for tunnel descriptions, not
"description" or "comment". Updated convertWireGuardInterface()
to check "descr" first so that tunnel descriptions (e.g., "HQ VPN")
are displayed in the UI instead of just the tunnel name (e.g., "tun_wg0").

* Remove calls to non-working tunnel and peer detail endpoints

The pfSense REST API endpoints /api/v2/vpn/wireguard/tunnel/{id}
and /api/v2/vpn/wireguard/tunnel/{id}/address don't work and were
causing log spam. Removed these calls and use only the data from
the tunnel/peer list responses.

Also removed the peer detail endpoint call that was added for
statistics collection, as it likely doesn't work either.

* Fix unused variable compilation error

Removed unused deviceId variable that was causing build failure.

* Optimize tunnel address fetching to use /tunnel?id endpoint

Instead of using the separate /tunnel/address endpoint, now query
the specific tunnel endpoint /tunnel?id={id} which includes the
addresses array in the response. This avoids unnecessary API calls
and simplifies the code.

- GetInterface() now queries /tunnel?id={id} after getting tunnel ID
- loadInterfaceData() queries /tunnel?id={id} as fallback if addresses missing
- extractAddresses() properly parses addresses array from tunnel response
- Removed /tunnel/address endpoint calls

Signed-off-by: rwjack <jack@foss.family>

* Fix URL encoding issue in tunnel endpoint queries

Use Filters in PfsenseRequestOptions instead of passing query strings
directly in the path. This prevents the ? character from being encoded
as %3F, which was causing 404 errors.

- GetInterface() now uses Filters map for id parameter
- loadInterfaceData() now uses Filters map for id parameter

Signed-off-by: rwjack <jack@foss.family>

* update backend docs for pfsense

---------

Signed-off-by: rwjack <jack@foss.family>
2025-12-09 22:33:12 +01:00
Christoph Haas
a318118ee6 chore: update dependencies 2025-12-09 22:19:29 +01:00
dependabot[bot]
a8b4b23742 chore(deps): bump the actions group across 1 directory with 4 updates (#591)
Bumps the actions group with 4 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [actions/setup-python](https://github.com/actions/setup-python), [docker/metadata-action](https://github.com/docker/metadata-action) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `actions/checkout` from 5.0.0 to 6.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](08c6903cd8...8e8c483db8)

Updates `actions/setup-python` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](e797f83bcb...83679a892e)

Updates `docker/metadata-action` from 5.9.0 to 5.10.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](318604b99e...c299e40c65)

Updates `softprops/action-gh-release` from 2.4.2 to 2.5.0
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](5be0e66d93...a06a81a03e)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: 5.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-09 22:11:04 +01:00
Christoph
a1fcce6fde set file permissions to 0600 for the sqlite database (#579)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-11-23 20:33:04 +01:00
Potorochin Max
364f7b3a5b Update russian translation (#574)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Signed-off-by: ornaras <ornaras.us@gmail.com>
2025-11-21 13:50:47 +01:00
Christoph Haas
907bb0599a fix race condition during ldap initialization (#571)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2025-11-20 18:28:20 +01:00
Christoph
d759fc7dc7 allow to log raw LDAP user data (#571)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-11-19 16:00:11 +01:00
Christoph
67192170fc doc: fix incorrect config examples
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2025-11-18 23:23:49 +01:00
Isak Wertwein
8f25bef050 feat: config by environment variables (#570)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* feat: config by environment variables without config file

Signed-off-by: Isak Wertwein <isak.wertwein@gmail.com>

* string slice by environment variable

Signed-off-by: Isak Wertwein <isak.wertwein@gmail.com>

---------

Signed-off-by: Isak Wertwein <isak.wertwein@gmail.com>
2025-11-16 18:33:25 +01:00
Christoph Haas
8bc4990441 chore: update frontend and backend dependencies
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-11-13 20:01:47 +01:00
Christoph Haas
80dc7f290a correct enum for User-Source in api doc (#562) 2025-11-13 20:00:37 +01:00
dependabot[bot]
de91506bfa chore(deps): bump the patch group across 1 directory with 2 updates (#566)
Bumps the patch group with 2 updates in the / directory: [gorm.io/driver/sqlserver](https://github.com/go-gorm/sqlserver) and [gorm.io/gorm](https://github.com/go-gorm/gorm).


Updates `gorm.io/driver/sqlserver` from 1.6.1 to 1.6.3
- [Commits](https://github.com/go-gorm/sqlserver/compare/v1.6.1...v1.6.3)

Updates `gorm.io/gorm` from 1.31.0 to 1.31.1
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.31.0...v1.31.1)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlserver
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-13 19:35:29 +01:00
dependabot[bot]
380d71ba07 chore(deps): bump softprops/action-gh-release in the actions group (#567)
Bumps the actions group with 1 update: [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `softprops/action-gh-release` from 2.4.1 to 2.4.2
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](6da8fa9354...5be0e66d93)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-13 19:34:54 +01:00
Osvaldo-Net
3d4a190949 Mejoras en la traducción al español y añade traducción para la herramienta de calculadora y cambio de contraseña. (#568)
* Mejorar traducción al español

* Update es.json

* Mejorar/Añadir traducciones al español
2025-11-13 17:48:33 +01:00
David Gonzalez
df450cf384 fix(helm): Append prerelease pattern for Helm kubeVersion check (#560)
Some checks failed
Chart / lint-test (push) Has been cancelled
Chart / publish (push) Has been cancelled
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-11-09 20:53:31 +01:00
Dmytro Bondar
9fbebc82f6 Update and pin all actions versions (#564)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Signed-off-by: Dmytro Bondar <git@bonddim.dev>
2025-11-07 23:17:00 +01:00
Christoph Haas
7c557d3e66 add german translation for ip calculator (#503) 2025-11-07 23:13:24 +01:00
Christoph Haas
bda99464f1 fix path parameter handling in REST api (#563) 2025-11-07 23:12:36 +01:00
Tomáš Lukča
d66a4b71b8 add IPCalculator View (#557)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* create IPCalculator View + add cidr_tools package

* fixed translation and comma separated ip as placeholder
2025-11-03 17:40:42 +01:00
50 changed files with 3121 additions and 598 deletions

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
@@ -35,16 +35,16 @@ jobs:
# ct lint requires Python 3.x to run following packages:
# - yamale (https://github.com/23andMe/Yamale)
# - yamllint (https://github.com/adrienverge/yamllint)
- uses: actions/setup-python@v6
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.x'
- uses: helm/chart-testing-action@v2
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
- name: Run chart-testing (lint)
run: ct lint --config ct.yaml
- uses: nolar/setup-k3d-k3s@v1
- uses: nolar/setup-k3d-k3s@293b8e5822a20bc0d5bcdd4826f1a665e72aba96 # v1.0.9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -60,9 +60,9 @@ jobs:
permissions:
packages: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: docker/login-action@v3
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -18,13 +18,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get Version
shell: bash
@@ -32,14 +32,14 @@ jobs:
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -47,7 +47,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
wgportal/wg-portal
@@ -68,7 +68,7 @@ jobs:
type=semver,pattern=v{{major}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -80,7 +80,7 @@ jobs:
BUILD_VERSION=${{ env.BUILD_VERSION }}
- name: Export binaries from images
uses: docker/build-push-action@v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -96,7 +96,7 @@ jobs:
done
- name: Upload binaries
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: binaries
path: binaries/wg-portal_linux*
@@ -110,12 +110,12 @@ jobs:
contents: write
steps:
- name: Download binaries
uses: actions/download-artifact@v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: binaries
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
files: 'wg-portal_linux*'
generate_release_notes: true

View File

@@ -15,11 +15,11 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- uses: actions/setup-python@v6
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: 3.x
@@ -37,4 +37,4 @@ jobs:
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
env:
GIT_COMMITTER_NAME: "github-actions[bot]"
GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"
GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"

View File

@@ -50,7 +50,7 @@ COPY --from=builder /build/dist/wg-portal /
######
# Final image
######
FROM alpine:3.22
FROM alpine:3.23
# Install OS-level dependencies
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
# Setup timezone

View File

@@ -32,7 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
* Docker ready
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* Supports multiple WireGuard backends (wgctrl or MikroTik)
* Supports multiple WireGuard backends (wgctrl, MikroTik, or pfSense)
* Peer Expiry Feature
* Handles route and DNS settings like wg-quick does
* Exposes Prometheus metrics for monitoring and alerting

View File

@@ -12,6 +12,18 @@ core:
web:
external_url: http://localhost:8888
request_logging: true
# Optional path where custom frontend files are stored.
# If this folder contains at least one file, it will override the embedded frontend.
# If the folder is empty or does not exist on startup, the embedded frontend will be
# written into it. Leave empty to use the embedded frontend only.
frontend_filepath: ""
mail:
# Path where custom email templates (.gotpl and .gohtml) are stored.
# If the directory is empty on startup, the default embedded templates
# will be written there so you can modify them.
# Leave empty to use embedded templates only.
templates_path: ""
webhook:
url: ""
@@ -93,4 +105,16 @@ auth:
admin_value_regex: ^true$
admin_group_regex: ^admin-group-name$
registration_enabled: true
log_user_info: true
log_user_info: true
backend:
default: local
pfsense:
- id: pfsense1
display_name: "Main pfSense Firewall"
api_url: "https://pfsense.example.com" # Base URL without /api/v2 (endpoints already include it)
api_key: "your-api-key" # Generate in pfSense under 'System' -> 'REST API' -> 'Keys'
api_verify_tls: true
api_timeout: 30s
concurrency: 5
debug: false

View File

@@ -2,7 +2,7 @@ apiVersion: v2
name: wg-portal
description: WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
# Version is set to ensure compatibility with the chart's Ingress resource.
kubeVersion: ">=1.19.0"
kubeVersion: ">=1.19.0-0"
type: application
home: https://wgportal.org
icon: https://wgportal.org/latest/assets/images/logo.svg
@@ -16,7 +16,7 @@ annotations:
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.7.1
version: 0.7.2
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to

View File

@@ -1,6 +1,6 @@
# wg-portal
![Version: 0.7.1](https://img.shields.io/badge/Version-0.7.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2](https://img.shields.io/badge/AppVersion-v2-informational?style=flat-square)
![Version: 0.7.2](https://img.shields.io/badge/Version-0.7.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2](https://img.shields.io/badge/AppVersion-v2-informational?style=flat-square)
WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
@@ -12,7 +12,7 @@ WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
## Requirements
Kubernetes: `>=1.19.0`
Kubernetes: `>=1.19.0-0`
## Installing the Chart

View File

@@ -67,8 +67,7 @@ auth:
auth:
ldap:
# a sample LDAP provider with user sync enabled
- id: ldap
provider_name: Active Directory
- provider_name: ldap
url: ldap://srv-ad1.company.local:389
bind_user: ldap_wireguard@company.local
bind_pass: super-s3cr3t-ldap
@@ -99,8 +98,7 @@ auth:
oidc:
# A sample Entra ID provider with environment variable substitution.
# Only users with an @outlook.com email address are allowed to register or login.
- id: azure
provider_name: azure
- provider_name: azure
display_name: Login with</br>Entra ID
registration_enabled: true
base_url: "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0"
@@ -113,8 +111,7 @@ auth:
- email
# a sample provider where users with the attribute `wg_admin` set to `true` are considered as admins
- id: oidc-with-admin-attribute
provider_name: google
- provider_name: google
display_name: Login with</br>Google
base_url: https://accounts.google.com
client_id: the-client-id-1234.apps.googleusercontent.com
@@ -136,8 +133,7 @@ auth:
log_user_info: true
# a sample provider where users in the group `the-admin-group` are considered as admins
- id: oidc-with-admin-group
provider_name: google2
- provider_name: google2
display_name: Login with</br>Google2
base_url: https://accounts.google.com
client_id: another-client-id-1234.apps.googleusercontent.com
@@ -168,8 +164,7 @@ auth:
oauth:
# a sample provider where users with the attribute `this-attribute-must-be-true` set to `true` or `True`
# are considered as admins
- id: google_plain_oauth-with-admin-attribute
provider_name: google3
- provider_name: google3
display_name: Login with</br>Google3
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
@@ -191,8 +186,7 @@ auth:
# a sample provider where either users with the attribute `this-attribute-must-be-true` set to `true` or
# users in the group `admin-group-name` are considered as admins
- id: google_plain_oauth_with_groups
provider_name: google4
- provider_name: google4
display_name: Login with</br>Google4
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET

View File

@@ -74,6 +74,7 @@ mail:
from: Wireguard Portal <noreply@wireguard.local>
link_only: false
allow_peer_email: false
templates_path: ""
auth:
oidc: []
@@ -96,6 +97,7 @@ web:
expose_host_info: false
cert_file: ""
key_File: ""
frontend_filepath: ""
webhook:
url: ""
@@ -127,51 +129,63 @@ More advanced options are found in the subsequent `Advanced` section.
### `admin_user`
- **Default:** `admin@wgportal.local`
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_USER`
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
### `admin_password`
- **Default:** `wgportal-default`
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_PASSWORD`
- **Description:** The administrator password. The default password should be changed immediately!
- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters.
### `disable_admin_user`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_DISABLE_ADMIN_USER`
- **Description:** If `true`, no admin user is created. This is useful if you plan to manage users exclusively through external authentication providers such as LDAP or OAuth.
### `admin_api_token`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_API_TOKEN`
- **Description:** An API token for the admin user. If a token is provided, the REST API can be accessed using this token. If empty, the API is initially disabled for the admin user.
### `editable_keys`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_CORE_EDITABLE_KEYS`
- **Description:** Allow editing of WireGuard key-pairs directly in the UI.
### `create_default_peer`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
### `create_default_peer_on_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces.
### `re_enable_peer_after_user_enable`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_CORE_RE_ENABLE_PEER_AFTER_USER_ENABLE`
- **Description:** Re-enable all peers that were previously disabled if the associated user is re-enabled.
### `delete_peer_after_user_deleted`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_DELETE_PEER_AFTER_USER_DELETED`
- **Description:** If a user is deleted, remove all linked peers. Otherwise, peers remain but are disabled.
### `self_provisioning_allowed`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_SELF_PROVISIONING_ALLOWED`
- **Description:** Allow registered (non-admin) users to self-provision peers from their profile page.
### `import_existing`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_CORE_IMPORT_EXISTING`
- **Description:** On startup, import existing WireGuard interfaces and peers into WireGuard Portal.
### `restore_state`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_CORE_RESTORE_STATE`
- **Description:** Restore the WireGuard interface states (up/down) that existed before WireGuard Portal started.
---
@@ -188,11 +202,14 @@ The current MikroTik backend is in **BETA** and may not support all features.
### `local_resolvconf_prefix`
- **Default:** `tun.`
- **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX`
- **Description:** Interface name prefix for WireGuard interfaces on the local system which is used to configure DNS servers with *resolvconf*.
It depends on the *resolvconf* implementation you are using, most use a prefix of `tun.`, but some have an empty prefix (e.g., systemd).
### `ignored_local_interfaces`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_BACKEND_IGNORED_LOCAL_INTERFACES`
(comma-separated values)
- **Description:** A list of interface names to exclude when enumerating local interfaces.
This is useful if you want to prevent certain interfaces from being imported from the local system.
@@ -256,54 +273,67 @@ Additional or more specialized configuration options for logging and interface c
### `log_level`
- **Default:** `info`
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_LEVEL`
- **Description:** The log level used by the application. Valid options are: `trace`, `debug`, `info`, `warn`, `error`.
### `log_pretty`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_PRETTY`
- **Description:** If `true`, log messages are colorized and formatted for readability (pretty-print).
### `log_json`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_JSON`
- **Description:** If `true`, log messages are structured in JSON format.
### `start_listen_port`
- **Default:** `51820`
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_LISTEN_PORT`
- **Description:** The first port to use when automatically creating new WireGuard interfaces.
### `start_cidr_v4`
- **Default:** `10.11.12.0/24`
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_CIDR_V4`
- **Description:** The initial IPv4 subnet to use when automatically creating new WireGuard interfaces.
### `start_cidr_v6`
- **Default:** `fdfd:d3ad:c0de:1234::0/64`
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_CIDR_V6`
- **Description:** The initial IPv6 subnet to use when automatically creating new WireGuard interfaces.
### `use_ip_v6`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_ADVANCED_USE_IP_V6`
- **Description:** Enable or disable IPv6 support.
### `config_storage_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_ADVANCED_CONFIG_STORAGE_PATH`
- **Description:** Path to a directory where `wg-quick` style configuration files will be stored (if you need local filesystem configs).
### `expiry_check_interval`
- **Default:** `15m`
- **Environment Variable:** `WG_PORTAL_ADVANCED_EXPIRY_CHECK_INTERVAL`
- **Description:** Interval after which existing peers are checked if they are expired. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
### `rule_prio_offset`
- **Default:** `20000`
- **Environment Variable:** `WG_PORTAL_ADVANCED_RULE_PRIO_OFFSET`
- **Description:** Offset for IP route rule priorities when configuring routing.
### `route_table_offset`
- **Default:** `20000`
- **Environment Variable:** `WG_PORTAL_ADVANCED_ROUTE_TABLE_OFFSET`
- **Description:** Offset for IP route table IDs when configuring routing.
### `api_admin_only`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_ADVANCED_API_ADMIN_ONLY`
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
### `limit_additional_user_peers`
- **Default:** `0`
- **Environment Variable:** `WG_PORTAL_ADVANCED_LIMIT_ADDITIONAL_USER_PEERS`
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
---
@@ -317,18 +347,22 @@ If sensitive values (like private keys) should be stored in an encrypted format,
### `debug`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_DATABASE_DEBUG`
- **Description:** If `true`, logs all database statements (verbose).
### `slow_query_threshold`
- **Default:** "0"
- **Environment Variable:** `WG_PORTAL_DATABASE_SLOW_QUERY_THRESHOLD`
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration). The value must be a string.
### `type`
- **Default:** `sqlite`
- **Environment Variable:** `WG_PORTAL_DATABASE_TYPE`
- **Description:** The database type. Valid options: `sqlite`, `mssql`, `mysql`, `postgres`.
### `dsn`
- **Default:** `data/sqlite.db`
- **Environment Variable:** `WG_PORTAL_DATABASE_DSN`
- **Description:** The Data Source Name (DSN) for connecting to the database.
For example:
```text
@@ -337,6 +371,7 @@ If sensitive values (like private keys) should be stored in an encrypted format,
### `encryption_passphrase`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_DATABASE_ENCRYPTION_PASSPHRASE`
- **Description:** Passphrase for encrypting sensitive values such as private keys in the database. Encryption is only applied if this passphrase is set.
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
New or updated records will be encrypted; existing data remains in plaintext until its next modified.
@@ -349,38 +384,47 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
### `use_ping_checks`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_STATISTICS_USE_PING_CHECKS`
- **Description:** Enable periodic ping checks to verify that peers remain responsive.
### `ping_check_workers`
- **Default:** `10`
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_CHECK_WORKERS`
- **Description:** Number of parallel worker processes for ping checks.
### `ping_unprivileged`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_UNPRIVILEGED`
- **Description:** If `false`, ping checks run without root privileges. This is currently considered BETA.
### `ping_check_interval`
- **Default:** `1m`
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_CHECK_INTERVAL`
- **Description:** Interval between consecutive ping checks for all peers. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
### `data_collection_interval`
- **Default:** `1m`
- **Environment Variable:** `WG_PORTAL_STATISTICS_DATA_COLLECTION_INTERVAL`
- **Description:** Interval between data collection cycles (bytes sent/received, handshake times, etc.). Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
### `collect_interface_data`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_INTERFACE_DATA`
- **Description:** If `true`, collects interface-level data (bytes in/out) for monitoring and statistics.
### `collect_peer_data`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_PEER_DATA`
- **Description:** If `true`, collects peer-level data (bytes, last handshake, endpoint, etc.).
### `collect_audit_data`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_AUDIT_DATA`
- **Description:** If `true`, logs certain portal events (such as user logins) to the database.
### `listening_address`
- **Default:** `:8787`
- **Environment Variable:** `WG_PORTAL_STATISTICS_LISTENING_ADDRESS`
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8787`).
---
@@ -393,45 +437,60 @@ To send emails to all peers that have a valid email-address as user-identifier,
### `host`
- **Default:** `127.0.0.1`
- **Environment Variable:** `WG_PORTAL_MAIL_HOST`
- **Description:** Hostname or IP of the SMTP server.
### `port`
- **Default:** `25`
- **Environment Variable:** `WG_PORTAL_MAIL_PORT`
- **Description:** Port number for the SMTP server.
### `encryption`
- **Default:** `none`
- **Environment Variable:** `WG_PORTAL_MAIL_ENCRYPTION`
- **Description:** SMTP encryption type. Valid values: `none`, `tls`, `starttls`.
### `cert_validation`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_MAIL_CERT_VALIDATION`
- **Description:** If `true`, validate the SMTP server certificate (relevant if `encryption` = `tls`).
### `username`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_MAIL_USERNAME`
- **Description:** Optional SMTP username for authentication.
### `password`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_MAIL_PASSWORD`
- **Description:** Optional SMTP password for authentication.
### `auth_type`
- **Default:** `plain`
- **Environment Variable:** `WG_PORTAL_MAIL_AUTH_TYPE`
- **Description:** SMTP authentication type. Valid values: `plain`, `login`, `crammd5`.
### `from`
- **Default:** `Wireguard Portal <noreply@wireguard.local>`
- **Environment Variable:** `WG_PORTAL_MAIL_FROM`
- **Description:** The default "From" address when sending emails.
### `link_only`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_MAIL_LINK_ONLY`
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
### `allow_peer_email`
- **Default:** `false`
- **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address.
If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record.
- **Environment Variable:** `WG_PORTAL_MAIL_ALLOW_PEER_EMAIL`
- **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address.
If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record.
### `templates_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_MAIL_TEMPLATES_PATH`
- **Description:** Path to the email template files that override embedded templates. Check [usage documentation](../usage/mail-templates.md) for an example.`
---
@@ -444,12 +503,14 @@ Some core authentication options are shared across all providers, while others a
### `min_password_length`
- **Default:** `16`
- **Environment Variable:** `WG_PORTAL_AUTH_MIN_PASSWORD_LENGTH`
- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication.
The default admin password strength is also enforced by this setting.
- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters.
### `hide_login_form`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_AUTH_HIDE_LOGIN_FORM`
- **Description:** If `true`, the login form is hidden and only the OIDC, OAuth, LDAP, or WebAuthn providers are shown. This is useful if you want to enforce a specific authentication method.
If no social login providers are configured, the login form is always shown, regardless of this setting.
- **Important:** You can still access the login form by adding the `?all` query parameter to the login URL (e.g. https://wg.portal/#/login?all).
@@ -691,6 +752,10 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
```
#### `sync_log_user_info`
- **Default:** `false`
- **Description:** If `true`, logs LDAP user data at the trace level during synchronization.
#### `disable_missing`
- **Default:** `false`
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
@@ -715,6 +780,7 @@ The `webauthn` section contains configuration options for WebAuthn authenticatio
#### `enabled`
- **Default:** `true`
- **Environment Variable:** `WG_PORTAL_AUTH_WEBAUTHN_ENABLED`
- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
Users are encouraged to use Passkeys for secure authentication instead of passwords.
If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure.
@@ -727,50 +793,69 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
### `listening_address`
- **Default:** `:8888`
- **Environment Variable:** `WG_PORTAL_WEB_LISTENING_ADDRESS`
- **Description:** The listening address and port for the web server (e.g., `:8888` to bind on all interfaces or `127.0.0.1:8888` to bind only on the loopback interface).
Ensure that access to WireGuard Portal is protected against unauthorized access, especially if binding to all interfaces.
### `external_url`
- **Default:** `http://localhost:8888`
- **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL`
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
### `site_company_name`
- **Default:** `WireGuard Portal`
- **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME`
- **Description:** The company name that is shown at the bottom of the web frontend.
### `site_title`
- **Default:** `WireGuard Portal`
- **Environment Variable:** `WG_PORTAL_WEB_SITE_TITLE`
- **Description:** The title that is shown in the web frontend.
### `session_identifier`
- **Default:** `wgPortalSession`
- **Environment Variable:** `WG_PORTAL_WEB_SESSION_IDENTIFIER`
- **Description:** The session identifier for the web frontend.
### `session_secret`
- **Default:** `very_secret`
- **Environment Variable:** `WG_PORTAL_WEB_SESSION_SECRET`
- **Description:** The session secret for the web frontend.
### `csrf_secret`
- **Default:** `extremely_secret`
- **Environment Variable:** `WG_PORTAL_WEB_CSRF_SECRET`
- **Description:** The CSRF secret.
### `request_logging`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_WEB_REQUEST_LOGGING`
- **Description:** Log all HTTP requests.
### `expose_host_info`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_WEB_EXPOSE_HOST_INFO`
- **Description:** Expose the hostname and version of the WireGuard Portal server in an HTTP header. This is useful for debugging but may expose sensitive information.
### `cert_file`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_CERT_FILE`
- **Description:** (Optional) Path to the TLS certificate file.
### `key_file`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE`
- **Description:** (Optional) Path to the TLS certificate key file.
### `frontend_filepath`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_FRONTEND_FILEPATH`
- **Description:** Optional base directory from which the web frontend is served. Check out the [building](../getting-started/sources.md) documentation for more information on how to compile the frontend assets.
- If the directory contains at least one file (recursively), these files are served at `/app`, overriding the embedded frontend assets.
- If the directory is empty or does not exist on startup, the embedded frontend is copied into this directory automatically and then served.
- If left empty, the embedded frontend is served and no files are written to disk.
---
## Webhook
@@ -780,12 +865,15 @@ Further details can be found in the [usage documentation](../usage/webhooks.md).
### `url`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEBHOOK_URL`
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.
### `authentication`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEBHOOK_AUTHENTICATION`
- **Description:** The Authorization header for the webhook endpoint. The value is send as-is in the header. For example: `Bearer <token>`.
### `timeout`
- **Default:** `10s`
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
- **Environment Variable:** `WG_PORTAL_WEBHOOK_TIMEOUT`
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.

View File

@@ -512,6 +512,8 @@ definitions:
description: The source of the user. This field is optional.
enum:
- db
- ldap
- oauth
example: db
type: string
required:

View File

@@ -8,6 +8,7 @@ A global default backend determines where newly created interfaces go (unless yo
**Supported backends:**
- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server.
- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+.
- **pfSense** (_alpha_): Manages interfaces and peers on pfSense firewalls via the pfSense REST API.
How backend selection works:
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend).
@@ -54,4 +55,37 @@ backend:
### Known limitations:
- The MikroTik backend is still in beta. Some features may not work as expected.
- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks)
- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks)
## Configuring pfSense backends
> :warning: The pfSense backend is currently **alpha**. Only basic interface and peer CRUD are supported. Traffic statistics (rx/tx, last handshake) are not exposed by the pfSense REST API and will show as empty.
The pfSense backend talks to the pfSense REST API (pfSense Plus / CE with the REST API package installed). Point the backend at the appliance hostname without appending `/api/v2` — the portal appends `/api/v2` automatically.
### Prerequisites on pfSense:
- pfSense with the REST API package enabled (`System -> API`) and WireGuard configured.
- An API key with permissions for WireGuard endpoints. If you use a read-only key, set `core.restore_state: false` in `config.yml` to avoid write attempts at startup.
- HTTPS recommended; set `api_verify_tls: false` only for lab/self-signed setups.
Example WireGuard Portal configuration:
```yaml
backend:
# default backend decides where new interfaces are created
default: pfsense1
pfsense:
- id: pfsense1 # unique id, not "local"
display_name: Main pfSense # optional nice name
api_url: https://pfsense.example.com # no trailing /api/v2
api_key: your-api-key
api_verify_tls: true
api_timeout: 30s
concurrency: 5
debug: false
```
### Known limitations:
- Alpha quality: behavior and API coverage may change.
- Statistics (rx/tx bytes, last handshake) are not available from the pfSense REST API today.

View File

@@ -0,0 +1,49 @@
WireGuard Portal sends emails when you share a configuration with a user.
By default, the application uses embedded templates. You can fully customize these emails by pointing the Portal
to a folder containing your own templates. If the folder is empty on startup, the default embedded templates
are written there to get you started.
## Configuration
To enable custom templates, set the `mail.templates_path` option in the application configuration file
or the `WG_PORTAL_MAIL_TEMPLATES_PATH` environment variable to a valid folder path.
For example:
```yaml
mail:
# ... other mail options ...
# Path where custom email templates (.gotpl and .gohtml) are stored.
# If the directory is empty on startup, the default embedded templates
# will be written there so you can modify them.
# Leave empty to use embedded templates only.
templates_path: "/opt/wg-portal/mail-templates"
```
## Template files and names
The system expects the following template names. Place files with these names in your `templates_path` to override the defaults.
You do not need to override all templates, only the ones you want to customize should be present.
- Text templates (`.gotpl`):
- `mail_with_link.gotpl`
- `mail_with_attachment.gotpl`
- HTML templates (`.gohtml`):
- `mail_with_link.gohtml`
- `mail_with_attachment.gohtml`
Both [text](https://pkg.go.dev/text/template) and [HTML templates](https://pkg.go.dev/html/template) are standard Go
templates and receive the following data fields, depending on the email type:
- Common fields:
- `PortalUrl` (string) - external URL of the Portal
- `PortalName` (string) - site title/company name
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
- Link email (`mail_with_link.*`):
- `Link` (string) - the download link
- Attachment email (`mail_with_attachment.*`):
- `ConfigFileName` (string) - filename of the attached WireGuard config
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
Tip: You can inspect the embedded templates in the repository under [`internal/app/mail/tpl_files/`](https://github.com/h44z/wg-portal/tree/master/internal/app/mail/tpl_files) for reference.
When the directory at `templates_path` is empty, these files are copied to your folder so you can edit them in place.

View File

@@ -8,29 +8,30 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@fontsource/nunito-sans": "^5.2.5",
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@fontsource/nunito-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.1.0",
"@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/browser": "^13.2.2",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.7",
"bootswatch": "^5.3.7",
"flag-icons": "^7.3.2",
"ip-address": "^10.0.1",
"is-cidr": "^5.1.1",
"bootstrap": "^5.3.8",
"bootswatch": "^5.3.8",
"cidr-tools": "^11.0.3",
"flag-icons": "^7.5.0",
"ip-address": "^10.1.0",
"is-cidr": "^6.0.1",
"is-ip": "^5.0.1",
"pinia": "^3.0.2",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.3",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.5.0"
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"sass-embedded": "^1.86.3",
"vite": "^6.3.6"
"@vitejs/plugin-vue": "^6.0.2",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -43,21 +44,21 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.4"
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -67,13 +68,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -538,22 +539,22 @@
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz",
"integrity": "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@intlify/core-base": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.1.12",
"@intlify/shared": "11.1.12"
"@intlify/message-compiler": "11.2.2",
"@intlify/shared": "11.2.2"
},
"engines": {
"node": ">= 16"
@@ -563,12 +564,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.12",
"@intlify/shared": "11.2.2",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -579,9 +580,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
"license": "MIT",
"engines": {
"node": ">= 16"
@@ -597,9 +598,9 @@
"license": "MIT"
},
"node_modules/@kyvg/vue3-notification": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz",
"integrity": "sha512-WhTWCbF36JHLJR5UdKmJF7KXGOGVy4tLeaJuKTHZhwttZWnbF9w1/c2d32tvCSwY9CdeX/n9uoaKWLMKK3vOyg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.2.tgz",
"integrity": "sha512-CZ2zOdXsbGCtWbdqMgbusKtZTkMT+dYpw9bmAitsdSNHT0knh4njD8X95JIyTMWvNVjhDkFedbkNiZLcPqttwQ==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
@@ -920,12 +921,18 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.50",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
"integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
@@ -1235,9 +1242,9 @@
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.0.tgz",
"integrity": "sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A==",
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"license": "MIT"
},
"node_modules/@types/estree": {
@@ -1248,16 +1255,19 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
"integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.50"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
"vue": "^3.2.25"
}
},
@@ -1276,53 +1286,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/shared": "3.5.22",
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.25",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-core": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22",
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22",
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.25",
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.19",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-dom": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/devtools-api": {
@@ -1359,53 +1369,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.22"
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/reactivity": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
"@vue/shared": "3.5.22",
"@vue/reactivity": "3.5.25",
"@vue/runtime-core": "3.5.25",
"@vue/shared": "3.5.25",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25"
},
"peerDependencies": {
"vue": "3.5.22"
"vue": "3.5.25"
}
},
"node_modules/@vue/shared": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"license": "MIT"
},
"node_modules/birpc": {
@@ -1481,15 +1491,27 @@
}
},
"node_modules/cidr-regex": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-4.1.3.tgz",
"integrity": "sha512-86M1y3ZeQvpZkZejQCcS+IaSWjlDUC+ORP0peScQ4uEUFCZ8bEQVz7NlJHqysoUb6w3zCjx4Mq/8/2RHhMwHYw==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.1.tgz",
"integrity": "sha512-2Apfc6qH9uwF3QHmlYBA8ExB9VHq+1/Doj9sEMY55TVBcpQ3y/+gmMpcNIBBtfb5k54Vphmta+1IxjMqPlWWAA==",
"license": "BSD-2-Clause",
"dependencies": {
"ip-regex": "^5.0.0"
"ip-regex": "5.0.0"
},
"engines": {
"node": ">=14"
"node": ">=20"
}
},
"node_modules/cidr-tools": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/cidr-tools/-/cidr-tools-11.0.3.tgz",
"integrity": "sha512-7p0rp7B2P+nZfBkJlrQzUMDyUHeYK2h/XCJY80VUl1v5oxwLxQjZMy39BXVOXugwAX67l0oJ/QQ6OhANgUtUbw==",
"license": "BSD-2-Clause",
"dependencies": {
"ip-bigint": "^8.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/clone-regexp": {
@@ -1542,9 +1564,9 @@
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -1698,14 +1720,23 @@
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ip-bigint": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/ip-bigint/-/ip-bigint-8.2.2.tgz",
"integrity": "sha512-wPoOpHigOtoY29UCFA0L82cJVFcT7M+TsrgipUVpFw7HV9LpLEuNXCymt3623jzHPlIZzFaCyaVf9VACssFYew==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=18"
}
},
"node_modules/ip-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
@@ -1719,15 +1750,15 @@
}
},
"node_modules/is-cidr": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-5.1.1.tgz",
"integrity": "sha512-AwzRMjtJNTPOgm7xuYZ71715z99t+4yRnSnSzgK5err5+heYi4zMuvmpUadaJ28+KCXCQo8CjUrKQZRWSPmqTQ==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.1.tgz",
"integrity": "sha512-JIJlvXodfsoWFAvvjB7Elqu8qQcys2SZjkIJCLdk4XherUqZ6+zH7WIpXkp4B3ZxMH0Fz7zIsZwyvs6JfM0csw==",
"license": "BSD-2-Clause",
"dependencies": {
"cidr-regex": "^4.1.1"
"cidr-regex": "5.0.1"
},
"engines": {
"node": ">=14"
"node": ">=20"
}
},
"node_modules/is-extglob": {
@@ -1807,9 +1838,9 @@
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1889,19 +1920,19 @@
}
},
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2020,9 +2051,9 @@
}
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz",
"integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2042,12 +2073,11 @@
}
},
"node_modules/sass-embedded": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz",
"integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.3.tgz",
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"buffer-builder": "^0.2.0",
@@ -2065,30 +2095,30 @@
"node": ">=16.0.0"
},
"optionalDependencies": {
"sass-embedded-all-unknown": "1.93.2",
"sass-embedded-android-arm": "1.93.2",
"sass-embedded-android-arm64": "1.93.2",
"sass-embedded-android-riscv64": "1.93.2",
"sass-embedded-android-x64": "1.93.2",
"sass-embedded-darwin-arm64": "1.93.2",
"sass-embedded-darwin-x64": "1.93.2",
"sass-embedded-linux-arm": "1.93.2",
"sass-embedded-linux-arm64": "1.93.2",
"sass-embedded-linux-musl-arm": "1.93.2",
"sass-embedded-linux-musl-arm64": "1.93.2",
"sass-embedded-linux-musl-riscv64": "1.93.2",
"sass-embedded-linux-musl-x64": "1.93.2",
"sass-embedded-linux-riscv64": "1.93.2",
"sass-embedded-linux-x64": "1.93.2",
"sass-embedded-unknown-all": "1.93.2",
"sass-embedded-win32-arm64": "1.93.2",
"sass-embedded-win32-x64": "1.93.2"
"sass-embedded-all-unknown": "1.93.3",
"sass-embedded-android-arm": "1.93.3",
"sass-embedded-android-arm64": "1.93.3",
"sass-embedded-android-riscv64": "1.93.3",
"sass-embedded-android-x64": "1.93.3",
"sass-embedded-darwin-arm64": "1.93.3",
"sass-embedded-darwin-x64": "1.93.3",
"sass-embedded-linux-arm": "1.93.3",
"sass-embedded-linux-arm64": "1.93.3",
"sass-embedded-linux-musl-arm": "1.93.3",
"sass-embedded-linux-musl-arm64": "1.93.3",
"sass-embedded-linux-musl-riscv64": "1.93.3",
"sass-embedded-linux-musl-x64": "1.93.3",
"sass-embedded-linux-riscv64": "1.93.3",
"sass-embedded-linux-x64": "1.93.3",
"sass-embedded-unknown-all": "1.93.3",
"sass-embedded-win32-arm64": "1.93.3",
"sass-embedded-win32-x64": "1.93.3"
}
},
"node_modules/sass-embedded-all-unknown": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz",
"integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.3.tgz",
"integrity": "sha512-3okGgnE41eg+CPLtAPletu6nQ4N0ij7AeW+Sl5Km4j29XcmqZQeFwYjHe1AlKTEgLi/UAONk1O8i8/lupeKMbw==",
"cpu": [
"!arm",
"!arm64",
@@ -2099,13 +2129,13 @@
"license": "MIT",
"optional": true,
"dependencies": {
"sass": "1.93.2"
"sass": "1.93.3"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz",
"integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.3.tgz",
"integrity": "sha512-8xOw9bywfOD6Wv24BgCmgjkk6tMrsOTTHcb28KDxeJtFtoxiUyMbxo0vChpPAfp2Hyg2tFFKS60s0s4JYk+Raw==",
"cpu": [
"arm"
],
@@ -2120,9 +2150,9 @@
}
},
"node_modules/sass-embedded-android-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz",
"integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.3.tgz",
"integrity": "sha512-uqUl3Kt1IqdGVAcAdbmC+NwuUJy8tM+2ZnB7/zrt6WxWVShVCRdFnWR9LT8HJr7eJN7AU8kSXxaVX/gedanPsg==",
"cpu": [
"arm64"
],
@@ -2137,9 +2167,9 @@
}
},
"node_modules/sass-embedded-android-riscv64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz",
"integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.3.tgz",
"integrity": "sha512-2jNJDmo+3qLocjWqYbXiBDnfgwrUeZgZFHJIwAefU7Fn66Ot7rsXl+XPwlokaCbTpj7eMFIqsRAZ/uDueXNCJg==",
"cpu": [
"riscv64"
],
@@ -2154,9 +2184,9 @@
}
},
"node_modules/sass-embedded-android-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz",
"integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.3.tgz",
"integrity": "sha512-y0RoAU6ZenQFcjM9PjQd3cRqRTjqwSbtWLL/p68y2oFyh0QGN0+LQ826fc0ZvU/AbqCsAizkqjzOn6cRZJxTTQ==",
"cpu": [
"x64"
],
@@ -2171,9 +2201,9 @@
}
},
"node_modules/sass-embedded-darwin-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz",
"integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.3.tgz",
"integrity": "sha512-7zb/hpdMOdKteK17BOyyypemglVURd1Hdz6QGsggy60aUFfptTLQftLRg8r/xh1RbQAUKWFbYTNaM47J9yPxYg==",
"cpu": [
"arm64"
],
@@ -2188,9 +2218,9 @@
}
},
"node_modules/sass-embedded-darwin-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz",
"integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.3.tgz",
"integrity": "sha512-Ek1Vp8ZDQEe327Lz0b7h3hjvWH3u9XjJiQzveq74RPpJQ2q6d9LfWpjiRRohM4qK6o4XOHw1X10OMWPXJtdtWg==",
"cpu": [
"x64"
],
@@ -2205,9 +2235,9 @@
}
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz",
"integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.3.tgz",
"integrity": "sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==",
"cpu": [
"arm"
],
@@ -2222,9 +2252,9 @@
}
},
"node_modules/sass-embedded-linux-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz",
"integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.3.tgz",
"integrity": "sha512-RBrHWgfd8Dd8w4fbmdRVXRrhh8oBAPyeWDTKAWw8ZEmuXfVl4ytjDuyxaVilh6rR1xTRTNpbaA/YWApBlLrrNw==",
"cpu": [
"arm64"
],
@@ -2239,9 +2269,9 @@
}
},
"node_modules/sass-embedded-linux-musl-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz",
"integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.3.tgz",
"integrity": "sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==",
"cpu": [
"arm"
],
@@ -2256,9 +2286,9 @@
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz",
"integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.3.tgz",
"integrity": "sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==",
"cpu": [
"arm64"
],
@@ -2273,9 +2303,9 @@
}
},
"node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz",
"integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.3.tgz",
"integrity": "sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==",
"cpu": [
"riscv64"
],
@@ -2290,9 +2320,9 @@
}
},
"node_modules/sass-embedded-linux-musl-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz",
"integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.3.tgz",
"integrity": "sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==",
"cpu": [
"x64"
],
@@ -2307,9 +2337,9 @@
}
},
"node_modules/sass-embedded-linux-riscv64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz",
"integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.3.tgz",
"integrity": "sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==",
"cpu": [
"riscv64"
],
@@ -2324,9 +2354,9 @@
}
},
"node_modules/sass-embedded-linux-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz",
"integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.3.tgz",
"integrity": "sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==",
"cpu": [
"x64"
],
@@ -2341,9 +2371,9 @@
}
},
"node_modules/sass-embedded-unknown-all": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz",
"integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.3.tgz",
"integrity": "sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2354,13 +2384,13 @@
"!win32"
],
"dependencies": {
"sass": "1.93.2"
"sass": "1.93.3"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz",
"integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.3.tgz",
"integrity": "sha512-0dOfT9moy9YmBolodwYYXtLwNr4jL4HQC9rBfv6mVrD7ud8ue2kDbn+GVzj1hEJxvEexVSmDCf7MHUTLcGs9xQ==",
"cpu": [
"arm64"
],
@@ -2375,9 +2405,9 @@
}
},
"node_modules/sass-embedded-win32-x64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz",
"integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.3.tgz",
"integrity": "sha512-wHFVfxiS9hU/sNk7KReD+lJWRp3R0SLQEX4zfOnRP2zlvI2X4IQR5aZr9GNcuMP6TmNpX0nQPZTegS8+h9RrEg==",
"cpu": [
"x64"
],
@@ -2539,7 +2569,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2576,25 +2605,24 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -2603,14 +2631,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "*",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -2675,7 +2703,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2684,17 +2711,16 @@
}
},
"node_modules/vue": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
"@vue/runtime-dom": "3.5.22",
"@vue/server-renderer": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
"@vue/runtime-dom": "3.5.25",
"@vue/server-renderer": "3.5.25",
"@vue/shared": "3.5.25"
},
"peerDependencies": {
"typescript": "*"
@@ -2706,13 +2732,13 @@
}
},
"node_modules/vue-i18n": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.1.12",
"@intlify/shared": "11.1.12",
"@intlify/core-base": "11.2.2",
"@intlify/shared": "11.2.2",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
@@ -2737,9 +2763,9 @@
"license": "MIT"
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
@@ -2748,7 +2774,7 @@
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {

View File

@@ -8,28 +8,29 @@
"preview": "vite preview --port 5050"
},
"dependencies": {
"@fontsource/nunito-sans": "^5.2.5",
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@fontsource/nunito-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.1.0",
"@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/browser": "^13.2.2",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.7",
"bootswatch": "^5.3.7",
"flag-icons": "^7.3.2",
"ip-address": "^10.0.1",
"is-cidr": "^5.1.1",
"bootstrap": "^5.3.8",
"bootswatch": "^5.3.8",
"cidr-tools": "^11.0.3",
"flag-icons": "^7.5.0",
"ip-address": "^10.1.0",
"is-cidr": "^6.0.1",
"is-ip": "^5.0.1",
"pinia": "^3.0.2",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.3",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.5.0"
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"sass-embedded": "^1.86.3",
"vite": "^6.3.6"
"@vitejs/plugin-vue": "^6.0.2",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
}
}

View File

@@ -133,6 +133,9 @@ const userDisplayName = computed(() => {
<li class="nav-item">
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
</li>
<li class="nav-item">
<RouterLink :to="{ name: 'ip-calculator' }" class="nav-link">{{ $t('menu.calculator') }}</RouterLink>
</li>
</ul>
<div class="navbar-nav d-flex justify-content-end">

View File

@@ -42,7 +42,8 @@
"audit": "Event Protokoll",
"login": "Anmelden",
"logout": "Abmelden",
"keygen": "Schlüsselgenerator"
"keygen": "Schlüsselgenerator",
"calculator": "IP-Rechner"
},
"home": {
"headline": "WireGuard® VPN Portal",
@@ -269,6 +270,26 @@
"placeholder": "Der geteilte Schlüssel"
}
},
"calculator": {
"headline": "WireGuard IP-Rechner",
"abstract": "Erzeuge erlaubte IPs für WireGuard. Die IP-Subnetze werden lokal in Ihrem Browser generiert und niemals an den Server gesendet.",
"headline-allowed-ip": "Neue erlaubte IPs",
"button-exclude-private": "Private IP-Bereiche ausschließen",
"allowed-ip": {
"label": "Erlaubte IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Wert darf nicht leer sein"
},
"dissallowed-ip": {
"label": "Nicht erlaubte IPs",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Ungültige Adresse: {addr}"
},
"new-allowed-ip": {
"label": "Erlaubte IPs",
"placeholder": ""
}
},
"modals": {
"user-view": {
"headline": "Benutzerkonto:",

View File

@@ -42,7 +42,8 @@
"audit": "Audit Log",
"login": "Login",
"logout": "Logout",
"keygen": "Key Generator"
"keygen": "Key Generator",
"calculator": "IP Calculator"
},
"home": {
"headline": "WireGuard® VPN Portal",
@@ -269,6 +270,26 @@
"placeholder": "The pre-shared key"
}
},
"calculator": {
"headline": "WireGuard IP Calculator",
"abstract": "Generate a WireGuard Allowed IPs. The IP subnets are generated in your local browser and are never sent to the server.",
"headline-allowed-ip": "New Allowed IPs",
"button-exclude-private": "Exclude Private IP Ranges",
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Value cannot be empty"
},
"dissallowed-ip": {
"label": "Disallowed IPs",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Invalid address: {addr}"
},
"new-allowed-ip": {
"label": "Allowed IPs",
"placeholder": ""
}
},
"modals": {
"user-view": {
"headline": "User Account:",

View File

@@ -2,6 +2,26 @@
"languages": {
"es": "Español"
},
"calculator": {
"abstract": "Genera direcciones IP permitidas de WireGuard. Las subredes IP se generan en tu navegador local y nunca se envían al servidor.",
"allowed-ip": {
"empty": "El valor no puede estar vacío",
"label": "IPs permitidas",
"placeholder": "0.0.0.0/0, ::/0"
},
"button-exclude-private": "Excluir rangos de IP privadas",
"dissallowed-ip": {
"invalid": "Dirección inválida: {addr}",
"label": "IPs no permitidas",
"placeholder": "10.0.0.0/8, 192.168.0.0/16"
},
"headline": "Calculadora de IPs de WireGuard",
"headline-allowed-ip": "Nuevas IPs permitidas",
"new-allowed-ip": {
"label": "IPs permitidas",
"placeholder": ""
}
},
"general": {
"pagination": {
"size": "Numero de elementos",
@@ -33,6 +53,7 @@
"button-webauthn": "Usar clave de acceso"
},
"menu": {
"calculator": "Calculadora IP",
"home": "Inicio",
"interfaces": "Interfaces",
"users": "Usuarios",
@@ -69,7 +90,7 @@
"profiles": {
"headline": "Perfiles VPN",
"abstract": "Puedes acceder y descargar tus configuraciones personales de VPN desde tu perfil de usuario.",
"content": "para ver todos tus perfiles configurados, haz clic en el botón de abajo.",
"content": "Para ver todos tus perfiles configurados, haz clic en el botón de abajo.",
"button": "Abrir mi perfil"
},
"admin": {
@@ -96,7 +117,7 @@
"table-heading": {
"name": "Nombre",
"user": "Usuario",
"ip": "IP's",
"ip": "IPs",
"endpoint": "Endpoint",
"status": "Estado"
},
@@ -114,6 +135,7 @@
"total-endpoints": "Endpoints totales",
"ip": "Dirección IP",
"default-allowed-ip": "IPs permitidas por defecto",
"default-dns": "Servidores DNS por defecto",
"dns": "Servidores DNS",
"mtu": "MTU",
"default-keep-alive": "Intervalo Keepalive por defecto",
@@ -160,7 +182,7 @@
"headline": "Mis peers VPN",
"table-heading": {
"name": "Nombre",
"ip": "IP's",
"ip": "IPs",
"stats": "Estado",
"interface": "Interfaz del servidor"
},
@@ -220,6 +242,16 @@
"button-delete-text": "Eliminar la llave de acceso. Ya no podrás iniciar sesión con ella.",
"button-register-title": "Registrar llave de acceso",
"button-register-text": "Registrar una nueva llave de acceso para proteger tu cuenta."
},
"password": {
"headline": "Configuración de contraseña",
"abstract": "Aquí puedes cambiar tu contraseña.",
"current-label": "Contraseña actual",
"new-label": "Nueva contraseña",
"new-confirm-label": "Confirmar nueva contraseña",
"change-button-text": "Cambiar contraseña",
"invalid-confirm-label": "Las contraseñas no coinciden",
"weak-label": "La contraseña es demasiado débil"
}
},
"audit": {
@@ -269,7 +301,7 @@
"firstname": "Nombre",
"lastname": "Apellido",
"phone": "Número de Teléfono",
"depeertment": "Departamento",
"department": "Departamento",
"api-enabled": "Acceso API",
"disabled": "Cuenta Deshabilitada",
"locked": "Cuenta Bloqueada",
@@ -277,7 +309,7 @@
"peers": {
"name": "Nombre",
"interface": "Interfaz",
"ip": "IP's"
"ip": "IPs"
}
},
"user-edit": {
@@ -309,7 +341,7 @@
"label": "Teléfono",
"placeholder": "El número de teléfono"
},
"depeertment": {
"department": {
"label": "Departamento",
"placeholder": "El departamento"
},
@@ -338,6 +370,16 @@
"interface-view": {
"headline": "Configuración de la interfaz:"
},
"password": {
"abstract": "Aquí puedes cambiar tu contraseña.",
"change-button-text": "Cambiar contraseña",
"current-label": "Contraseña actual",
"headline": "Configuración de contraseña",
"invalid-confirm-label": "Las contraseñas no coinciden",
"new-confirm-label": "Confirmar nueva contraseña",
"new-label": "Nueva contraseña",
"weak-label": "La contraseña es demasiado débil"
},
"interface-edit": {
"headline-edit": "Editar interfaz:",
"headline-new": "Nueva interfaz",
@@ -461,6 +503,8 @@
"section-config": "Configuración",
"identifier": "Identificador",
"ip": "Direcciones IP",
"allowed-ip": "Direcciones IP permitidas",
"extra-allowed-ip": "Direcciones IP permitidas del lado del servidor",
"user": "Usuario Asociado",
"notes": "Notas",
"expiry-status": "Expira en",
@@ -469,10 +513,10 @@
"connection-status": "Estadísticas de Conexión",
"upload": "Bytes Subidos (del Servidor al peer)",
"download": "Bytes Descargados (del peer al Servidor)",
"pingable": "Es Alcanzable (Ping)",
"handshake": "Último Handshake",
"pingable": "Alcanzable (ping)",
"handshake": "Último handshake",
"connected-since": "Conectado desde",
"endpoint": "Endpoint",
"endpoint": "Dirección del host remoto",
"button-download": "Descargar configuración",
"button-email": "Enviar configuración por Correo Electrónico",
"style-label": "Estilo de Configuración"
@@ -488,7 +532,7 @@
"header-hooks": "Hooks (Ejecutados en el peer)",
"header-state": "Estado",
"display-name": {
"label": "Nombre para Mostrar",
"label": "Nombre para mostrar",
"placeholder": "El nombre descriptivo para el peer"
},
"linked-user": {
@@ -501,7 +545,7 @@
"help": "La clave privada se almacena de forma segura en el servidor. Si el usuario ya posee una copia, puedes omitir este campo. El servidor sigue funcionando exclusivamente con la clave pública del peer."
},
"public-key": {
"label": "Cave Pública",
"label": "Clave Pública",
"placeholder": "La Clave pública"
},
"preshared-key": {
@@ -512,6 +556,10 @@
"label": "Dirección del endpoint",
"placeholder": "La dirección del endpoint remoto"
},
"endpoint-public-key": {
"label": "Clave pública del punto del endpoint",
"placeholder": "La clave pública del endpoint remoto"
},
"ip": {
"label": "Direcciones IP",
"placeholder": "Direcciones IP (formato CIDR)"
@@ -576,11 +624,11 @@
"description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer."
},
"prefix": {
"headline-peer": "peer:",
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Prefijo del Nombre peera Mostrar",
"placeholder": "El prefijo",
"description": "Un prefijo que se agregará al nombre mostrado de los peers."
"label": "Prefijo del nombre del peer a mostrar",
"placeholder": "Prefijo",
"description": "Un prefijo que se agregará al nombre visible de los peers."
}
}
}

View File

@@ -29,7 +29,8 @@
"label": "Пароль",
"placeholder": "Пожалуйста, введите ваш пароль"
},
"button": "Войти"
"button": "Войти",
"button-webauthn": "Использовать Passkey"
},
"menu": {
"home": "Главная",
@@ -37,8 +38,12 @@
"users": "Пользователи",
"lang": "Сменить язык",
"profile": "Мой профиль",
"settings": "Настройки",
"audit": "Журнал аудита",
"login": "Вход",
"logout": "Выход"
"logout": "Выход",
"keygen": "Генератор ключей",
"calculator": "Калькулятор IP-адресов"
},
"home": {
"headline": "Портал VPN WireGuard®",
@@ -100,6 +105,8 @@
"interface": {
"headline": "Статус интерфейса для",
"backend": "бэкэнд",
"unknown-backend": "Неизвестно",
"wrong-backend": "Неверный бэкэнд, вместо него используется локальный сервер WireGuard!",
"key": "Публичный ключ",
"endpoint": "Публичная конечная точка",
"port": "Порт прослушивания",
@@ -112,6 +119,7 @@
"dns": "DNS-серверы",
"mtu": "MTU",
"default-keep-alive": "Интервал поддержания активности по умолчанию",
"default-dns": "DNS-сервера по-умолчанию",
"button-show-config": "Показать конфигурацию",
"button-download-config": "Скачать конфигурацию",
"button-store-config": "Сохранить конфигурацию для wg-quick",
@@ -168,6 +176,121 @@
"button-show-peer": "Показать пира",
"button-edit-peer": "Редактировать пира"
},
"settings": {
"headline": "Настройки",
"abstract": "Здесь вы можете изменить персональные настройки.",
"api": {
"headline": "Настройки API",
"abstract": "Здесь можете настроить RESTful API.",
"active-description": "В данный момент API активен для вашей учетной записи. Все запросы API проверяются с помощью Basic Auth. Для проверки подлинности используйте следующие учетные данные.",
"inactive-description": "В данный момент API неактивен. Нажмите кнопку ниже, чтобы активировать его.",
"user-label": "Имя пользователя API:",
"user-placeholder": "Имя пользователя API",
"token-label": "API-пароль:",
"token-placeholder": "API-токен",
"token-created-label": "Доступ к API предоставлен с: ",
"button-disable-title": "Отключение API приведет к аннулированию текущего токена.",
"button-disable-text": "Отключить API",
"button-enable-title": "Включение API приведет к созданию нового токена.",
"button-enable-text": "Включить API",
"api-link": "Документация API"
},
"webauthn": {
"headline": "Настройки Passkey",
"abstract": "Passkey - это современный способ аутентификации пользователей без использования паролей. Он надежно хранятся в вашем браузере и могут быть использованы для входа в WireGuard Portal.",
"active-description": "В данный момент для вашей учетной записи пользователя активен по крайней мере один Passkey.",
"inactive-description": "В настоящее время для вашей учетной записи пользователя не зарегистрировано ни одного Passkey. Нажмите кнопку ниже, чтобы зарегистрировать новый Passkey.",
"table": {
"name": "Название",
"created": "Создано",
"actions": ""
},
"credentials-list": "Зарегистрированные Passkeys",
"modal-delete": {
"headline": "Удалить Passkey",
"abstract": "Вы уверены, что хотите удалить этот Passkey? Вы больше не сможете войти в систему с помощью этого Passkey.",
"created": "Создано:",
"button-delete": "Удалить",
"button-cancel": "Отмена"
},
"button-rename-title": "Переименновать",
"button-rename-text": "Переименновать Passkey.",
"button-save-title": "Сохранить",
"button-save-text": "Сохранить новое название Passkey.",
"button-cancel-title": "Отмена",
"button-cancel-text": "Отмена переименования Passkey.",
"button-delete-title": "Удалить",
"button-delete-text": "Удалить Passkey. Вы больше не сможете войти в систему с помощью этого Passkey.",
"button-register-title": "Зарегистрировать Passkey",
"button-register-text": "Зарегистрировать Passkey, чтобы защитить свою учетную запись."
},
"password": {
"headline": "Настройки пароля",
"abstract": "Здесь можете изменить свой пароль.",
"current-label": "Текущий пароль",
"new-label": "Новый пароль",
"new-confirm-label": "Повторно новый пароль",
"change-button-text": "Изменить пароль",
"invalid-confirm-label": "Пароли не совпадают",
"weak-label": "Пароль слишком простой"
}
},
"audit": {
"headline": "Журнал аудита",
"abstract": "Здесь вы можете ознакомиться с журналом аудита всех действий, выполненных на WireGuard Portal.",
"no-entries": {
"headline": "Нет доступных записей в журнале",
"abstract": "В данный момент, журнал аудита пуст."
},
"entries-headline": "Записи журнала",
"table-heading": {
"id": "#",
"time": "Время",
"user": "Пользователь",
"severity": "Серьезность",
"origin": "Источник",
"message": "Сообщение"
}
},
"keygen": {
"headline": "Генератор WireGuard-ключей",
"abstract": "Генерация WireGuard-ключей. Ключи генерируются в вашем локальном браузере и никогда не отправляются на сервер.",
"headline-keypair": "Новая пара ключей",
"headline-preshared-key": "Новый общий ключ",
"button-generate": "Генерировать",
"private-key": {
"label": "Приватный ключ",
"placeholder": "Приватный ключ"
},
"public-key": {
"label": "Публичный ключ",
"placeholder": "Публичный ключ"
},
"preshared-key": {
"label": "Общий ключ",
"placeholder": "Общий ключ"
}
},
"calculator": {
"headline": "Калькулятор IP-адресов",
"abstract": "Генерация разрешенных IP-адресов. IP-подсети генерируются в вашем локальном браузере и никогда не отправляются на сервер.",
"headline-allowed-ip": "Новые разрешенные IP-адреса",
"button-exclude-private": "Исключить частные диапазоны IP-адресов",
"allowed-ip": {
"label": "Разрешенные IP-адреса",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Поле ввода не должно быть пустым"
},
"dissallowed-ip": {
"label": "Запрещенные IP-адреса",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Некорректный адрес: {addr}"
},
"new-allowed-ip": {
"label": "Разрешенные IP-адреса",
"placeholder": ""
}
},
"modals": {
"user-view": {
"headline": "Учетная запись пользователя:",
@@ -180,6 +303,7 @@
"lastname": "Фамилия",
"phone": "Номер телефона",
"department": "Отдел",
"api-enabled": "API",
"disabled": "Учетная запись отключена",
"locked": "Учетная запись заблокирована",
"no-peers": "У пользователя нет связанных пиров.",
@@ -207,7 +331,8 @@
"password": {
"label": "Пароль",
"placeholder": "Надежный пароль",
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль."
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль.",
"too-weak": "Пароль слишком простой. Используйте более сложный пароль."
},
"email": {
"label": "Электронная почта",
@@ -267,6 +392,11 @@
"client": "Режим клиента",
"any": "Неизвестный режим"
},
"backend": {
"label": "Бэкэнд интерфейса",
"invalid-label": "Оригинальный бэкэнд больше недоступн, вместо нее используется локальная WireGuard-бэкэнд!",
"local": "Локальный WireGuard-бэкэнд"
},
"display-name": {
"label": "Отображаемое имя",
"placeholder": "Описательное имя для интерфейса"
@@ -364,6 +494,8 @@
"section-config": "Конфигурация",
"identifier": "Идентификатор",
"ip": "IP-адреса",
"allowed-ip": "Разрешённые IP-адреса",
"extra-allowed-ip": "Разрешённые IP-адреса на стороне сервера",
"user": "Связанный пользователь",
"notes": "Заметки",
"expiry-status": "Истекает в",
@@ -376,8 +508,10 @@
"handshake": "Последнее рукопожатие",
"connected-since": "Подключен с",
"endpoint": "Конечная точка",
"endpoint-key": "Публичный ключ конечной точки",
"button-download": "Скачать конфигурацию",
"button-email": "Отправить конфигурацию по электронной почте"
"button-email": "Отправить конфигурацию по электронной почте",
"style-label": "Вид конфигурации"
},
"peer-edit": {
"headline-edit-peer": "Редактировать пира:",
@@ -399,7 +533,8 @@
},
"private-key": {
"label": "Приватный ключ",
"placeholder": "Приватный ключ"
"placeholder": "Приватный ключ",
"help": "Закрытый ключ надежно хранится на сервере. Если у пользователя уже есть копия, вы можете не указывать это поле. Сервер работает исключительно с открытым ключом клиента."
},
"public-key": {
"label": "Публичный ключ",
@@ -431,61 +566,61 @@
"description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса."
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
"label": "DNS-сервер",
"placeholder": "Используемые DNS-серверы"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
"label": "Поисковые домены DNS",
"placeholder": "Префиксы поиска DNS"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
"label": "Интервал поддержания активности",
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
"placeholder": "MTU клиента (0 = использовать значение по умолчанию)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Одна или несколько команд bash, разделенных ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Одна или несколько команд bash, разделенных ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Одна или несколько команд bash, разделенных ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Одна или несколько команд bash, разделенных ;"
},
"disabled": {
"label": "Peer Disabled"
"label": "Узел отключен"
},
"ignore-global": {
"label": "Ignore global settings"
"label": "Игнорировать глобальные настройки"
},
"expires-at": {
"label": "Expiry date"
"label": "Дата истечения срока действия"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",
"headline-endpoint": "Create multiple endpoints",
"headline-peer": "Создать несколько узлов",
"headline-endpoint": "Создать несколько конечных точек",
"identifiers": {
"label": "User Identifiers",
"placeholder": "User Identifiers",
"description": "A user identifier (the username) for which a peer should be created."
"label": "Идентификаторы пользователей",
"placeholder": "Идентификаторы пользователей",
"description": "Идентификатор пользователя (имя пользователя), для которого узел будет создан."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Display Name Prefix",
"placeholder": "The prefix",
"description": "A prefix that is added to the peers display name."
"headline-peer": "Узел:",
"headline-endpoint": "Конечная точка:",
"label": "Префикс отображаемого имени",
"placeholder": "Префикс",
"description": "Префикс будет добавлен к отображаемому имени узла."
}
}
}

View File

@@ -72,6 +72,14 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/KeyGeneraterView.vue')
},
{
path: '/ip-calculator',
name: 'ip-calculator',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/IPCalculatorView.vue')
}
],
linkActiveClass: "active",
@@ -122,7 +130,7 @@ router.beforeEach(async (to) => {
}
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login', '/key-generator']
const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) {

View File

@@ -0,0 +1,139 @@
<script setup>
import {ref, watch, computed} from "vue";
import isCidr from "is-cidr";
import {isIP} from "is-ip";
import {excludeCidr} from "cidr-tools";
import {useI18n} from 'vue-i18n';
const allowedIp = ref("")
const dissallowedIp = ref("")
const privateIP = ref("10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16")
const {t} = useI18n()
const errorAllowed = ref("")
const errorDissallowed = ref("")
/**
* Validate a comma-separated list of IP and/or CIDR addresses.
* @function validateIpAndCidrList
* @param {string} value - Comma-separated string (e.g. "10.0.0.0/8, 192.168.0.1")
* @returns {true|string} Returns true if all values are valid, otherwise an error message.
*/
function validateIpAndCidrList(value) {
const list = value.split(",").map(v => v.trim()).filter(Boolean);
if (list.length === 0) {
return t('calculator.allowed-ip.empty');
}
for (const addr of list) {
if (!isIP(addr) && !isCidr(addr)) {
return t('calculator.dissallowed-ip.invalid', {addr});
}
}
return true;
}
/**
* Watcher that validates allowed IPs input in real-time.
* Updates `errorAllowed` whenever `allowedIp` changes.
*/
watch(allowedIp, (newValue) => {
const result = validateIpAndCidrList(newValue);
errorAllowed.value = result === true ? "" : result;
});
/**
* Watcher that validates disallowed IPs input in real-time.
* Updates `errorDissallowed` whenever `dissallowedIp` changes.
*/
watch(dissallowedIp, (newValue) => {
if (!allowedIp.value || allowedIp.value.trim() === "") {
allowedIp.value = "0.0.0.0/0";
}
const result = validateIpAndCidrList(newValue);
errorDissallowed.value = result === true ? "" : result;
});
/**
* Dynamically computes the resulting "Allowed IPs" list
* by excluding the disallowed ranges from the allowed ranges.
* @constant
* @type {ComputedRef<string>}
* @returns {string} A comma-separated string of resulting CIDR blocks.
*/
const newAllowedIp = computed(() => {
if (errorAllowed.value || errorDissallowed.value) return "";
try {
const allowedList = allowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
const disallowedList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
const result = excludeCidr(allowedList, disallowedList);
return result.join(", ");
} catch (e) {
console.error("Allowed IPs calculation error:", e);
return "";
}
});
/**
* Append private IP ranges to disallowed IPs.
* If any already exist, they are preserved and new ones are appended only if not present.
* @function addPrivateIPs
*/
function addPrivateIPs() {
const privateList = privateIP.value.split(",").map(v => v.trim());
const currentList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
const combined = Array.from(new Set([...currentList, ...privateList]));
dissallowedIp.value = combined.join(", ");
}
</script>
<template>
<div class="page-header">
<h1>{{ $t('calculator.headline') }}</h1>
</div>
<p class="lead">{{ $t('calculator.abstract') }}</p>
<div class="mt-4 row">
<div class="col-12 col-lg-5">
<fieldset>
<div class="form-group">
<label class="form-label mt-4">{{ $t('calculator.allowed-ip.label') }}</label>
<input class="form-control" v-model="allowedIp" :placeholder="$t('calculator.allowed-ip.placeholder')" :class="{ 'is-invalid': errorAllowed }">
<div v-if="errorAllowed" class="text-danger mt-1">{{ errorAllowed }}</div>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('calculator.dissallowed-ip.label') }}</label>
<input class="form-control" v-model="dissallowedIp" :placeholder="$t('calculator.dissallowed-ip.placeholder')" :class="{ 'is-invalid': errorDissallowed }">
<div v-if="errorDissallowed" class="text-danger mt-1">{{ errorDissallowed }}</div>
</div>
</fieldset>
<fieldset>
<hr class="mt-4">
<button class="btn btn-primary mb-4" type="button" @click="addPrivateIPs">{{ $t('calculator.button-exclude-private') }}</button>
</fieldset>
</div>
<div class="col-12 col-lg-2 mt-sm-4">
</div>
<div class="col-12 col-lg-5">
<h1>{{ $t('calculator.headline-allowed-ip') }}</h1>
<fieldset>
<div class="form-group">
<textarea class="form-control" :value="newAllowedIp" rows="6" :placeholder="$t('calculator.new-allowed-ip.placeholder')" readonly></textarea>
</div>
</fieldset>
</div>
</div>
</template>
<style scoped>
</style>

71
go.mod
View File

@@ -5,12 +5,12 @@ go 1.24.0
require (
github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pkgz/routegroup v1.5.3
github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.14.0
github.com/go-webauthn/webauthn v0.15.0
github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.2
@@ -21,50 +21,51 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.43.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sys v0.37.0
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.39.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.1
gorm.io/gorm v1.31.0
gorm.io/driver/sqlserver v1.6.3
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.2 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/go-tpm v0.9.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
@@ -76,32 +77,32 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.9.3 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
modernc.org/sqlite v1.40.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

162
go.sum
View File

@@ -20,8 +20,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
@@ -38,8 +38,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -50,8 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -62,31 +62,35 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -99,10 +103,12 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -115,8 +121,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -127,6 +133,8 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -173,18 +181,16 @@ github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHi
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@@ -197,15 +203,17 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -262,18 +270,18 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -291,10 +299,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -302,8 +310,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -324,8 +332,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -354,16 +362,16 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
@@ -385,23 +393,25 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -410,8 +420,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -166,6 +166,9 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
if err := os.Chmod(cfg.DSN, 0600); err != nil {
return nil, fmt.Errorf("failed to set permissions on sqlite database: %w", err)
}
sqlDB, _ := gormDb.DB()
sqlDB.SetMaxOpenConns(1)
}

View File

@@ -0,0 +1,979 @@
package wgcontroller
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/lowlevel"
)
// PfsenseController implements the InterfaceController interface for pfSense firewalls.
// It uses the pfSense REST API (https://pfrest.org/) to manage WireGuard interfaces and peers.
// API endpoint paths and field names should be verified against the Swagger documentation:
// https://pfrest.org/api-docs/
type PfsenseController struct {
coreCfg *config.Config
cfg *config.BackendPfsense
client *lowlevel.PfsenseApiClient
// Add mutexes to prevent race conditions
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
coreMutex sync.Mutex // for updating the core configuration such as routing table or DNS settings
}
func NewPfsenseController(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseController, error) {
client, err := lowlevel.NewPfsenseApiClient(coreCfg, cfg)
if err != nil {
return nil, fmt.Errorf("failed to create pfSense API client: %w", err)
}
return &PfsenseController{
coreCfg: coreCfg,
cfg: cfg,
client: client,
interfaceMutexes: sync.Map{},
peerMutexes: sync.Map{},
coreMutex: sync.Mutex{},
}, nil
}
func (c *PfsenseController) GetId() domain.InterfaceBackend {
return domain.InterfaceBackend(c.cfg.Id)
}
// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications
func (c *PfsenseController) getInterfaceMutex(id domain.InterfaceIdentifier) *sync.Mutex {
mutex, _ := c.interfaceMutexes.LoadOrStore(id, &sync.Mutex{})
return mutex.(*sync.Mutex)
}
// getPeerMutex returns a mutex for the given peer to prevent concurrent modifications
func (c *PfsenseController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex {
mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{})
return mutex.(*sync.Mutex)
}
// region wireguard-related
func (c *PfsenseController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
// Query WireGuard tunnels from pfSense API
// Using pfSense REST API v2 endpoints: GET /api/v2/vpn/wireguard/tunnels
// Field names should be verified against Swagger docs: https://pfrest.org/api-docs/
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{})
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return nil, fmt.Errorf("failed to query interfaces: %v", wgReply.Error)
}
// Parallelize loading of interface details to speed up overall latency.
// Use a bounded semaphore to avoid overloading the pfSense device.
maxConcurrent := c.cfg.GetConcurrency()
sem := make(chan struct{}, maxConcurrent)
interfaces := make([]domain.PhysicalInterface, 0, len(wgReply.Data))
var mu sync.Mutex
var wgWait sync.WaitGroup
var firstErr error
ctx2, cancel := context.WithCancel(ctx)
defer cancel()
for _, wgObj := range wgReply.Data {
wgWait.Add(1)
sem <- struct{}{} // block if more than maxConcurrent requests are processing
go func(wg lowlevel.GenericJsonObject) {
defer wgWait.Done()
defer func() { <-sem }() // read from the semaphore and make space for the next entry
if firstErr != nil {
return
}
pi, err := c.loadInterfaceData(ctx2, wg)
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
cancel()
}
mu.Unlock()
return
}
mu.Lock()
interfaces = append(interfaces, *pi)
mu.Unlock()
}(wgObj)
}
wgWait.Wait()
if firstErr != nil {
return nil, firstErr
}
return interfaces, nil
}
func (c *PfsenseController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.PhysicalInterface,
error,
) {
// First, get the tunnel ID by querying by name
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"name": string(id),
},
})
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error)
}
if len(wgReply.Data) == 0 {
return nil, fmt.Errorf("interface %s not found", id)
}
tunnelId := wgReply.Data[0].GetString("id")
// Query the specific tunnel endpoint to get full details including addresses
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
if tunnelId != "" {
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"id": tunnelId,
},
})
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
// Use the detailed tunnel response which includes addresses
return c.loadInterfaceData(ctx, tunnelReply.Data)
}
// Fall back to list response if detail query fails
if c.cfg.Debug {
slog.Debug("failed to query detailed tunnel info, using list response", "interface", id, "tunnel_id", tunnelId)
}
}
return c.loadInterfaceData(ctx, wgReply.Data[0])
}
func (c *PfsenseController) loadInterfaceData(
ctx context.Context,
wireGuardObj lowlevel.GenericJsonObject,
) (*domain.PhysicalInterface, error) {
deviceName := wireGuardObj.GetString("name")
deviceId := wireGuardObj.GetString("id")
// Extract addresses from the tunnel data
// The tunnel response may include an "addresses" array when queried via /tunnel?id={id}
addresses := c.extractAddresses(wireGuardObj, nil)
// If addresses weren't found in the tunnel object and we have a tunnel ID,
// query the specific tunnel endpoint to get full details including addresses
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
if len(addresses) == 0 && deviceId != "" {
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"id": deviceId,
},
})
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
// Extract addresses from the detailed tunnel response
parsedAddrs := c.extractAddresses(tunnelReply.Data, nil)
if len(parsedAddrs) > 0 {
addresses = parsedAddrs
if c.cfg.Debug {
slog.Debug("loaded addresses from detailed tunnel query", "interface", deviceName, "count", len(addresses))
}
}
}
}
interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, nil, addresses)
if err != nil {
return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err)
}
return &interfaceModel, nil
}
func (c *PfsenseController) extractAddresses(
wgObj lowlevel.GenericJsonObject,
ifaceObj lowlevel.GenericJsonObject,
) []domain.Cidr {
addresses := make([]domain.Cidr, 0)
// Try to get addresses from ifaceObj first
if ifaceObj != nil {
addrStr := ifaceObj.GetString("addresses")
if addrStr != "" {
// Addresses might be comma-separated or in an array
addrs, _ := domain.CidrsFromString(addrStr)
addresses = append(addresses, addrs...)
}
}
// Try to get addresses from wgObj - check if it's an array first
if len(addresses) == 0 {
if addressesValue, ok := wgObj["addresses"]; ok && addressesValue != nil {
if addressesArray, ok := addressesValue.([]any); ok {
// Parse addresses array (from /tunnel?id={id} response)
// Each object has "address" and "mask" fields
for _, addrItem := range addressesArray {
if addrObj, ok := addrItem.(map[string]any); ok {
address := ""
mask := 0
// Extract address
if addrVal, ok := addrObj["address"]; ok {
if addrStr, ok := addrVal.(string); ok {
address = addrStr
} else {
address = fmt.Sprintf("%v", addrVal)
}
}
// Extract mask
if maskVal, ok := addrObj["mask"]; ok {
if maskInt, ok := maskVal.(int); ok {
mask = maskInt
} else if maskFloat, ok := maskVal.(float64); ok {
mask = int(maskFloat)
} else if maskStr, ok := maskVal.(string); ok {
if maskInt, err := strconv.Atoi(maskStr); err == nil {
mask = maskInt
}
}
}
// Convert to CIDR format
if address != "" && mask > 0 {
cidrStr := fmt.Sprintf("%s/%d", address, mask)
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
addresses = append(addresses, cidr)
}
} else if address != "" {
// Try parsing as CIDR string directly
if cidr, err := domain.CidrFromString(address); err == nil {
addresses = append(addresses, cidr)
}
}
}
}
} else if addrStr, ok := addressesValue.(string); ok {
// Fallback: try parsing as comma-separated string
addrs, _ := domain.CidrsFromString(addrStr)
addresses = append(addresses, addrs...)
}
} else {
// Try as string field
addrStr := wgObj.GetString("addresses")
if addrStr != "" {
addrs, _ := domain.CidrsFromString(addrStr)
addresses = append(addresses, addrs...)
}
}
}
return addresses
}
// parseAddressArray parses an array of address objects from the pfSense API
// Each object has "address" and "mask" fields (similar to allowedips structure)
func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr {
addresses := make([]domain.Cidr, 0, len(addressArray))
for _, addrObj := range addressArray {
address := addrObj.GetString("address")
mask := addrObj.GetInt("mask")
if address != "" && mask > 0 {
cidrStr := fmt.Sprintf("%s/%d", address, mask)
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
addresses = append(addresses, cidr)
}
} else if address != "" {
// Try parsing as CIDR string directly
if cidr, err := domain.CidrFromString(address); err == nil {
addresses = append(addresses, cidr)
}
}
}
return addresses
}
func (c *PfsenseController) convertWireGuardInterface(
wg, iface lowlevel.GenericJsonObject,
addresses []domain.Cidr,
) (
domain.PhysicalInterface,
error,
) {
// Map pfSense field names to our domain model
// Field names should be verified against the Swagger UI: https://pfrest.org/api-docs/
// The implementation attempts to handle both camelCase and kebab-case variations
privateKey := wg.GetString("privatekey")
if privateKey == "" {
privateKey = wg.GetString("private-key")
}
publicKey := wg.GetString("publickey")
if publicKey == "" {
publicKey = wg.GetString("public-key")
}
listenPort := wg.GetInt("listenport")
if listenPort == 0 {
listenPort = wg.GetInt("listen-port")
}
mtu := wg.GetInt("mtu")
running := wg.GetBool("running")
disabled := wg.GetBool("disabled")
// TODO: Interface statistics (rx/tx bytes) are not currently supported
// by the pfSense REST API. This functionality is reserved for future implementation.
var rxBytes, txBytes uint64
pi := domain.PhysicalInterface{
Identifier: domain.InterfaceIdentifier(wg.GetString("name")),
KeyPair: domain.KeyPair{
PrivateKey: privateKey,
PublicKey: publicKey,
},
ListenPort: listenPort,
Addresses: addresses,
Mtu: mtu,
FirewallMark: 0,
DeviceUp: running && !disabled,
ImportSource: domain.ControllerTypePfsense,
DeviceType: domain.ControllerTypePfsense,
BytesUpload: txBytes,
BytesDownload: rxBytes,
}
// Extract description - pfSense API uses "descr" field
description := wg.GetString("descr")
if description == "" {
description = wg.GetString("description")
}
if description == "" {
description = wg.GetString("comment")
}
pi.SetExtras(domain.PfsenseInterfaceExtras{
Id: wg.GetString("id"),
Comment: description,
Disabled: disabled,
})
return pi, nil
}
func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) (
[]domain.PhysicalPeer,
error,
) {
// Query all peers and filter by interface client-side
// Using pfSense REST API v2 endpoints (https://pfrest.org/)
// The API uses query parameters like ?id=0 for specific items, but we need to filter
// by interface (tun field), so we fetch all peers and filter client-side
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{})
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error)
}
if len(wgReply.Data) == 0 {
return nil, nil
}
// Filter peers client-side by checking the "tun" field in each peer
// pfSense peer responses use "tun" field to indicate which tunnel/interface the peer belongs to
peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data))
for _, peer := range wgReply.Data {
// Check if this peer belongs to the requested interface
// pfSense uses "tun" field with the interface name (e.g., "tun_wg0")
peerTun := peer.GetString("tun")
if peerTun == "" {
// Try alternative field names as fallback
peerTun = peer.GetString("interface")
if peerTun == "" {
peerTun = peer.GetString("tunnel")
}
}
// Only include peers that match the requested interface name
if peerTun != string(deviceId) {
if c.cfg.Debug {
slog.Debug("skipping peer - interface mismatch",
"peer", peer.GetString("name"),
"peer_tun", peerTun,
"requested_interface", deviceId,
"peer_id", peer.GetString("id"))
}
continue
}
// Use peer data directly from the list response
peerModel, err := c.convertWireGuardPeer(peer)
if err != nil {
return nil, fmt.Errorf("peer convert failed for %v: %w", peer.GetString("name"), err)
}
peers = append(peers, peerModel)
}
if c.cfg.Debug {
slog.Debug("filtered peers for interface",
"interface", deviceId,
"total_peers_from_api", len(wgReply.Data),
"filtered_peers", len(peers))
}
return peers, nil
}
func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) (
domain.PhysicalPeer,
error,
) {
publicKey := peer.GetString("publickey")
if publicKey == "" {
publicKey = peer.GetString("public-key")
}
privateKey := peer.GetString("privatekey")
if privateKey == "" {
privateKey = peer.GetString("private-key")
}
presharedKey := peer.GetString("presharedkey")
if presharedKey == "" {
presharedKey = peer.GetString("preshared-key")
}
// pfSense returns allowedips as an array of objects with "address" and "mask" fields
// Example: [{"address": "10.1.2.3", "mask": 32, ...}, ...]
var allowedAddresses []domain.Cidr
if allowedIPsValue, ok := peer["allowedips"]; ok {
if allowedIPsArray, ok := allowedIPsValue.([]any); ok {
// Parse array of objects
for _, item := range allowedIPsArray {
if itemObj, ok := item.(map[string]any); ok {
address := ""
mask := 0
// Extract address
if addrVal, ok := itemObj["address"]; ok {
if addrStr, ok := addrVal.(string); ok {
address = addrStr
} else {
address = fmt.Sprintf("%v", addrVal)
}
}
// Extract mask
if maskVal, ok := itemObj["mask"]; ok {
if maskInt, ok := maskVal.(int); ok {
mask = maskInt
} else if maskFloat, ok := maskVal.(float64); ok {
mask = int(maskFloat)
} else if maskStr, ok := maskVal.(string); ok {
if maskInt, err := strconv.Atoi(maskStr); err == nil {
mask = maskInt
}
}
}
// Convert to CIDR format (e.g., "10.1.2.3/32")
if address != "" && mask > 0 {
cidrStr := fmt.Sprintf("%s/%d", address, mask)
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
allowedAddresses = append(allowedAddresses, cidr)
}
}
}
}
} else if allowedIPsStr, ok := allowedIPsValue.(string); ok {
// Fallback: try parsing as comma-separated string
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
}
}
// Fallback to string parsing if array parsing didn't work
if len(allowedAddresses) == 0 {
allowedIPsStr := peer.GetString("allowedips")
if allowedIPsStr == "" {
allowedIPsStr = peer.GetString("allowed-ips")
}
if allowedIPsStr != "" {
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
}
}
endpoint := peer.GetString("endpoint")
port := peer.GetString("port")
// Combine endpoint and port if both are available
if endpoint != "" && port != "" {
// Check if endpoint already contains a port
if !strings.Contains(endpoint, ":") {
endpoint = fmt.Sprintf("%s:%s", endpoint, port)
}
} else if endpoint == "" && port != "" {
// If only port is available, we can't construct a full endpoint
// This might be used with the interface's listenport
}
keepAliveSeconds := 0
keepAliveStr := peer.GetString("persistentkeepalive")
if keepAliveStr == "" {
keepAliveStr = peer.GetString("persistent-keepalive")
}
if keepAliveStr != "" {
duration, err := time.ParseDuration(keepAliveStr)
if err == nil {
keepAliveSeconds = int(duration.Seconds())
} else {
// Try parsing as integer (seconds)
if secs, err := strconv.Atoi(keepAliveStr); err == nil {
keepAliveSeconds = secs
}
}
}
// TODO: Peer statistics (last handshake, rx/tx bytes) are not currently supported
// by the pfSense REST API. This functionality is reserved for future implementation
// when the API adds support for these fields.
// See: https://github.com/jaredhendrickson13/pfsense-api/issues (issue opened by user)
//
// When supported, extract fields like:
// - lastHandshake: peer.GetString("lasthandshake") or peer.GetString("last-handshake")
// - rxBytes: peer.GetInt("rxbytes") or peer.GetInt("rx-bytes")
// - txBytes: peer.GetInt("txbytes") or peer.GetInt("tx-bytes")
lastHandshakeTime := time.Time{}
rxBytes := uint64(0)
txBytes := uint64(0)
peerModel := domain.PhysicalPeer{
Identifier: domain.PeerIdentifier(publicKey),
Endpoint: endpoint,
AllowedIPs: allowedAddresses,
KeyPair: domain.KeyPair{
PublicKey: publicKey,
PrivateKey: privateKey,
},
PresharedKey: domain.PreSharedKey(presharedKey),
PersistentKeepalive: keepAliveSeconds,
LastHandshake: lastHandshakeTime,
ProtocolVersion: 0, // pfSense may not expose protocol version
BytesUpload: txBytes,
BytesDownload: rxBytes,
ImportSource: domain.ControllerTypePfsense,
}
// Extract description/name - pfSense API uses "descr" field
description := peer.GetString("descr")
if description == "" {
description = peer.GetString("description")
}
if description == "" {
description = peer.GetString("comment")
}
// Extract name - pfSense API may use "name" or "descr"
name := peer.GetString("name")
if name == "" {
name = peer.GetString("descr")
}
if name == "" {
name = description // fallback to description if name is not available
}
peerModel.SetExtras(domain.PfsensePeerExtras{
Id: peer.GetString("id"),
Name: name,
Comment: description,
Disabled: peer.GetBool("disabled"),
ClientEndpoint: "", // pfSense may handle this differently
ClientAddress: "", // pfSense may handle this differently
ClientDns: "", // pfSense may handle this differently
ClientKeepalive: 0, // pfSense may handle this differently
})
return peerModel, nil
}
func (c *PfsenseController) SaveInterface(
ctx context.Context,
id domain.InterfaceIdentifier,
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
) error {
// Lock the interface to prevent concurrent modifications
mutex := c.getInterfaceMutex(id)
mutex.Lock()
defer mutex.Unlock()
physicalInterface, err := c.getOrCreateInterface(ctx, id)
if err != nil {
return err
}
deviceId := ""
if physicalInterface.GetExtras() != nil {
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
deviceId = extras.Id
}
}
if updateFunc != nil {
physicalInterface, err = updateFunc(physicalInterface)
if err != nil {
return err
}
if deviceId != "" {
// Ensure the ID is preserved
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
extras.Id = deviceId
physicalInterface.SetExtras(extras)
}
}
}
if err := c.updateInterface(ctx, physicalInterface); err != nil {
return err
}
return nil
}
func (c *PfsenseController) getOrCreateInterface(
ctx context.Context,
id domain.InterfaceIdentifier,
) (*domain.PhysicalInterface, error) {
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"name": string(id),
},
})
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
return c.loadInterfaceData(ctx, wgReply.Data[0])
}
// create a new tunnel if it does not exist
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", lowlevel.GenericJsonObject{
"name": string(id),
})
if createReply.Status == lowlevel.PfsenseApiStatusOk {
return c.loadInterfaceData(ctx, createReply.Data)
}
return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error)
}
func (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
extras := pi.GetExtras().(domain.PfsenseInterfaceExtras)
interfaceId := extras.Id
payload := lowlevel.GenericJsonObject{
"name": string(pi.Identifier),
"description": extras.Comment,
"mtu": strconv.Itoa(pi.Mtu),
"listenport": strconv.Itoa(pi.ListenPort),
"privatekey": pi.KeyPair.PrivateKey,
"disabled": strconv.FormatBool(!pi.DeviceUp),
}
// Add addresses if present
if len(pi.Addresses) > 0 {
addresses := make([]string, 0, len(pi.Addresses))
for _, addr := range pi.Addresses {
addresses = append(addresses, addr.String())
}
payload["addresses"] = strings.Join(addresses, ",")
}
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id}
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload)
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
}
return nil
}
func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
// Lock the interface to prevent concurrent modifications
mutex := c.getInterfaceMutex(id)
mutex.Lock()
defer mutex.Unlock()
// Find the tunnel ID
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"name": string(id),
},
})
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("unable to find WireGuard tunnel %s: %v", id, wgReply.Error)
}
if len(wgReply.Data) == 0 {
return nil // tunnel does not exist, nothing to delete
}
interfaceId := wgReply.Data[0].GetString("id")
// Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId)
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
}
return nil
}
func (c *PfsenseController) SavePeer(
ctx context.Context,
deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier,
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
) error {
// Lock the peer to prevent concurrent modifications
mutex := c.getPeerMutex(id)
mutex.Lock()
defer mutex.Unlock()
physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id)
if err != nil {
return err
}
peerId := ""
if physicalPeer.GetExtras() != nil {
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
peerId = extras.Id
}
}
physicalPeer, err = updateFunc(physicalPeer)
if err != nil {
return err
}
if peerId != "" {
// Ensure the ID is preserved
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
extras.Id = peerId
physicalPeer.SetExtras(extras)
}
}
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
return err
}
return nil
}
func (c *PfsenseController) getOrCreatePeer(
ctx context.Context,
deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier,
) (*domain.PhysicalPeer, error) {
// Query for peer by publickey and interface (tun field)
// The API uses query parameters like ?publickey=...&tun=...
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"publickey": string(id),
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
},
})
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId)
existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0])
if err != nil {
return nil, err
}
return &existingPeer, nil
}
// create a new peer if it does not exist
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular)
slog.Debug("creating new pfSense peer", "peer", id, "interface", deviceId)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", lowlevel.GenericJsonObject{
"name": fmt.Sprintf("wg-%s", id[0:8]),
"interface": string(deviceId),
"publickey": string(id),
"allowedips": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer
})
if createReply.Status == lowlevel.PfsenseApiStatusOk {
newPeer, err := c.convertWireGuardPeer(createReply.Data)
if err != nil {
return nil, err
}
slog.Debug("successfully created pfSense peer", "peer", id, "interface", deviceId)
return &newPeer, nil
}
return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error)
}
func (c *PfsenseController) updatePeer(
ctx context.Context,
deviceId domain.InterfaceIdentifier,
pp *domain.PhysicalPeer,
) error {
extras := pp.GetExtras().(domain.PfsensePeerExtras)
peerId := extras.Id
allowedIPsStr := domain.CidrsToString(pp.AllowedIPs)
slog.Debug("updating pfSense peer",
"peer", pp.Identifier,
"interface", deviceId,
"allowed-ips", allowedIPsStr,
"allowed-ips-count", len(pp.AllowedIPs),
"disabled", extras.Disabled)
payload := lowlevel.GenericJsonObject{
"name": extras.Name,
"description": extras.Comment,
"presharedkey": string(pp.PresharedKey),
"publickey": pp.KeyPair.PublicKey,
"privatekey": pp.KeyPair.PrivateKey,
"persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive),
"disabled": strconv.FormatBool(extras.Disabled),
"allowedips": allowedIPsStr,
}
if pp.Endpoint != "" {
payload["endpoint"] = pp.Endpoint
}
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id}
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload)
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
}
if extras.Disabled {
slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId)
} else {
slog.Debug("successfully updated pfSense peer", "peer", pp.Identifier, "interface", deviceId)
}
return nil
}
func (c *PfsenseController) DeletePeer(
ctx context.Context,
deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier,
) error {
// Lock the peer to prevent concurrent modifications
mutex := c.getPeerMutex(id)
mutex.Lock()
defer mutex.Unlock()
// Query for peer by publickey and interface (tun field)
// The API uses query parameters like ?publickey=...&tun=...
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{
"publickey": string(id),
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
},
})
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("unable to find WireGuard peer %s for interface %s: %v", id, deviceId, wgReply.Error)
}
if len(wgReply.Data) == 0 {
return nil // peer does not exist, nothing to delete
}
peerId := wgReply.Data[0].GetString("id")
// Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId)
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error)
}
return nil
}
// endregion wireguard-related
// region wg-quick-related
func (c *PfsenseController) ExecuteInterfaceHook(
_ context.Context,
_ domain.InterfaceIdentifier,
_ string,
) error {
// TODO implement me
slog.Error("interface hooks are not yet supported for pfSense backends, please open an issue on GitHub")
return nil
}
func (c *PfsenseController) SetDNS(
ctx context.Context,
_ domain.InterfaceIdentifier,
dnsStr, _ string,
) error {
// Lock the interface to prevent concurrent modifications
c.coreMutex.Lock()
defer c.coreMutex.Unlock()
// pfSense DNS configuration is typically managed at the system level
// This may need to be implemented based on pfSense API capabilities
slog.Warn("DNS setting is not yet fully supported for pfSense backends")
return nil
}
func (c *PfsenseController) UnsetDNS(
ctx context.Context,
_ domain.InterfaceIdentifier,
dnsStr, _ string,
) error {
// Lock the interface to prevent concurrent modifications
c.coreMutex.Lock()
defer c.coreMutex.Unlock()
// pfSense DNS configuration is typically managed at the system level
slog.Warn("DNS unsetting is not yet fully supported for pfSense backends")
return nil
}
// endregion wg-quick-related
// region routing-related
func (c *PfsenseController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
// pfSense routing is typically managed through the firewall rules and routing tables
// This may need to be implemented based on pfSense API capabilities
slog.Warn("route setting is not yet fully supported for pfSense backends")
return nil
}
func (c *PfsenseController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
// pfSense routing is typically managed through the firewall rules and routing tables
slog.Warn("route removal is not yet fully supported for pfSense backends")
return nil
}
// endregion routing-related
// region statistics-related
func (c *PfsenseController) PingAddresses(
ctx context.Context,
addr string,
) (*domain.PingerResult, error) {
// Use pfSense API to ping if available, otherwise return error
// This may need to be implemented based on pfSense API capabilities
return nil, fmt.Errorf("ping functionality is not yet implemented for pfSense backends")
}
// endregion statistics-related

View File

@@ -2216,7 +2216,9 @@
"description": "The source of the user. This field is optional.",
"type": "string",
"enum": [
"db"
"db",
"ldap",
"oauth"
],
"example": "db"
}

View File

@@ -561,6 +561,8 @@ definitions:
description: The source of the user. This field is optional.
enum:
- db
- ldap
- oauth
example: db
type: string
required:

View File

@@ -4,10 +4,12 @@ import (
"context"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/go-pkgz/routegroup"
@@ -155,6 +157,37 @@ func (s *Server) setupFrontendRoutes() {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
})
// If a custom frontend path is configured, serve files from there when it contains content.
// If the directory is empty or missing, populate it with the embedded frontend-dist content first.
if s.cfg.Web.FrontendFilePath != "" {
if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil {
slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
} else {
ok := true
hasFiles, err := dirHasFiles(s.cfg.Web.FrontendFilePath)
if err != nil {
slog.Error("failed to check frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
ok = false
}
if !hasFiles && ok {
embeddedFS := fsMust(fs.Sub(frontendStatics, "frontend-dist"))
if err := copyEmbedDirToDisk(embeddedFS, s.cfg.Web.FrontendFilePath); err != nil {
slog.Error("failed to populate frontend base directory from embedded assets",
"path", s.cfg.Web.FrontendFilePath, "error", err)
ok = false
}
}
if ok {
// serve files from FS
slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath)
s.server.HandleFiles("/app", http.Dir(s.cfg.Web.FrontendFilePath))
return
}
}
}
// Fallback: serve embedded frontend files
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
}
@@ -182,3 +215,67 @@ func fsMust(f fs.FS, err error) fs.FS {
}
return f
}
// dirHasFiles returns true if the directory contains at least one file (non-directory).
func dirHasFiles(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
defer d.Close()
// Read a few entries; if any entry exists, consider it having files/dirs.
// We want to know if there is at least one file; if only subdirs exist, still treat as content.
entries, err := d.Readdir(-1)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
// check recursively
has, err := dirHasFiles(filepath.Join(dir, e.Name()))
if err == nil && has {
return true, nil
}
continue
}
// regular file
return true, nil
}
return false, nil
}
// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk.
func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error {
return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dstDir, path)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
// ensure parent dir exists
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
// open source file
f, err := srcFS.Open(path)
if err != nil {
return err
}
defer f.Close()
out, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(out, f); err != nil {
_ = out.Close()
return err
}
return out.Close()
})
}

View File

@@ -48,12 +48,12 @@ func (e InterfaceEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.Use(e.authenticator.LoggedIn(ScopeAdmin))
apiGroup.HandleFunc("GET /all", e.handleAllGet())
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
apiGroup.HandleFunc("GET /by-id/{id...}", e.handleByIdGet())
apiGroup.HandleFunc("GET /prepare", e.handlePrepareGet())
apiGroup.HandleFunc("POST /new", e.handleCreatePost())
apiGroup.HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
apiGroup.HandleFunc("DELETE /by-id/{id}", e.handleDelete())
apiGroup.HandleFunc("PUT /by-id/{id...}", e.handleUpdatePut())
apiGroup.HandleFunc("DELETE /by-id/{id...}", e.handleDelete())
}
// handleAllGet returns a gorm Handler function.

View File

@@ -44,10 +44,10 @@ func (e MetricsEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/metrics")
apiGroup.Use(e.authenticator.LoggedIn())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id}",
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id...}",
e.handleMetricsForInterfaceGet())
apiGroup.HandleFunc("GET /by-user/{id}", e.handleMetricsForUserGet())
apiGroup.HandleFunc("GET /by-peer/{id}", e.handleMetricsForPeerGet())
apiGroup.HandleFunc("GET /by-user/{id...}", e.handleMetricsForUserGet())
apiGroup.HandleFunc("GET /by-peer/{id...}", e.handleMetricsForPeerGet())
}
// handleMetricsForInterfaceGet returns a gorm Handler function.

View File

@@ -47,15 +47,15 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/peer")
apiGroup.Use(e.authenticator.LoggedIn())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id}",
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id...}",
e.handleAllForInterfaceGet())
apiGroup.HandleFunc("GET /by-user/{id}", e.handleAllForUserGet())
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
apiGroup.HandleFunc("GET /by-user/{id...}", e.handleAllForUserGet())
apiGroup.HandleFunc("GET /by-id/{id...}", e.handleByIdGet())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /prepare/{id}", e.handlePrepareGet())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /prepare/{id...}", e.handlePrepareGet())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id}", e.handleDelete())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id...}", e.handleUpdatePut())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id...}", e.handleDelete())
}
// handleAllForInterfaceGet returns a gorm Handler function.

View File

@@ -47,10 +47,10 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.Use(e.authenticator.LoggedIn())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /all", e.handleAllGet())
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
apiGroup.HandleFunc("GET /by-id/{id...}", e.handleByIdGet())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id}", e.handleDelete())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id...}", e.handleUpdatePut())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id...}", e.handleDelete())
}
// handleAllGet returns a gorm Handler function.

View File

@@ -13,7 +13,7 @@ type User struct {
// The email address of the user. This field is optional.
Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"`
// The source of the user. This field is optional.
Source string `json:"Source" binding:"oneof=db" example:"db"`
Source string `json:"Source" binding:"oneof=db ldap oauth" example:"db"`
// The name of the authentication provider. This field is read-only.
ProviderName string `json:"ProviderName,omitempty" readonly:"true" example:""`
// If this field is set, the user is an admin.

View File

@@ -20,18 +20,7 @@ type LdapAuthenticator struct {
}
func newLdapAuthenticator(_ context.Context, cfg *config.LdapProvider) (*LdapAuthenticator, error) {
var provider = &LdapAuthenticator{}
provider.cfg = cfg
dn, err := ldap.ParseDN(cfg.AdminGroupDN)
if err != nil {
return nil, fmt.Errorf("failed to parse admin group DN: %w", err)
}
provider.cfg.FieldMap = provider.getLdapFieldMapping(cfg.FieldMap)
provider.cfg.ParsedAdminGroupDN = dn
return provider, nil
return &LdapAuthenticator{cfg: cfg}, nil
}
// GetName returns the name of the LDAP authenticator.
@@ -154,40 +143,3 @@ func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.Authentica
return userInfo, nil
}
func (l LdapAuthenticator) getLdapFieldMapping(f config.LdapFields) config.LdapFields {
defaultMap := config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "mail",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "memberOf",
}
if f.UserIdentifier != "" {
defaultMap.UserIdentifier = f.UserIdentifier
}
if f.Email != "" {
defaultMap.Email = f.Email
}
if f.Firstname != "" {
defaultMap.Firstname = f.Firstname
}
if f.Lastname != "" {
defaultMap.Lastname = f.Lastname
}
if f.Phone != "" {
defaultMap.Phone = f.Phone
}
if f.Department != "" {
defaultMap.Department = f.Department
}
if f.GroupMembership != "" {
defaultMap.GroupMembership = f.GroupMembership
}
return defaultMap
}

View File

@@ -72,7 +72,7 @@ func NewMailManager(
users UserDatabaseRepo,
wg WireguardDatabaseRepo,
) (*Manager, error) {
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle, cfg.Mail.TemplatesPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
}

View File

@@ -6,6 +6,10 @@ import (
"fmt"
htmlTemplate "html/template"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"text/template"
"github.com/h44z/wg-portal/internal/domain"
@@ -22,15 +26,50 @@ type TemplateHandler struct {
textTemplates *template.Template
}
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
func newTemplateHandler(portalUrl, portalName string, basePath string) (*TemplateHandler, error) {
// Always parse embedded defaults first
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
if err != nil {
return nil, fmt.Errorf("failed to parse html template files: %w", err)
return nil, fmt.Errorf("failed to parse embedded html template files: %w", err)
}
txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
if err != nil {
return nil, fmt.Errorf("failed to parse text template files: %w", err)
return nil, fmt.Errorf("failed to parse embedded text template files: %w", err)
}
// If a basePath is provided, ensure existence, populate if empty, then parse to override
if basePath != "" {
if err := os.MkdirAll(basePath, 0755); err != nil {
return nil, fmt.Errorf("failed to create templates base directory %s: %w", basePath, err)
}
hasTemplates, err := dirHasTemplates(basePath)
if err != nil {
return nil, fmt.Errorf("failed to inspect templates directory: %w", err)
}
// If no templates present, copy embedded defaults to directory
if !hasTemplates {
if err := copyEmbeddedTemplates(basePath); err != nil {
return nil, fmt.Errorf("failed to populate templates directory: %w", err)
}
}
// Parse files from basePath to override embedded ones.
// Only parse when matches exist to allow partial overrides without errors.
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gohtml")); len(matches) > 0 {
slog.Debug("parsing html email templates from base path", "base-path", basePath, "files", matches)
if htmlTemplateCache, err = htmlTemplateCache.ParseFiles(matches...); err != nil {
return nil, fmt.Errorf("failed to parse html templates from base path: %w", err)
}
}
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gotpl")); len(matches) > 0 {
slog.Debug("parsing text email templates from base path", "base-path", basePath, "files", matches)
if txtTemplateCache, err = txtTemplateCache.ParseFiles(matches...); err != nil {
return nil, fmt.Errorf("failed to parse text templates from base path: %w", err)
}
}
}
handler := &TemplateHandler{
@@ -43,24 +82,71 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
return handler, nil
}
// dirHasTemplates checks whether directory contains any .gohtml or .gotpl files.
func dirHasTemplates(basePath string) (bool, error) {
entries, err := os.ReadDir(basePath)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := filepath.Ext(e.Name())
if ext == ".gohtml" || ext == ".gotpl" {
return true, nil
}
}
return false, nil
}
// copyEmbeddedTemplates writes embedded templates into basePath.
func copyEmbeddedTemplates(basePath string) error {
list, err := fs.ReadDir(TemplateFiles, "tpl_files")
if err != nil {
return err
}
for _, entry := range list {
if entry.IsDir() {
continue
}
name := entry.Name()
// Only copy known template extensions
if ext := filepath.Ext(name); ext != ".gohtml" && ext != ".gotpl" {
continue
}
data, err := TemplateFiles.ReadFile(filepath.Join("tpl_files", name))
if err != nil {
return err
}
out := filepath.Join(basePath, name)
if err := os.WriteFile(out, data, 0644); err != nil {
return err
}
}
return nil
}
// GetConfigMail returns the text and html template for the mail with a link.
func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
var tplBuff bytes.Buffer
var htmlTplBuff bytes.Buffer
err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
}
err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)

View File

@@ -551,6 +551,12 @@ func (m Manager) updateLdapUsers(
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
}
if provider.SyncLogUserInfo {
slog.Debug("ldap user data",
"raw-user", rawUser, "user", user.Identifier,
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
}
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)

View File

@@ -44,6 +44,10 @@ func (c *ControllerManager) init() error {
return err
}
if err := c.registerPfsenseControllers(); err != nil {
return err
}
c.logRegisteredControllers()
return nil
@@ -86,6 +90,26 @@ func (c *ControllerManager) registerMikrotikControllers() error {
return nil
}
func (c *ControllerManager) registerPfsenseControllers() error {
for _, backendConfig := range c.cfg.Backend.Pfsense {
if backendConfig.Id == config.LocalBackendName {
slog.Warn("skipping registration of pfSense controller with reserved ID", "id", config.LocalBackendName)
continue
}
controller, err := wgcontroller.NewPfsenseController(c.cfg, &backendConfig)
if err != nil {
return fmt.Errorf("failed to create pfSense controller for backend %s: %w", backendConfig.Id, err)
}
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
Config: backendConfig.BackendBase,
Implementation: controller,
}
}
return nil
}
func (c *ControllerManager) logRegisteredControllers() {
for backend, controller := range c.controllers {
slog.Debug("backend controller registered",

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"slices"
"strings"
"time"
"github.com/h44z/wg-portal/internal/app"
@@ -867,6 +868,17 @@ func (m Manager) importInterface(
iface.Backend = backend.GetId()
iface.PeerDefAllowedIPsStr = iface.AddressStr()
// For pfSense backends, extract endpoint and DNS from peers
if backend.GetId() == domain.ControllerTypePfsense {
endpoint, dns := extractPfsenseDefaultsFromPeers(peers, iface.ListenPort)
if endpoint != "" {
iface.PeerDefEndpoint = endpoint
}
if dns != "" {
iface.PeerDefDnsStr = dns
}
}
// try to predict the interface type based on the number of peers
switch len(peers) {
case 0:
@@ -904,6 +916,61 @@ func (m Manager) importInterface(
return nil
}
// extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers
// For server interfaces, peers typically have endpoints pointing to the server, so we use the most common one
func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) {
if len(peers) == 0 {
return "", ""
}
// Count endpoint occurrences to find the most common one
endpointCounts := make(map[string]int)
dnsValues := make(map[string]int)
for _, peer := range peers {
// Extract endpoint from peer
if peer.Endpoint != "" {
endpointCounts[peer.Endpoint]++
}
// Extract DNS from peer extras if available
if extras := peer.GetExtras(); extras != nil {
if pfsenseExtras, ok := extras.(domain.PfsensePeerExtras); ok {
if pfsenseExtras.ClientDns != "" {
dnsValues[pfsenseExtras.ClientDns]++
}
}
}
}
// Find the most common endpoint
maxCount := 0
for ep, count := range endpointCounts {
if count > maxCount {
maxCount = count
endpoint = ep
}
}
// If endpoint doesn't have a port and we have a listenPort, add it
if endpoint != "" && listenPort > 0 {
if !strings.Contains(endpoint, ":") {
endpoint = fmt.Sprintf("%s:%d", endpoint, listenPort)
}
}
// Find the most common DNS
maxDnsCount := 0
for dnsVal, count := range dnsValues {
if count > maxDnsCount {
maxDnsCount = count
dns = dnsVal
}
}
return endpoint, dns
}
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
now := time.Now()
peer := domain.ConvertPhysicalPeer(p)

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"log/slog"
"regexp"
"time"
@@ -125,6 +126,45 @@ type LdapFields struct {
GroupMembership string `yaml:"memberof"`
}
// getMappingWithDefaults returns a full field mapping for the LDAP provider.
// If specific fields are not set, the default values are used.
func (f LdapFields) getMappingWithDefaults() LdapFields {
defaultMap := LdapFields{
BaseFields: BaseFields{
UserIdentifier: "mail",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "memberOf",
}
if f.UserIdentifier != "" {
defaultMap.UserIdentifier = f.UserIdentifier
}
if f.Email != "" {
defaultMap.Email = f.Email
}
if f.Firstname != "" {
defaultMap.Firstname = f.Firstname
}
if f.Lastname != "" {
defaultMap.Lastname = f.Lastname
}
if f.Phone != "" {
defaultMap.Phone = f.Phone
}
if f.Department != "" {
defaultMap.Department = f.Department
}
if f.GroupMembership != "" {
defaultMap.GroupMembership = f.GroupMembership
}
return defaultMap
}
// LdapProvider contains the configuration for the LDAP connection.
type LdapProvider struct {
// ProviderName is an internal name that is used to distinguish LDAP servers. It must not contain spaces or special characters.
@@ -168,6 +208,8 @@ type LdapProvider struct {
SyncFilter string `yaml:"sync_filter"`
// SyncInterval is the interval between consecutive LDAP user syncs. If it is 0, sync is disabled.
SyncInterval time.Duration `yaml:"sync_interval"`
// If SyncLogUserInfo is set to true, the user info retrieved from the LDAP provider during a sync-run will be logged in trace level.
SyncLogUserInfo bool `yaml:"sync_log_user_info"`
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
RegistrationEnabled bool `yaml:"registration_enabled"`
@@ -176,6 +218,19 @@ type LdapProvider struct {
LogUserInfo bool `yaml:"log_user_info"`
}
// Sanitize checks the LDAP configuration and sets default values for missing fields.
func (l *LdapProvider) Sanitize() error {
l.FieldMap = l.FieldMap.getMappingWithDefaults()
dn, err := ldap.ParseDN(l.AdminGroupDN)
if err != nil {
return fmt.Errorf("failed to parse admin group DN: %w", err)
}
l.ParsedAdminGroupDN = dn
return nil
}
// OpenIDConnectProvider contains the configuration for the OpenID Connect provider.
type OpenIDConnectProvider struct {
// ProviderName is an internal name that is used to distinguish oauth endpoints. It must not contain spaces or special characters.

View File

@@ -18,6 +18,7 @@ type Backend struct {
// External Backend-specific configuration
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
Pfsense []BackendPfsense `yaml:"pfsense"`
}
// Validate checks the backend configuration for errors.
@@ -36,6 +37,15 @@ func (b *Backend) Validate() error {
}
uniqueMap[backend.Id] = struct{}{}
}
for _, backend := range b.Pfsense {
if backend.Id == LocalBackendName {
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
}
if _, exists := uniqueMap[backend.Id]; exists {
return fmt.Errorf("backend ID %q is not unique", backend.Id)
}
uniqueMap[backend.Id] = struct{}{}
}
if b.Default != LocalBackendName {
if _, ok := uniqueMap[b.Default]; !ok {
@@ -101,3 +111,42 @@ func (b *BackendMikrotik) GetApiTimeout() time.Duration {
}
return b.ApiTimeout
}
type BackendPfsense struct {
BackendBase `yaml:",inline"` // Embed the base fields
ApiUrl string `yaml:"api_url"` // The base URL of the pfSense REST API (e.g., "https://pfsense.example.com/api/v2")
ApiKey string `yaml:"api_key"` // API key for authentication (generated in pfSense under 'System' -> 'REST API' -> 'Keys')
ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the pfSense API
ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds)
// Concurrency controls the maximum number of concurrent API requests that this backend will issue
// when enumerating interfaces and their details. If 0 or negative, a default of 5 is used.
Concurrency int `yaml:"concurrency"`
Debug bool `yaml:"debug"` // Enable debug logging for the pfSense backend
}
// GetConcurrency returns the configured concurrency for this backend or a sane default (5)
// when the configured value is zero or negative.
func (b *BackendPfsense) GetConcurrency() int {
if b == nil {
return 5
}
if b.Concurrency <= 0 {
return 5
}
return b.Concurrency
}
// GetApiTimeout returns the configured API timeout or a sane default (30 seconds)
// when the configured value is zero or negative.
func (b *BackendPfsense) GetApiTimeout() time.Duration {
if b == nil {
return 30 * time.Second
}
if b.ApiTimeout <= 0 {
return 30 * time.Second
}
return b.ApiTimeout
}

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/a8m/envsubst"
@@ -114,82 +116,96 @@ func (c *Config) LogStartupValues() {
func defaultConfig() *Config {
cfg := &Config{}
cfg.Core.AdminUserDisabled = false
cfg.Core.AdminUser = "admin@wgportal.local"
cfg.Core.AdminPassword = "wgportal-default"
cfg.Core.AdminApiToken = "" // by default, the API access is disabled
cfg.Core.ImportExisting = true
cfg.Core.RestoreState = true
cfg.Core.CreateDefaultPeer = false
cfg.Core.CreateDefaultPeerOnCreation = false
cfg.Core.EditableKeys = true
cfg.Core.SelfProvisioningAllowed = false
cfg.Core.ReEnablePeerAfterUserEnable = true
cfg.Core.DeletePeerAfterUserDeleted = false
cfg.Core.AdminUserDisabled = getEnvBool("WG_PORTAL_CORE_DISABLE_ADMIN_USER", false)
cfg.Core.AdminUser = getEnvStr("WG_PORTAL_CORE_ADMIN_USER", "admin@wgportal.local")
cfg.Core.AdminPassword = getEnvStr("WG_PORTAL_CORE_ADMIN_PASSWORD", "wgportal-default")
cfg.Core.AdminApiToken = getEnvStr("WG_PORTAL_CORE_ADMIN_API_TOKEN", "") // by default, the API access is disabled
cfg.Core.ImportExisting = getEnvBool("WG_PORTAL_CORE_IMPORT_EXISTING", true)
cfg.Core.RestoreState = getEnvBool("WG_PORTAL_CORE_RESTORE_STATE", true)
cfg.Core.CreateDefaultPeer = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER", false)
cfg.Core.CreateDefaultPeerOnCreation = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION", false)
cfg.Core.EditableKeys = getEnvBool("WG_PORTAL_CORE_EDITABLE_KEYS", true)
cfg.Core.SelfProvisioningAllowed = getEnvBool("WG_PORTAL_CORE_SELF_PROVISIONING_ALLOWED", false)
cfg.Core.ReEnablePeerAfterUserEnable = getEnvBool("WG_PORTAL_CORE_RE_ENABLE_PEER_AFTER_USER_ENABLE", true)
cfg.Core.DeletePeerAfterUserDeleted = getEnvBool("WG_PORTAL_CORE_DELETE_PEER_AFTER_USER_DELETED", false)
cfg.Database = DatabaseConfig{
Type: "sqlite",
DSN: "data/sqlite.db",
Debug: getEnvBool("WG_PORTAL_DATABASE_DEBUG", false),
SlowQueryThreshold: getEnvDuration("WG_PORTAL_DATABASE_SLOW_QUERY_THRESHOLD", 0),
Type: SupportedDatabase(getEnvStr("WG_PORTAL_DATABASE_TYPE", "sqlite")),
DSN: getEnvStr("WG_PORTAL_DATABASE_DSN", "data/sqlite.db"),
EncryptionPassphrase: getEnvStr("WG_PORTAL_DATABASE_ENCRYPTION_PASSPHRASE", ""),
}
cfg.Backend = Backend{
Default: LocalBackendName, // local backend is the default (using wgcrtl)
Default: LocalBackendName, // local backend is the default (using wgcrtl)
IgnoredLocalInterfaces: getEnvStrSlice("WG_PORTAL_BACKEND_IGNORED_LOCAL_INTERFACES", nil),
// Most resolconf implementations use "tun." as a prefix for interface names.
// But systemd's implementation uses no prefix, for example.
LocalResolvconfPrefix: "tun.",
LocalResolvconfPrefix: getEnvStr("WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX", "tun."),
}
cfg.Web = WebConfig{
RequestLogging: false,
ExternalUrl: "http://localhost:8888",
ListeningAddress: ":8888",
SessionIdentifier: "wgPortalSession",
SessionSecret: "very_secret",
CsrfSecret: "extremely_secret",
SiteTitle: "WireGuard Portal",
SiteCompanyName: "WireGuard Portal",
RequestLogging: getEnvBool("WG_PORTAL_WEB_REQUEST_LOGGING", false),
ExposeHostInfo: getEnvBool("WG_PORTAL_WEB_EXPOSE_HOST_INFO", false),
ExternalUrl: getEnvStr("WG_PORTAL_WEB_EXTERNAL_URL", "http://localhost:8888"),
ListeningAddress: getEnvStr("WG_PORTAL_WEB_LISTENING_ADDRESS", ":8888"),
SessionIdentifier: getEnvStr("WG_PORTAL_WEB_SESSION_IDENTIFIER", "wgPortalSession"),
SessionSecret: getEnvStr("WG_PORTAL_WEB_SESSION_SECRET", "very_secret"),
CsrfSecret: getEnvStr("WG_PORTAL_WEB_CSRF_SECRET", "extremely_secret"),
SiteTitle: getEnvStr("WG_PORTAL_WEB_SITE_TITLE", "WireGuard Portal"),
SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"),
CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""),
KeyFile: getEnvStr("WG_PORTAL_WEB_KEY_FILE", ""),
FrontendFilePath: getEnvStr("WG_PORTAL_WEB_FRONTEND_FILEPATH", ""),
}
cfg.Advanced.LogLevel = "info"
cfg.Advanced.StartListenPort = 51820
cfg.Advanced.StartCidrV4 = "10.11.12.0/24"
cfg.Advanced.StartCidrV6 = "fdfd:d3ad:c0de:1234::0/64"
cfg.Advanced.UseIpV6 = true
cfg.Advanced.ExpiryCheckInterval = 15 * time.Minute
cfg.Advanced.RulePrioOffset = 20000
cfg.Advanced.RouteTableOffset = 20000
cfg.Advanced.ApiAdminOnly = true
cfg.Advanced.LimitAdditionalUserPeers = 0
cfg.Advanced.LogLevel = getEnvStr("WG_PORTAL_ADVANCED_LOG_LEVEL", "info")
cfg.Advanced.LogPretty = getEnvBool("WG_PORTAL_ADVANCED_LOG_PRETTY", false)
cfg.Advanced.LogJson = getEnvBool("WG_PORTAL_ADVANCED_LOG_JSON", false)
cfg.Advanced.StartListenPort = getEnvInt("WG_PORTAL_ADVANCED_START_LISTEN_PORT", 51820)
cfg.Advanced.StartCidrV4 = getEnvStr("WG_PORTAL_ADVANCED_START_CIDR_V4", "10.11.12.0/24")
cfg.Advanced.StartCidrV6 = getEnvStr("WG_PORTAL_ADVANCED_START_CIDR_V6", "fdfd:d3ad:c0de:1234::0/64")
cfg.Advanced.UseIpV6 = getEnvBool("WG_PORTAL_ADVANCED_USE_IP_V6", true)
cfg.Advanced.ConfigStoragePath = getEnvStr("WG_PORTAL_ADVANCED_CONFIG_STORAGE_PATH", "")
cfg.Advanced.ExpiryCheckInterval = getEnvDuration("WG_PORTAL_ADVANCED_EXPIRY_CHECK_INTERVAL", 15*time.Minute)
cfg.Advanced.RulePrioOffset = getEnvInt("WG_PORTAL_ADVANCED_RULE_PRIO_OFFSET", 20000)
cfg.Advanced.RouteTableOffset = getEnvInt("WG_PORTAL_ADVANCED_ROUTE_TABLE_OFFSET", 20000)
cfg.Advanced.ApiAdminOnly = getEnvBool("WG_PORTAL_ADVANCED_API_ADMIN_ONLY", true)
cfg.Advanced.LimitAdditionalUserPeers = getEnvInt("WG_PORTAL_ADVANCED_LIMIT_ADDITIONAL_USER_PEERS", 0)
cfg.Statistics.UsePingChecks = true
cfg.Statistics.PingCheckWorkers = 10
cfg.Statistics.PingUnprivileged = false
cfg.Statistics.PingCheckInterval = 1 * time.Minute
cfg.Statistics.DataCollectionInterval = 1 * time.Minute
cfg.Statistics.CollectInterfaceData = true
cfg.Statistics.CollectPeerData = true
cfg.Statistics.CollectAuditData = true
cfg.Statistics.ListeningAddress = ":8787"
cfg.Statistics.UsePingChecks = getEnvBool("WG_PORTAL_STATISTICS_USE_PING_CHECKS", true)
cfg.Statistics.PingCheckWorkers = getEnvInt("WG_PORTAL_STATISTICS_PING_CHECK_WORKERS", 10)
cfg.Statistics.PingUnprivileged = getEnvBool("WG_PORTAL_STATISTICS_PING_UNPRIVILEGED", false)
cfg.Statistics.PingCheckInterval = getEnvDuration("WG_PORTAL_STATISTICS_PING_CHECK_INTERVAL", 1*time.Minute)
cfg.Statistics.DataCollectionInterval = getEnvDuration("WG_PORTAL_STATISTICS_DATA_COLLECTION_INTERVAL",
1*time.Minute)
cfg.Statistics.CollectInterfaceData = getEnvBool("WG_PORTAL_STATISTICS_COLLECT_INTERFACE_DATA", true)
cfg.Statistics.CollectPeerData = getEnvBool("WG_PORTAL_STATISTICS_COLLECT_PEER_DATA", true)
cfg.Statistics.CollectAuditData = getEnvBool("WG_PORTAL_STATISTICS_COLLECT_AUDIT_DATA", true)
cfg.Statistics.ListeningAddress = getEnvStr("WG_PORTAL_STATISTICS_LISTENING_ADDRESS", ":8787")
cfg.Mail = MailConfig{
Host: "127.0.0.1",
Port: 25,
Encryption: MailEncryptionNone,
CertValidation: true,
Username: "",
Password: "",
AuthType: MailAuthPlain,
From: "Wireguard Portal <noreply@wireguard.local>",
LinkOnly: false,
Host: getEnvStr("WG_PORTAL_MAIL_HOST", "127.0.0.1"),
Port: getEnvInt("WG_PORTAL_MAIL_PORT", 25),
Encryption: MailEncryption(getEnvStr("WG_PORTAL_MAIL_ENCRYPTION", string(MailEncryptionNone))),
CertValidation: getEnvBool("WG_PORTAL_MAIL_CERT_VALIDATION", true),
Username: getEnvStr("WG_PORTAL_MAIL_USERNAME", ""),
Password: getEnvStr("WG_PORTAL_MAIL_PASSWORD", ""),
AuthType: MailAuthType(getEnvStr("WG_PORTAL_MAIL_AUTH_TYPE", string(MailAuthPlain))),
From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"),
LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false),
AllowPeerEmail: getEnvBool("WG_PORTAL_MAIL_ALLOW_PEER_EMAIL", false),
TemplatesPath: getEnvStr("WG_PORTAL_MAIL_TEMPLATES_PATH", ""),
}
cfg.Webhook.Url = "" // no webhook by default
cfg.Webhook.Authentication = ""
cfg.Webhook.Timeout = 10 * time.Second
cfg.Webhook.Url = getEnvStr("WG_PORTAL_WEBHOOK_URL", "") // no webhook by default
cfg.Webhook.Authentication = getEnvStr("WG_PORTAL_WEBHOOK_AUTHENTICATION", "")
cfg.Webhook.Timeout = getEnvDuration("WG_PORTAL_WEBHOOK_TIMEOUT", 10*time.Second)
cfg.Auth.WebAuthn.Enabled = true
cfg.Auth.MinPasswordLength = 16
cfg.Auth.HideLoginForm = false
cfg.Auth.WebAuthn.Enabled = getEnvBool("WG_PORTAL_AUTH_WEBAUTHN_ENABLED", true)
cfg.Auth.MinPasswordLength = getEnvInt("WG_PORTAL_AUTH_MIN_PASSWORD_LENGTH", 16)
cfg.Auth.HideLoginForm = getEnvBool("WG_PORTAL_AUTH_HIDE_LOGIN_FORM", false)
return cfg
}
@@ -222,6 +238,11 @@ func GetConfig() (*Config, error) {
if err != nil {
return nil, err
}
for i := range cfg.Auth.Ldap {
if err := cfg.Auth.Ldap[i].Sanitize(); err != nil {
return nil, fmt.Errorf("sanitizing of ldap config for %s failed: %w", cfg.Auth.Ldap[i].ProviderName, err)
}
}
return cfg, nil
}
@@ -244,3 +265,75 @@ func loadConfigFile(cfg any, filename string) error {
return nil
}
func getEnvStr(name, fallback string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return fallback
}
func getEnvStrSlice(name string, fallback []string) []string {
v, ok := os.LookupEnv(name)
if !ok {
return fallback
}
strParts := strings.Split(v, ",")
stringSlice := make([]string, 0, len(strParts))
for _, s := range strParts {
trimmed := strings.TrimSpace(s)
if trimmed != "" {
stringSlice = append(stringSlice, trimmed)
}
}
return stringSlice
}
func getEnvBool(name string, fallback bool) bool {
v, ok := os.LookupEnv(name)
if !ok {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
slog.Warn("invalid bool env, using fallback", "env", name, "value", v, "fallback", fallback)
return fallback
}
return b
}
func getEnvInt(name string, fallback int) int {
v, ok := os.LookupEnv(name)
if !ok {
return fallback
}
i, err := strconv.Atoi(v)
if err != nil {
slog.Warn("invalid int env, using fallback", "env", name, "value", v, "fallback", fallback)
return fallback
}
return i
}
func getEnvDuration(name string, fallback time.Duration) time.Duration {
v, ok := os.LookupEnv(name)
if !ok {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
slog.Warn("invalid duration env, using fallback", "env", name, "value", v, "fallback", fallback)
return fallback
}
return d
}

View File

@@ -43,4 +43,8 @@ type MailConfig struct {
LinkOnly bool `yaml:"link_only"`
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
AllowPeerEmail bool `yaml:"allow_peer_email"`
// TemplatesPath is an optional base path on the filesystem that contains email templates (.gotpl and .gohtml).
// If the directory exists but is empty, the embedded default templates will be written there on startup.
// If templates are present in the directory, they override the embedded defaults.
TemplatesPath string `yaml:"templates_path"`
}

View File

@@ -27,6 +27,10 @@ type WebConfig struct {
CertFile string `yaml:"cert_file"`
// KeyFile is the path to the TLS certificate key file.
KeyFile string `yaml:"key_file"`
// FrontendFilePath is an optional path to a folder that contains the frontend files.
// If set and the folder contains at least one file, it overrides the embedded frontend.
// If set and the folder is empty or does not exist, the embedded frontend will be written into it on startup.
FrontendFilePath string `yaml:"frontend_filepath"`
}
func (c *WebConfig) Sanitize() {

View File

@@ -5,6 +5,7 @@ package domain
const (
ControllerTypeMikrotik = "mikrotik"
ControllerTypeLocal = "wgctrl"
ControllerTypePfsense = "pfsense"
)
// Controller extras can be used to store additional information available for specific controllers only.
@@ -30,3 +31,20 @@ type MikrotikPeerExtras struct {
type LocalPeerExtras struct {
Disabled bool
}
type PfsenseInterfaceExtras struct {
Id string // internal pfSense ID
Comment string
Disabled bool
}
type PfsensePeerExtras struct {
Id string // internal pfSense ID
Name string
Comment string
Disabled bool
ClientEndpoint string
ClientAddress string
ClientDns string
ClientKeepalive int
}

View File

@@ -240,7 +240,8 @@ func (p *PhysicalInterface) GetExtras() any {
func (p *PhysicalInterface) SetExtras(extras any) {
switch extras.(type) {
case MikrotikInterfaceExtras: // OK
default: // we only support MikrotikInterfaceExtras for now
case PfsenseInterfaceExtras: // OK
default: // we only support MikrotikInterfaceExtras and PfsenseInterfaceExtras for now
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
}
@@ -303,6 +304,14 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
} else {
iface.Disabled = nil
}
case ControllerTypePfsense:
extras := pi.GetExtras().(PfsenseInterfaceExtras)
iface.DisplayName = extras.Comment
if extras.Disabled {
iface.Disabled = &now
} else {
iface.Disabled = nil
}
}
return iface
@@ -325,6 +334,12 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
Disabled: i.IsDisabled(),
}
pi.SetExtras(extras)
case ControllerTypePfsense:
extras := PfsenseInterfaceExtras{
Comment: i.DisplayName,
Disabled: i.IsDisabled(),
}
pi.SetExtras(extras)
}
}

View File

@@ -240,7 +240,8 @@ func (p *PhysicalPeer) SetExtras(extras any) {
switch extras.(type) {
case MikrotikPeerExtras: // OK
case LocalPeerExtras: // OK
default: // we only support MikrotikPeerExtras and LocalPeerExtras for now
case PfsensePeerExtras: // OK
default: // we only support MikrotikPeerExtras, LocalPeerExtras, and PfsensePeerExtras for now
panic(fmt.Sprintf("unsupported peer backend extras type %T", extras))
}
@@ -301,6 +302,26 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
peer.Disabled = nil
peer.DisabledReason = ""
}
case ControllerTypePfsense:
extras := pp.GetExtras().(PfsensePeerExtras)
peer.Notes = extras.Comment
peer.DisplayName = extras.Name
if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true)
peer.Interface.Type = InterfaceTypeClient
peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress)
peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true)
peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true)
} else {
peer.Interface.Type = InterfaceTypeServer
}
if extras.Disabled {
peer.Disabled = &now
peer.DisabledReason = "Disabled by pfSense controller"
} else {
peer.Disabled = nil
peer.DisabledReason = ""
}
}
return peer
@@ -355,6 +376,18 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
Disabled: p.IsDisabled(),
}
pp.SetExtras(extras)
case ControllerTypePfsense:
extras := PfsensePeerExtras{
Id: "",
Name: p.DisplayName,
Comment: p.Notes,
Disabled: p.IsDisabled(),
ClientEndpoint: p.Endpoint.GetValue(),
ClientAddress: CidrsToString(p.Interface.Addresses),
ClientDns: p.Interface.DnsStr.GetValue(),
ClientKeepalive: p.PersistentKeepalive.GetValue(),
}
pp.SetExtras(extras)
}
}

View File

@@ -0,0 +1,428 @@
package lowlevel
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
)
// PfsenseApiClient provides HTTP client functionality for interacting with the pfSense REST API.
// Documentation: https://pfrest.org/
// Swagger UI: https://pfrest.org/api-docs/
// region models
const (
PfsenseApiStatusOk = "ok" // pfSense REST API uses "ok" in response
PfsenseApiStatusError = "error"
)
const (
PfsenseApiErrorCodeUnknown = iota + 700
PfsenseApiErrorCodeRequestPreparationFailed
PfsenseApiErrorCodeRequestFailed
PfsenseApiErrorCodeResponseDecodeFailed
)
type PfsenseApiResponse[T any] struct {
Status string
Code int
Data T `json:"data,omitempty"`
Error *PfsenseApiError `json:"error,omitempty"`
}
type PfsenseApiError struct {
Code int `json:"error,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"detail,omitempty"`
}
func (e *PfsenseApiError) String() string {
if e == nil {
return "no error"
}
return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details)
}
type PfsenseRequestOptions struct {
Filters map[string]string `json:"filters,omitempty"`
PropList []string `json:"proplist,omitempty"`
}
func (o *PfsenseRequestOptions) GetPath(base string) string {
if o == nil {
return base
}
path, err := url.Parse(base)
if err != nil {
return base
}
query := path.Query()
// pfSense REST API uses standard query parameters for filtering
for k, v := range o.Filters {
query.Set(k, v)
}
// Note: PropList may not be supported by pfSense REST API in the same way as Mikrotik
// pfSense typically returns all fields by default, but we keep this for potential future use
// Verify the correct parameter name in Swagger docs if field selection is needed
if len(o.PropList) > 0 {
// pfSense might use different parameter name - verify in Swagger docs
// For now, we'll skip it as pfSense may return all fields by default
// query.Set("fields", strings.Join(o.PropList, ","))
}
path.RawQuery = query.Encode()
return path.String()
}
// endregion models
// region API-client
type PfsenseApiClient struct {
coreCfg *config.Config
cfg *config.BackendPfsense
client *http.Client
log *slog.Logger
}
func NewPfsenseApiClient(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseApiClient, error) {
c := &PfsenseApiClient{
coreCfg: coreCfg,
cfg: cfg,
}
err := c.setup()
if err != nil {
return nil, err
}
c.debugLog("pfSense api client created", "api_url", cfg.ApiUrl)
return c, nil
}
func (p *PfsenseApiClient) setup() error {
p.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: !p.cfg.ApiVerifyTls,
},
},
Timeout: p.cfg.GetApiTimeout(),
}
if p.cfg.Debug {
p.log = slog.New(internal.GetLoggingHandler("debug",
p.coreCfg.Advanced.LogPretty,
p.coreCfg.Advanced.LogJson).
WithAttrs([]slog.Attr{
{
Key: "pfsense-bid", Value: slog.StringValue(p.cfg.Id),
},
}))
}
return nil
}
func (p *PfsenseApiClient) debugLog(msg string, args ...any) {
if p.log != nil {
p.log.Debug("[PFS-API] "+msg, args...)
}
}
func (p *PfsenseApiClient) getFullPath(command string) string {
path, err := url.JoinPath(p.cfg.ApiUrl, command)
if err != nil {
return ""
}
return path
}
func (p *PfsenseApiClient) prepareGetRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if p.cfg.ApiKey != "" {
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
// Uses X-API-Key header for API key authentication
req.Header.Set("X-API-Key", p.cfg.ApiKey)
}
return req, nil
}
func (p *PfsenseApiClient) prepareDeleteRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if p.cfg.ApiKey != "" {
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
// Uses X-API-Key header for API key authentication
req.Header.Set("X-API-Key", p.cfg.ApiKey)
}
return req, nil
}
func (p *PfsenseApiClient) preparePayloadRequest(
ctx context.Context,
method string,
fullUrl string,
payload GenericJsonObject,
) (*http.Request, error) {
// marshal the payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if p.cfg.ApiKey != "" {
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
// Uses X-API-Key header for API key authentication
req.Header.Set("X-API-Key", p.cfg.ApiKey)
}
return req, nil
}
func errToPfsenseApiResponse[T any](code int, message string, err error) PfsenseApiResponse[T] {
return PfsenseApiResponse[T]{
Status: PfsenseApiStatusError,
Code: code,
Error: &PfsenseApiError{
Code: code,
Message: message,
Details: err.Error(),
},
}
}
func parsePfsenseHttpResponse[T any](resp *http.Response, err error) PfsenseApiResponse[T] {
if err != nil {
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeRequestFailed, "failed to execute request", err)
}
// pfSense REST API wraps responses in {code, status, data} or {code, status, error} structure
var wrapper struct {
Code int `json:"code"`
Status string `json:"status"`
Data T `json:"data,omitempty"`
Error *struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
} `json:"error,omitempty"`
}
// Read the entire body first
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed, "failed to read response body", err)
}
// Close the body after reading
defer func() {
if err := resp.Body.Close(); err != nil {
slog.Error("failed to close response body", "error", err)
}
}()
if len(bodyBytes) == 0 {
// Empty response for DELETE operations
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return PfsenseApiResponse[T]{Status: PfsenseApiStatusOk, Code: resp.StatusCode}
}
return errToPfsenseApiResponse[T](resp.StatusCode, "empty error response", fmt.Errorf("HTTP %d", resp.StatusCode))
}
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
// Log the actual response for debugging when JSON parsing fails
contentType := resp.Header.Get("Content-Type")
bodyPreview := string(bodyBytes)
if len(bodyPreview) > 500 {
bodyPreview = bodyPreview[:500] + "..."
}
slog.Error("failed to decode pfSense API response",
"status_code", resp.StatusCode,
"content_type", contentType,
"url", resp.Request.URL.String(),
"method", resp.Request.Method,
"body_preview", bodyPreview,
"error", err)
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed,
fmt.Sprintf("failed to decode response (status %d, content-type: %s): %v", resp.StatusCode, contentType, err), err)
}
// Check if response indicates success
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// Map pfSense status to our status
status := PfsenseApiStatusOk
if wrapper.Status != "ok" && wrapper.Status != "success" {
status = PfsenseApiStatusError
}
// Handle EmptyResponse type
if _, ok := any(wrapper.Data).(EmptyResponse); ok {
return PfsenseApiResponse[T]{Status: status, Code: wrapper.Code}
}
return PfsenseApiResponse[T]{Status: status, Code: wrapper.Code, Data: wrapper.Data}
}
// Handle error response
if wrapper.Error != nil {
return PfsenseApiResponse[T]{
Status: PfsenseApiStatusError,
Code: wrapper.Code,
Error: &PfsenseApiError{
Code: wrapper.Error.Code,
Message: wrapper.Error.Message,
Details: wrapper.Error.Detail,
},
}
}
// Fallback error response
return errToPfsenseApiResponse[T](wrapper.Code, "unknown error", fmt.Errorf("HTTP %d: %s", wrapper.Code, wrapper.Status))
}
func (p *PfsenseApiClient) Query(
ctx context.Context,
command string,
opts *PfsenseRequestOptions,
) PfsenseApiResponse[[]GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := opts.GetPath(p.getFullPath(command))
req, err := p.prepareGetRequest(apiCtx, fullUrl)
if err != nil {
return errToPfsenseApiResponse[[]GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API query", "url", fullUrl)
response := parsePfsenseHttpResponse[[]GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API query result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Get(
ctx context.Context,
command string,
opts *PfsenseRequestOptions,
) PfsenseApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := opts.GetPath(p.getFullPath(command))
req, err := p.prepareGetRequest(apiCtx, fullUrl)
if err != nil {
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API get", "url", fullUrl)
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API get result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Create(
ctx context.Context,
command string,
payload GenericJsonObject,
) PfsenseApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := p.getFullPath(command)
req, err := p.preparePayloadRequest(apiCtx, http.MethodPost, fullUrl, payload)
if err != nil {
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API post", "url", fullUrl)
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API post result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Update(
ctx context.Context,
command string,
payload GenericJsonObject,
) PfsenseApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := p.getFullPath(command)
req, err := p.preparePayloadRequest(apiCtx, http.MethodPatch, fullUrl, payload)
if err != nil {
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API patch", "url", fullUrl)
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API patch result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Delete(
ctx context.Context,
command string,
) PfsenseApiResponse[EmptyResponse] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := p.getFullPath(command)
req, err := p.prepareDeleteRequest(apiCtx, fullUrl)
if err != nil {
return errToPfsenseApiResponse[EmptyResponse](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API delete", "url", fullUrl)
response := parsePfsenseHttpResponse[EmptyResponse](p.client.Do(req))
p.debugLog("retrieved API delete result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
// endregion API-client

View File

@@ -81,6 +81,7 @@ nav:
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
- Configuration:
- Overview: documentation/configuration/overview.md
- Mail templates: documentation/configuration/mail-templates.md
- Examples: documentation/configuration/examples.md
- Usage:
- General: documentation/usage/general.md
@@ -88,6 +89,7 @@ nav:
- LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md
- Mail Templates: documentation/usage/mail-templates.md
- REST API: documentation/rest-api/api-doc.md
- Upgrade: documentation/upgrade/v1.md
- Monitoring: documentation/monitoring/prometheus.md