mirror of
https://github.com/h44z/wg-portal.git
synced 2025-12-14 10:36:18 +00:00
Compare commits
1 Commits
master
...
enhance_pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4104fcc1d6 |
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: h44z # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
12
.github/workflows/chart.yml
vendored
12
.github/workflows/chart.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v5
|
||||
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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
- uses: helm/chart-testing-action@v2
|
||||
|
||||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config ct.yaml
|
||||
|
||||
- uses: nolar/setup-k3d-k3s@293b8e5822a20bc0d5bcdd4826f1a665e72aba96 # v1.0.9
|
||||
- uses: nolar/setup-k3d-k3s@v1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
22
.github/workflows/docker-publish.yml
vendored
22
.github/workflows/docker-publish.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v3
|
||||
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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v3
|
||||
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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@v5
|
||||
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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v6
|
||||
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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries
|
||||
path: binaries/wg-portal_linux*
|
||||
@@ -110,12 +110,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download binaries
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: binaries
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: 'wg-portal_linux*'
|
||||
generate_release_notes: true
|
||||
|
||||
6
.github/workflows/pages.yml
vendored
6
.github/workflows/pages.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
- uses: actions/setup-python@v5
|
||||
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"
|
||||
@@ -20,7 +20,7 @@ RUN npm run build
|
||||
######
|
||||
# Build backend
|
||||
######
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder
|
||||
# Set the working directory
|
||||
WORKDIR /build
|
||||
# Download dependencies
|
||||
@@ -50,9 +50,9 @@ COPY --from=builder /build/dist/wg-portal /
|
||||
######
|
||||
# Final image
|
||||
######
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.22
|
||||
# Install OS-level dependencies
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
|
||||
# Setup timezone
|
||||
ENV TZ=UTC
|
||||
# Copy binaries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020-2025 Christoph Haas
|
||||
Copyright (c) 2020-2023 Christoph Haas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
|
||||
15
README.md
15
README.md
@@ -21,7 +21,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
||||
## Features
|
||||
|
||||
* Self-hosted - the whole application is a single binary
|
||||
* Responsive multi-language web UI with dark-mode written in Vue.js
|
||||
* Responsive multi-language web UI written in Vue.js
|
||||
* Automatically selects IP from the network pool assigned to the client
|
||||
* QR-Code for convenient mobile client configuration
|
||||
* Sends email to the client with QR-code and client config
|
||||
@@ -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, MikroTik, or pfSense)
|
||||
* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA])
|
||||
* Peer Expiry Feature
|
||||
* Handles route and DNS settings like wg-quick does
|
||||
* Exposes Prometheus metrics for monitoring and alerting
|
||||
@@ -62,17 +62,6 @@ For the complete documentation visit [wgportal.org](https://wgportal.org).
|
||||
|
||||
* MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT>
|
||||
|
||||
## Contributors and Sponsors
|
||||
|
||||
Thanks so much for all your contributions! They’re truly appreciated and help keep WireGuard Portal moving ahead.
|
||||
|
||||
<a href="https://github.com/h44z/wg-portal/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=h44z/wg-portal" />
|
||||
</a>
|
||||
|
||||
Want to support the project? You can buy me a coffee or join as a contributor - every bit of support helps!
|
||||
[Become a sponsor!](https://github.com/sponsors/h44z)
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).
|
||||
|
||||
@@ -7,7 +7,7 @@ If you believe you've found a security issue in one of the supported versions of
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| v2.x | :white_check_mark: |
|
||||
| v1.x | :x: |
|
||||
| v1.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ func main() {
|
||||
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wgQuick := adapters.NewWgQuickRepo()
|
||||
|
||||
mailer := adapters.NewSmtpMailRepo(cfg.Mail)
|
||||
|
||||
metricsServer := adapters.NewMetricsServer(cfg)
|
||||
@@ -91,7 +93,7 @@ func main() {
|
||||
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database)
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
||||
internal.AssertNoError(err)
|
||||
wireGuardManager.StartBackgroundJobs(ctx)
|
||||
|
||||
@@ -105,7 +107,7 @@ func main() {
|
||||
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard)
|
||||
routeManager, err := route.NewRouteManager(cfg, eventBus, database)
|
||||
internal.AssertNoError(err)
|
||||
routeManager.StartBackgroundJobs(ctx)
|
||||
|
||||
|
||||
@@ -12,18 +12,6 @@ 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: ""
|
||||
@@ -105,16 +93,4 @@ auth:
|
||||
admin_value_regex: ^true$
|
||||
admin_group_regex: ^admin-group-name$
|
||||
registration_enabled: 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
|
||||
log_user_info: true
|
||||
@@ -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-0"
|
||||
kubeVersion: ">=1.19.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.2
|
||||
version: 0.7.1
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# wg-portal
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
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-0`
|
||||
Kubernetes: `>=1.19.0`
|
||||
|
||||
## Installing the Chart
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 131 KiB |
@@ -11,27 +11,6 @@ core:
|
||||
create_default_peer: true
|
||||
self_provisioning_allowed: true
|
||||
|
||||
backend:
|
||||
# default backend decides where new interfaces are created
|
||||
default: mikrotik
|
||||
|
||||
# A prefix for resolvconf. Usually it is "tun.". If you are using systemd, the prefix should be empty.
|
||||
local_resolvconf_prefix: "tun."
|
||||
|
||||
mikrotik:
|
||||
- id: mikrotik # unique id, not "local"
|
||||
display_name: RouterOS RB5009 # optional nice name
|
||||
api_url: https://10.10.10.10/rest
|
||||
api_user: wgportal
|
||||
api_password: a-super-secret-password
|
||||
api_verify_tls: false # set to false only if using self-signed during testing
|
||||
api_timeout: 30s # maximum request duration
|
||||
concurrency: 5 # limit parallel REST calls to device
|
||||
debug: false # verbose logging for this backend
|
||||
ignored_interfaces: # ignore these interfaces during import
|
||||
- wgTest1
|
||||
- wgTest2
|
||||
|
||||
web:
|
||||
site_title: My WireGuard Server
|
||||
site_company_name: My Company
|
||||
@@ -67,7 +46,8 @@ auth:
|
||||
auth:
|
||||
ldap:
|
||||
# a sample LDAP provider with user sync enabled
|
||||
- provider_name: ldap
|
||||
- id: ldap
|
||||
provider_name: Active Directory
|
||||
url: ldap://srv-ad1.company.local:389
|
||||
bind_user: ldap_wireguard@company.local
|
||||
bind_pass: super-s3cr3t-ldap
|
||||
@@ -98,7 +78,8 @@ 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.
|
||||
- provider_name: azure
|
||||
- id: 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"
|
||||
@@ -111,7 +92,8 @@ auth:
|
||||
- email
|
||||
|
||||
# a sample provider where users with the attribute `wg_admin` set to `true` are considered as admins
|
||||
- provider_name: google
|
||||
- id: oidc-with-admin-attribute
|
||||
provider_name: google
|
||||
display_name: Login with</br>Google
|
||||
base_url: https://accounts.google.com
|
||||
client_id: the-client-id-1234.apps.googleusercontent.com
|
||||
@@ -133,7 +115,8 @@ auth:
|
||||
log_user_info: true
|
||||
|
||||
# a sample provider where users in the group `the-admin-group` are considered as admins
|
||||
- provider_name: google2
|
||||
- id: oidc-with-admin-group
|
||||
provider_name: google2
|
||||
display_name: Login with</br>Google2
|
||||
base_url: https://accounts.google.com
|
||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||
@@ -164,7 +147,8 @@ auth:
|
||||
oauth:
|
||||
# a sample provider where users with the attribute `this-attribute-must-be-true` set to `true` or `True`
|
||||
# are considered as admins
|
||||
- provider_name: google3
|
||||
- id: google_plain_oauth-with-admin-attribute
|
||||
provider_name: google3
|
||||
display_name: Login with</br>Google3
|
||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||
client_secret: A_CLIENT_SECRET
|
||||
@@ -186,7 +170,8 @@ 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
|
||||
- provider_name: google4
|
||||
- id: google_plain_oauth_with_groups
|
||||
provider_name: google4
|
||||
display_name: Login with</br>Google4
|
||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||
client_secret: A_CLIENT_SECRET
|
||||
@@ -210,5 +195,3 @@ auth:
|
||||
registration_enabled: true
|
||||
log_user_info: true
|
||||
```
|
||||
|
||||
For more information, check out the usage documentation (e.g. [General Configuration](../usage/general.md) or [Backends Configuration](../usage/backends.md)).
|
||||
|
||||
@@ -16,7 +16,6 @@ core:
|
||||
admin_user: admin@wgportal.local
|
||||
admin_password: wgportal-default
|
||||
admin_api_token: ""
|
||||
disable_admin_user: false
|
||||
editable_keys: true
|
||||
create_default_peer: false
|
||||
create_default_peer_on_creation: false
|
||||
@@ -28,7 +27,6 @@ core:
|
||||
|
||||
backend:
|
||||
default: local
|
||||
local_resolvconf_prefix: tun.
|
||||
|
||||
advanced:
|
||||
log_level: info
|
||||
@@ -73,8 +71,6 @@ mail:
|
||||
auth_type: plain
|
||||
from: Wireguard Portal <noreply@wireguard.local>
|
||||
link_only: false
|
||||
allow_peer_email: false
|
||||
templates_path: ""
|
||||
|
||||
auth:
|
||||
oidc: []
|
||||
@@ -97,7 +93,6 @@ web:
|
||||
expose_host_info: false
|
||||
cert_file: ""
|
||||
key_File: ""
|
||||
frontend_filepath: ""
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
@@ -129,63 +124,47 @@ 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.
|
||||
|
||||
---
|
||||
@@ -200,19 +179,6 @@ The current MikroTik backend is in **BETA** and may not support all features.
|
||||
- **Description:** The default backend to use for managing WireGuard interfaces.
|
||||
Valid options are: `local`, or other backend id's configured in the `mikrotik` section.
|
||||
|
||||
### `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.
|
||||
|
||||
### Mikrotik
|
||||
|
||||
The `mikrotik` array contains a list of MikroTik backend definitions. Each entry describes how to connect to a MikroTik RouterOS instance that hosts WireGuard interfaces.
|
||||
@@ -254,11 +220,6 @@ Below are the properties for each entry inside `backend.mikrotik`:
|
||||
- **Default:** `5`
|
||||
- **Description:** Maximum number of concurrent API requests the backend will issue when enumerating interfaces and their details. If `0` or negative, a sane default of `5` is used.
|
||||
|
||||
#### `ignored_interfaces`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A list of interface names to exclude during interface enumeration.
|
||||
This is useful if you want to prevent specific interfaces from being imported from the MikroTik device.
|
||||
|
||||
#### `debug`
|
||||
- **Default:** `false`
|
||||
- **Description:** Enable verbose debug logging for the MikroTik backend.
|
||||
@@ -273,67 +234,54 @@ 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.
|
||||
|
||||
---
|
||||
@@ -347,22 +295,18 @@ 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
|
||||
@@ -371,7 +315,6 @@ 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 it’s next modified.
|
||||
@@ -384,114 +327,82 @@ 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`).
|
||||
|
||||
---
|
||||
|
||||
## Mail
|
||||
|
||||
Options for configuring email notifications or sending peer configurations via email.
|
||||
By default, emails will only be sent to peers that have a valid user record linked.
|
||||
To send emails to all peers that have a valid email-address as user-identifier, set `allow_peer_email` to `true`.
|
||||
Options for configuring email notifications or sending peer configurations via email.
|
||||
|
||||
### `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`
|
||||
- **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.`
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
@@ -503,14 +414,12 @@ 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).
|
||||
@@ -573,18 +482,13 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
|
||||
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
|
||||
|
||||
#### `log_sensitive_info`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
|
||||
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
|
||||
|
||||
---
|
||||
|
||||
### OAuth
|
||||
@@ -651,18 +555,13 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
|
||||
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, new users are created automatically on successful login.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, logs user info at the trace level upon login.
|
||||
|
||||
#### `log_sensitive_info`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
|
||||
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
|
||||
|
||||
---
|
||||
|
||||
### LDAP
|
||||
@@ -679,11 +578,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
|
||||
|
||||
#### `start_tls`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
|
||||
|
||||
#### `cert_validation`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, validate the LDAP server’s TLS certificate.
|
||||
|
||||
#### `tls_certificate_path`
|
||||
@@ -752,24 +651,20 @@ 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`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
|
||||
|
||||
#### `auto_re_enable`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** `false`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** If `true`, logs LDAP user data at the trace level upon login.
|
||||
|
||||
---
|
||||
@@ -780,7 +675,6 @@ 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.
|
||||
@@ -793,69 +687,50 @@ 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
|
||||
@@ -865,15 +740,12 @@ 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`
|
||||
- **Environment Variable:** `WG_PORTAL_WEBHOOK_TIMEOUT`
|
||||
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
||||
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
||||
@@ -512,8 +512,6 @@ definitions:
|
||||
description: The source of the user. This field is optional.
|
||||
enum:
|
||||
- db
|
||||
- ldap
|
||||
- oauth
|
||||
example: db
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -8,7 +8,6 @@ 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).
|
||||
@@ -55,37 +54,4 @@ 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)
|
||||
|
||||
## 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.
|
||||
- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks)
|
||||
@@ -1,49 +0,0 @@
|
||||
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.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
img-comparison-slider {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
img-comparison-slider [slot='second'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
img-comparison-slider.rendered {
|
||||
visibility: inherit;
|
||||
}
|
||||
|
||||
img-comparison-slider.rendered [slot='second'] {
|
||||
display: unset;
|
||||
}
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.tx-hero__image {
|
||||
max-width: 1000px;
|
||||
min-width: 0;
|
||||
min-width: 600px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
@@ -218,7 +218,7 @@
|
||||
|
||||
.secondary-section .g .section .component-wrapper .responsive-grid .card {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
background-color: #fff none repeat scroll 0% 0%;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -300,59 +300,6 @@
|
||||
background: var(--md-accent-fg-color--transparent);
|
||||
}
|
||||
|
||||
.before,
|
||||
.after {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.after figcaption {
|
||||
background: #fff;
|
||||
font-weight: bold;
|
||||
border: 1px solid #c0c0c0;
|
||||
color: #000000;
|
||||
opacity: 0.9;
|
||||
padding: 9px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
transform: translateY(-100%);
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.before figcaption {
|
||||
background: #000;
|
||||
font-weight: bold;
|
||||
border: 1px solid #c0c0c0;
|
||||
color: #ffffff;
|
||||
opacity: 0.9;
|
||||
padding: 9px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
transform: translateY(-100%);
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.before figcaption {
|
||||
left: 0px;
|
||||
}
|
||||
.after figcaption {
|
||||
right: 0px;
|
||||
}
|
||||
.custom-animated-handle {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.slider-with-animated-handle:hover .custom-animated-handle {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.md-typeset img-comparison-slider figure {
|
||||
margin: initial;
|
||||
}
|
||||
|
||||
.first-overlay {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<!-- Hero for landing page -->
|
||||
@@ -363,6 +310,7 @@
|
||||
<h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1>
|
||||
<p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage
|
||||
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
|
||||
</p>
|
||||
<a
|
||||
href="documentation/overview/"
|
||||
title="Get Started"
|
||||
@@ -378,34 +326,11 @@
|
||||
|
||||
<div class="md-container">
|
||||
<div class="tx-hero__image">
|
||||
<div>
|
||||
<img-comparison-slider hover="hover">
|
||||
<figure slot="first" class="before">
|
||||
<img src="{{config.site_url}}/assets/images/wgportal_light.png" alt="Light Mode"/>
|
||||
<figcaption>Light Mode</figcaption>
|
||||
</figure>
|
||||
<figure slot="second" class="after">
|
||||
<img src="{{config.site_url}}/assets/images/wgportal_dark.png" alt="Dark Mode"/>
|
||||
<figcaption>Dark Mode</figcaption>
|
||||
</figure>
|
||||
<svg slot="handle" class="custom-animated-handle" xmlns="http://www.w3.org/2000/svg" width="100" viewBox="-8 -3 16 6">
|
||||
<!-- Left arrow (dark) -->
|
||||
<path d="M -5 -2 L -7 0 L -5 2 M -5 -2 L -5 2"
|
||||
stroke="#1a1a1a"
|
||||
fill="#1a1a1a"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke">
|
||||
</path>
|
||||
<!-- Right arrow (white) -->
|
||||
<path d="M 5 -2 L 7 0 L 5 2 M 5 -2 L 5 2"
|
||||
stroke="#fff"
|
||||
fill="#fff"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke">
|
||||
</path>
|
||||
</svg>
|
||||
</img-comparison-slider>
|
||||
</div>
|
||||
<img
|
||||
src="{{config.site_url}}/assets/images/screenshot.png"
|
||||
alt=""
|
||||
draggable="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
1700
frontend/package-lock.json
generated
1700
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,29 +8,28 @@
|
||||
"preview": "vite preview --port 5050"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/nunito-sans": "^5.2.7",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@kyvg/vue3-notification": "^3.4.2",
|
||||
"@fontsource/nunito-sans": "^5.2.5",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@kyvg/vue3-notification": "^3.4.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@vojtechlanka/vue-tags-input": "^3.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",
|
||||
"bootstrap": "^5.3.7",
|
||||
"bootswatch": "^5.3.7",
|
||||
"flag-icons": "^7.3.2",
|
||||
"ip-address": "^10.0.1",
|
||||
"is-cidr": "^5.1.1",
|
||||
"is-ip": "^5.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia": "^3.0.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.3",
|
||||
"vue-prism-component": "github:h44z/vue-prism-component",
|
||||
"vue-router": "^4.6.3"
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"vite": "^7.2.7"
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"vite": "6.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
|
||||
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
||||
import { authStore } from "./stores/auth";
|
||||
import { securityStore } from "./stores/security";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
@@ -11,13 +11,12 @@ const auth = authStore()
|
||||
const sec = securityStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const currentTheme = ref("auto")
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("Starting WireGuard Portal frontend...");
|
||||
|
||||
// restore theme from localStorage
|
||||
switchTheme(getTheme());
|
||||
const theme = localStorage.getItem('wgTheme') || 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
|
||||
await sec.LoadSecurityProperties();
|
||||
await auth.LoadProviders();
|
||||
@@ -45,22 +44,10 @@ const switchLanguage = function (lang) {
|
||||
}
|
||||
}
|
||||
|
||||
const getTheme = function () {
|
||||
return localStorage.getItem('wgTheme') || 'auto';
|
||||
}
|
||||
|
||||
const switchTheme = function (theme) {
|
||||
let bsTheme = theme;
|
||||
if (theme === 'auto') {
|
||||
bsTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
currentTheme.value = theme;
|
||||
|
||||
if (document.documentElement.getAttribute('data-bs-theme') !== bsTheme) {
|
||||
console.log("Switching theme to " + theme + " (" + bsTheme + ")");
|
||||
if (document.documentElement.getAttribute('data-bs-theme') !== theme) {
|
||||
localStorage.setItem('wgTheme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', bsTheme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +63,6 @@ const languageFlag = computed(() => {
|
||||
uk: "ua",
|
||||
zh: "cn",
|
||||
ko: "kr",
|
||||
es: "es",
|
||||
|
||||
};
|
||||
return "fi-" + (langMap[lang] || lang);
|
||||
@@ -133,9 +119,6 @@ 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">
|
||||
@@ -153,25 +136,20 @@ const userDisplayName = computed(() => {
|
||||
<div v-if="!auth.IsAuthenticated" class="nav-item">
|
||||
<RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink>
|
||||
</div>
|
||||
<div class="nav-item dropdown" :key="currentTheme">
|
||||
<div class="nav-item dropdown" data-bs-theme="light">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme">
|
||||
<i class="fa-solid fa-circle-half-stroke"></i>
|
||||
<span class="d-lg-none ms-2">Toggle theme</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('auto')" aria-pressed="false">
|
||||
<i class="fa-solid fa-circle-half-stroke"></i><span class="ms-2">System</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='auto'}"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false">
|
||||
<i class="fa-solid fa-sun"></i><span class="ms-2">Light</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='light'}"></i>
|
||||
<i class="fa-solid fa-sun"></i><span class="ms-2">Light</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true">
|
||||
<i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='dark'}"></i>
|
||||
<i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -204,7 +182,6 @@ const userDisplayName = computed(() => {
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('es')"><span class="fi fi-es"></span> Español</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,8 +219,4 @@ const userDisplayName = computed(() => {
|
||||
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
|
||||
color: var(--bs-badge-color)!important;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .navbar-dark, .navbar {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,8 +104,4 @@ a.disabled {
|
||||
|
||||
.vue-tags-input .ti-deletion-mark:after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -316,16 +316,6 @@ async function del() {
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
|
||||
|
||||
// reload all interfaces and peers
|
||||
await interfaces.LoadInterfaces()
|
||||
if (interfaces.Count > 0 && interfaces.GetSelected !== undefined) {
|
||||
const selectedInterface = interfaces.GetSelected
|
||||
await peers.LoadPeers(selectedInterface.Identifier)
|
||||
await peers.LoadStats(selectedInterface.Identifier)
|
||||
} else {
|
||||
await peers.Reset() // reset peers if no interfaces are available
|
||||
}
|
||||
close()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -444,11 +434,6 @@ async function del() {
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
|
||||
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
|
||||
</div>
|
||||
<div class="form-group col-md-6" v-if="formData.Backend!=='local'">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
|
||||
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
|
||||
<small id="routingTableHelp" class="form-text text-muted">{{ $t('modals.interface-edit.routing-table.description') }}</small>
|
||||
</div>
|
||||
<div class="form-group col-md-6" v-else>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,7 +447,7 @@ async function del() {
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset v-if="formData.Backend==='local'">
|
||||
<fieldset>
|
||||
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
|
||||
@@ -487,7 +472,7 @@ async function del() {
|
||||
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
|
||||
</div>
|
||||
<div class="form-check form-switch" v-if="formData.Backend==='local'">
|
||||
<div class="form-check form-switch">
|
||||
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>
|
||||
</div>
|
||||
|
||||
@@ -358,7 +358,7 @@ async function del() {
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
|
||||
v-model="formData.Endpoint.Value">
|
||||
</div>
|
||||
<div class="form-group" v-if="selectedInterface.Mode !== 'client'">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
|
||||
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
|
||||
:tags="formData.Addresses.map(str => ({ text: str }))"
|
||||
|
||||
@@ -130,7 +130,7 @@ function ConfigQrUrl() {
|
||||
<template>
|
||||
<Modal :title="title" :visible="visible" @close="close">
|
||||
<template #default>
|
||||
<div class="d-flex justify-content-end align-items-center mb-1" v-if="selectedInterface.Mode !== 'client'">
|
||||
<div class="d-flex justify-content-end align-items-center mb-1">
|
||||
<span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span>
|
||||
<div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style">
|
||||
<input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle">
|
||||
@@ -151,28 +151,20 @@ function ConfigQrUrl() {
|
||||
data-bs-parent="#peerInformation" style="">
|
||||
<div class="accordion-body">
|
||||
<div class="row">
|
||||
<div :class="{ 'col-md-8': selectedInterface.Mode !== 'client', 'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8">
|
||||
<div class="col-md-8">
|
||||
<ul>
|
||||
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li>
|
||||
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint-key') }}</strong>: {{ selectedPeer.PublicKey }}</li>
|
||||
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint') }}</strong>: {{ selectedPeer.Endpoint.Value }}</li>
|
||||
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.ip') }}</strong>: <span v-for="ip in selectedPeer.Addresses" :key="ip"
|
||||
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
|
||||
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip"
|
||||
class="badge rounded-pill bg-light">{{ ip }}</span></li>
|
||||
<li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip"
|
||||
class="badge rounded-pill bg-light">{{ ip }}</span></li>
|
||||
<li v-if="selectedInterface.Mode !== 'server' && selectedPeer.AllowedIPs.Value"><strong>{{ $t('modals.peer-view.allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.AllowedIPs.Value" :key="ip"
|
||||
class="badge rounded-pill bg-light">{{ ip }}</span></li>
|
||||
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.keepalive') }}</strong>: {{ selectedPeer.PersistentKeepalive.Value }}</li>
|
||||
<li v-if="selectedPeer.UserDisplayName"><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserDisplayName }} ({{ selectedPeer.UserIdentifier }})</li>
|
||||
<li v-else><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserIdentifier }}</li>
|
||||
<li v-if="selectedPeer.Notes"><strong>{{ $t('modals.peer-view.notes') }}</strong>: {{ selectedPeer.Notes }}</li>
|
||||
<li v-if="selectedPeer.ExpiresAt"><strong>{{ $t('modals.peer-view.expiry-status') }}</strong>: {{
|
||||
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
|
||||
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
|
||||
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{
|
||||
selectedPeer.ExpiresAt }}</li>
|
||||
<li v-if="selectedPeer.Disabled"><strong>{{ $t('modals.peer-view.disabled-status') }}</strong>: {{
|
||||
<li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{
|
||||
selectedPeer.DisabledReason }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4" v-if="selectedInterface.Mode !== 'client'">
|
||||
<div class="col-md-4">
|
||||
<img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code">
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +199,7 @@ function ConfigQrUrl() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedInterface.Mode !== 'client'" class="accordion-item">
|
||||
<div v-if="selectedInterface.Mode === 'server'" class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingConfig">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
|
||||
@@ -225,9 +217,9 @@ function ConfigQrUrl() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="selectedInterface.Mode !== 'client'" @click.prevent="download" type="button" class="btn btn-primary me-1">{{
|
||||
<button @click.prevent="download" type="button" class="btn btn-primary me-1">{{
|
||||
$t('modals.peer-view.button-download') }}</button>
|
||||
<button v-if="selectedInterface.Mode !== 'client'" @click.prevent="email" type="button" class="btn btn-primary me-1">{{
|
||||
<button @click.prevent="email" type="button" class="btn btn-primary me-1">{{
|
||||
$t('modals.peer-view.button-email') }}</button>
|
||||
</div>
|
||||
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
|
||||
|
||||
@@ -53,7 +53,6 @@ export function freshPeer() {
|
||||
Identifier: "",
|
||||
DisplayName: "",
|
||||
UserIdentifier: "",
|
||||
UserDisplayName: "",
|
||||
InterfaceIdentifier: "",
|
||||
Disabled: false,
|
||||
ExpiresAt: null,
|
||||
|
||||
@@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
|
||||
// Check if it's an IPv4 address
|
||||
if (ip.includes(".")) {
|
||||
const addr = new Address4(ip)
|
||||
return addr.bigInt()
|
||||
return addr.bigInteger()
|
||||
}
|
||||
|
||||
// Otherwise, assume it's an IPv6 address
|
||||
const addr = new Address6(ip)
|
||||
return addr.bigInt()
|
||||
return addr.bigInteger()
|
||||
}
|
||||
|
||||
export function humanFileSize(size) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import ru from './translations/ru.json';
|
||||
import uk from './translations/uk.json';
|
||||
import vi from './translations/vi.json';
|
||||
import zh from './translations/zh.json';
|
||||
import es from './translations/es.json';
|
||||
|
||||
import {createI18n} from "vue-i18n";
|
||||
|
||||
@@ -33,7 +32,6 @@ const i18n = createI18n({
|
||||
"uk": uk,
|
||||
"vi": vi,
|
||||
"zh": zh,
|
||||
"es": es,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
"audit": "Event Protokoll",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"keygen": "Schlüsselgenerator",
|
||||
"calculator": "IP-Rechner"
|
||||
"keygen": "Schlüsselgenerator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -118,7 +117,6 @@
|
||||
"dns": "DNS-Server",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Standard Keepalive-Intervall",
|
||||
"default-dns": "Standard DNS-Server",
|
||||
"button-show-config": "Konfiguration anzeigen",
|
||||
"button-download-config": "Konfiguration herunterladen",
|
||||
"button-store-config": "Konfiguration für wg-quick speichern",
|
||||
@@ -222,16 +220,6 @@
|
||||
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
|
||||
"button-register-title": "Passkey registrieren",
|
||||
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
|
||||
},
|
||||
"password": {
|
||||
"headline": "Passwort-Einstellungen",
|
||||
"abstract": "Hier können Sie Ihr Passwort ändern.",
|
||||
"current-label": "Aktuelles Passwort",
|
||||
"new-label": "Neues Passwort",
|
||||
"new-confirm-label": "Neues Passwort bestätigen",
|
||||
"change-button-text": "Passwort ändern",
|
||||
"invalid-confirm-label": "Passwörter stimmen nicht überein",
|
||||
"weak-label": "Passwort ist zu schwach"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
@@ -270,26 +258,6 @@
|
||||
"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:",
|
||||
@@ -493,8 +461,6 @@
|
||||
"section-config": "Konfiguration",
|
||||
"identifier": "Kennung",
|
||||
"ip": "IP-Adressen",
|
||||
"allowed-ip": "Erlaubte IP-Adressen",
|
||||
"extra-allowed-ip": "Serverseitig erlaubte IP-Adressen",
|
||||
"user": "Zugeordneter Benutzer",
|
||||
"notes": "Notizen",
|
||||
"expiry-status": "Läuft ab am",
|
||||
@@ -507,8 +473,6 @@
|
||||
"handshake": "Letzter Handshake",
|
||||
"connected-since": "Verbunden seit",
|
||||
"endpoint": "Endpunkt",
|
||||
"endpoint-key": "Öffentlicher Endpunkt-Schlüssel",
|
||||
"keepalive": "Persistentes Keepalive",
|
||||
"button-download": "Konfiguration herunterladen",
|
||||
"button-email": "Konfiguration per E-Mail senden",
|
||||
"style-label": "Konfigurationsformat"
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
"audit": "Audit Log",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"keygen": "Key Generator",
|
||||
"calculator": "IP Calculator"
|
||||
"keygen": "Key Generator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -118,7 +117,6 @@
|
||||
"dns": "DNS Servers",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Default Keepalive Interval",
|
||||
"default-dns": "Default DNS Servers",
|
||||
"button-show-config": "Show configuration",
|
||||
"button-download-config": "Download configuration",
|
||||
"button-store-config": "Store configuration for wg-quick",
|
||||
@@ -222,16 +220,6 @@
|
||||
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.",
|
||||
"button-register-title": "Register Passkey",
|
||||
"button-register-text": "Register a new Passkey to secure your account."
|
||||
},
|
||||
"password": {
|
||||
"headline": "Password Settings",
|
||||
"abstract": "Here you can change your password.",
|
||||
"current-label": "Current Password",
|
||||
"new-label": "New Password",
|
||||
"new-confirm-label": "Confirm New Password",
|
||||
"change-button-text": "Change Password",
|
||||
"invalid-confirm-label": "Passwords do not match",
|
||||
"weak-label": "Password is too weak"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
@@ -270,26 +258,6 @@
|
||||
"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:",
|
||||
@@ -494,8 +462,6 @@
|
||||
"section-config": "Configuration",
|
||||
"identifier": "Identifier",
|
||||
"ip": "IP Addresses",
|
||||
"allowed-ip": "Allowed IP Addresses",
|
||||
"extra-allowed-ip": "Server Side Allowed IP Addresses",
|
||||
"user": "Associated User",
|
||||
"notes": "Notes",
|
||||
"expiry-status": "Expires At",
|
||||
@@ -508,8 +474,6 @@
|
||||
"handshake": "Last Handshake",
|
||||
"connected-since": "Connected since",
|
||||
"endpoint": "Endpoint",
|
||||
"endpoint-key": "Endpoint Public Key",
|
||||
"keepalive": "Persistent Keepalive",
|
||||
"button-download": "Download configuration",
|
||||
"button-email": "Send configuration via E-Mail",
|
||||
"style-label": "Configuration Style"
|
||||
|
||||
@@ -1,635 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"all": "Todos (Lento)"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"button": "Buscar"
|
||||
},
|
||||
"select-all": "Buscar todos",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"cancel": "Cancelar",
|
||||
"close": "Cerrar",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"login": {
|
||||
"headline": "Por favor inicie sesión",
|
||||
"username": {
|
||||
"label": "Usuario",
|
||||
"placeholder": "Por favor ingrese su usuario"
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"placeholder": "Por favor ingrese su contraseña"
|
||||
},
|
||||
"button": "Ingresar",
|
||||
"button-webauthn": "Usar clave de acceso"
|
||||
},
|
||||
"menu": {
|
||||
"calculator": "Calculadora IP",
|
||||
"home": "Inicio",
|
||||
"interfaces": "Interfaces",
|
||||
"users": "Usuarios",
|
||||
"lang": "Cambiar idioma",
|
||||
"profile": "Mi perfil",
|
||||
"settings": "Configuración",
|
||||
"audit": "Registro de auditoría",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"keygen": "Generador de claves"
|
||||
},
|
||||
"home": {
|
||||
"headline": "Portal VPN WireGuard®",
|
||||
"info-headline": "Más información",
|
||||
"abstract": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación. Su objetivo es ser más rápida, simple, ligera y útil que IPsec, a la vez que evita los enormes problemas que supone. Su objetivo es ofrecer un rendimiento considerablemente superior al de OpenVPN.",
|
||||
"installation": {
|
||||
"box-header": "Instalación de WireGuard",
|
||||
"headline": "Instalación",
|
||||
"content": "Las instrucciones de instalación del cliente se pueden encontrar en el sitio web oficial de WireGuard.",
|
||||
"button": "Abrir instrucciones"
|
||||
},
|
||||
"about-wg": {
|
||||
"box-header": "Acerca de WireGuard",
|
||||
"headline": "Acerca de",
|
||||
"content": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación.",
|
||||
"button": "Más"
|
||||
},
|
||||
"about-portal": {
|
||||
"box-header": "Acerca del Portal WireGuard",
|
||||
"headline": "Portal WireGuard",
|
||||
"content": "WireGuard Portal es un portal web simple para la configuración de WireGuard.",
|
||||
"button": "Más"
|
||||
},
|
||||
"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.",
|
||||
"button": "Abrir mi perfil"
|
||||
},
|
||||
"admin": {
|
||||
"headline": "Área de administración",
|
||||
"abstract": "En el área de administración puedes gestionar los peers de WireGuard, la interfaz del servidor, así como los usuarios que tienen acceso al Portal WireGuard.",
|
||||
"content": "",
|
||||
"button-admin": "Abrir administración del servidor",
|
||||
"button-user": "Abrir administración de usuarios"
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"headline": "Administración de interfaces",
|
||||
"headline-peers": "Peers VPN actuales",
|
||||
"headline-endpoints": "Extremos actuales",
|
||||
"no-interface": {
|
||||
"default-selection": "No hay interfaces disponibles",
|
||||
"headline": "No se encontraron interfaces...",
|
||||
"abstract": "Haz clic en el botón + para crear una nueva interfaz WireGuard."
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "No hay peers disponibles",
|
||||
"abstract": "Actualmente no hay peers disponibles para la interfaz WireGuard seleccionada."
|
||||
},
|
||||
"table-heading": {
|
||||
"name": "Nombre",
|
||||
"user": "Usuario",
|
||||
"ip": "IPs",
|
||||
"endpoint": "Endpoint",
|
||||
"status": "Estado"
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Estado de la interfaz para",
|
||||
"backend": "Backend",
|
||||
"unknown-backend": "Desconocido",
|
||||
"wrong-backend": "Backend inválido, usando backend local de WireGuard en su lugar.",
|
||||
"key": "Clave pública",
|
||||
"endpoint": "Endpoint público",
|
||||
"port": "Puerto de escucha",
|
||||
"peers": "Peers habilitados",
|
||||
"total-peers": "Peers totales",
|
||||
"endpoints": "Endpoints habilitados",
|
||||
"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",
|
||||
"button-show-config": "Mostrar configuración",
|
||||
"button-download-config": "Descargar configuración",
|
||||
"button-store-config": "Guardar configuración para wg-quick",
|
||||
"button-edit": "Editar interfaz"
|
||||
},
|
||||
"button-add-interface": "Agregar interfaz",
|
||||
"button-add-peer": "Agregar peer",
|
||||
"button-add-peers": "Agregar múltiples peers",
|
||||
"button-show-peer": "Mostrar peer",
|
||||
"button-edit-peer": "Editar peer",
|
||||
"peer-disabled": "Peer deshabilitado, motivo:",
|
||||
"peer-expiring": "El peer expira en",
|
||||
"peer-connected": "Conectado",
|
||||
"peer-not-connected": "No conectado",
|
||||
"peer-handshake": "Último handshake:"
|
||||
},
|
||||
"users": {
|
||||
"headline": "Administración de usuarios",
|
||||
"table-heading": {
|
||||
"id": "ID",
|
||||
"email": "Correo electrónico",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"source": "Origen",
|
||||
"peers": "Peers",
|
||||
"admin": "Administrador"
|
||||
},
|
||||
"no-user": {
|
||||
"headline": "No hay usuarios disponibles",
|
||||
"abstract": "Actualmente no hay usuarios registrados en el Portal WireGuard."
|
||||
},
|
||||
"button-add-user": "Agregar usuario",
|
||||
"button-show-user": "Mostrar usuario",
|
||||
"button-edit-user": "Editar usuario",
|
||||
"user-disabled": "Usuario deshabilitado, motivo:",
|
||||
"user-locked": "Cuenta bloqueada, motivo:",
|
||||
"admin": "El usuario tiene privilegios de administrador",
|
||||
"no-admin": "El usuario no tiene privilegios de administrador"
|
||||
},
|
||||
"profile": {
|
||||
"headline": "Mis peers VPN",
|
||||
"table-heading": {
|
||||
"name": "Nombre",
|
||||
"ip": "IPs",
|
||||
"stats": "Estado",
|
||||
"interface": "Interfaz del servidor"
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "No hay peers disponibles",
|
||||
"abstract": "Actualmente no hay peers asociados a tu perfil de usuario."
|
||||
},
|
||||
"peer-connected": "Conectado",
|
||||
"button-add-peer": "Agregar peer",
|
||||
"button-show-peer": "Mostrar peer",
|
||||
"button-edit-peer": "Editar peer"
|
||||
},
|
||||
"settings": {
|
||||
"headline": "Configuración",
|
||||
"abstract": "Aquí puedes cambiar tu configuración personal.",
|
||||
"api": {
|
||||
"headline": "Configuración de API",
|
||||
"abstract": "Aquí puedes configurar los ajustes de la API RESTful.",
|
||||
"active-description": "La API está actualmente activa para tu cuenta. Todas las solicitudes están autenticadas con Basic Auth. Usa las siguientes credenciales.",
|
||||
"inactive-description": "La API está actualmente inactiva. Presiona el botón de abajo para activarla.",
|
||||
"user-label": "Usuario de la API:",
|
||||
"user-placeholder": "Usuario de la API",
|
||||
"token-label": "Contraseña de la API:",
|
||||
"token-placeholder": "Token de la API",
|
||||
"token-created-label": "Acceso API concedido en: ",
|
||||
"button-disable-title": "Desactivar API, invalidará el token actual.",
|
||||
"button-disable-text": "Desactivar API",
|
||||
"button-enable-title": "Activar API, generará un nuevo token.",
|
||||
"button-enable-text": "Activar API",
|
||||
"api-link": "Documentación de API"
|
||||
},
|
||||
"webauthn": {
|
||||
"headline": "Configuración de llave de acceso",
|
||||
"abstract": "Las llaves de acceso son una forma moderna de autenticar usuarios sin necesidad de contraseñas. Se almacenan de forma segura en tu navegador y pueden usarse para iniciar sesión en el Portal WireGuard.",
|
||||
"active-description": "Al menos una llave de acceso está activa en tu cuenta.",
|
||||
"inactive-description": "Actualmente no hay llaves de acceso registradas. Presiona el botón de abajo para registrar una.",
|
||||
"table": {
|
||||
"name": "Nombre",
|
||||
"created": "Creada",
|
||||
"actions": ""
|
||||
},
|
||||
"credentials-list": "Llaves de acceso registradas actualmente",
|
||||
"modal-delete": {
|
||||
"headline": "Eliminar llaves de acceso",
|
||||
"abstract": "¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.",
|
||||
"created": "Creada:",
|
||||
"button-delete": "Eliminar",
|
||||
"button-cancel": "Cancelar"
|
||||
},
|
||||
"button-rename-title": "Renombrar",
|
||||
"button-rename-text": "Renombrar la llave de acceso.",
|
||||
"button-save-title": "Guardar",
|
||||
"button-save-text": "Guardar el nuevo nombre de la llave de acceso.",
|
||||
"button-cancel-title": "Cancelar",
|
||||
"button-cancel-text": "Cancelar el renombrado de la llave de acceso.",
|
||||
"button-delete-title": "Eliminar",
|
||||
"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": {
|
||||
"headline": "Registro de Auditoría",
|
||||
"abstract": "Aquí puedes encontrar el registro de auditoría de todas las acciones realizadas en el Portal WireGuard.",
|
||||
"no-entries": {
|
||||
"headline": "No hay entradas en el registro",
|
||||
"abstract": "Actualmente no se han registrado auditorías."
|
||||
},
|
||||
"entries-headline": "Entradas del Registro",
|
||||
"table-heading": {
|
||||
"id": "#",
|
||||
"time": "Hora",
|
||||
"user": "Usuario",
|
||||
"severity": "Severidad",
|
||||
"origin": "Origen",
|
||||
"message": "Mensaje"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "Generador de claves WireGuard",
|
||||
"abstract": "Genera nuevas claves de WireGuard. Las claves se generan en tu navegador local y nunca se envían al servidor.",
|
||||
"headline-keypair": "Nuevo par de claves",
|
||||
"headline-preshared-key": "Nueva clave pre-compartida",
|
||||
"button-generate": "Generar",
|
||||
"private-key": {
|
||||
"label": "Clave privada",
|
||||
"placeholder": "La clave privada"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Clave pública",
|
||||
"placeholder": "La clave pública"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Clave pre-compartida",
|
||||
"placeholder": "La clave pre-compartida"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "Cuenta de Usuario:",
|
||||
"tab-user": "Información",
|
||||
"tab-peers": "Peers",
|
||||
"headline-info": "Información del Usuario:",
|
||||
"headline-notes": "Notas:",
|
||||
"email": "Correo Electrónico",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"phone": "Número de Teléfono",
|
||||
"department": "Departamento",
|
||||
"api-enabled": "Acceso API",
|
||||
"disabled": "Cuenta Deshabilitada",
|
||||
"locked": "Cuenta Bloqueada",
|
||||
"no-peers": "El usuario no tiene peers asociados.",
|
||||
"peers": {
|
||||
"name": "Nombre",
|
||||
"interface": "Interfaz",
|
||||
"ip": "IPs"
|
||||
}
|
||||
},
|
||||
"user-edit": {
|
||||
"headline-edit": "Editar usuario:",
|
||||
"headline-new": "Nuevo usuario",
|
||||
"header-general": "General",
|
||||
"header-personal": "Información del Usuario",
|
||||
"header-notes": "Notas",
|
||||
"header-state": "Estado",
|
||||
"identifier": {
|
||||
"label": "Identificador",
|
||||
"placeholder": "El identificador único del usuario"
|
||||
},
|
||||
"source": {
|
||||
"label": "Origen",
|
||||
"placeholder": "El origen del usuario"
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"placeholder": "Una contraseña súper segura",
|
||||
"description": "Deja este campo en blanco para mantener la contraseña actual.",
|
||||
"too-weak": "La contraseña es demasiado débil. Por favor usa una más fuerte."
|
||||
},
|
||||
"email": {
|
||||
"label": "Correo",
|
||||
"placeholder": "La dirección de correo"
|
||||
},
|
||||
"phone": {
|
||||
"label": "Teléfono",
|
||||
"placeholder": "El número de teléfono"
|
||||
},
|
||||
"department": {
|
||||
"label": "Departamento",
|
||||
"placeholder": "El departamento"
|
||||
},
|
||||
"firstname": {
|
||||
"label": "Nombre",
|
||||
"placeholder": "Nombre"
|
||||
},
|
||||
"lastname": {
|
||||
"label": "Apellido",
|
||||
"placeholder": "Apellido"
|
||||
},
|
||||
"notes": {
|
||||
"label": "Notas",
|
||||
"placeholder": ""
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Deshabilitado (sin conexión WireGuard y sin posibilidad de inicio de sesión)"
|
||||
},
|
||||
"locked": {
|
||||
"label": "Bloqueado (no es posible iniciar sesión, las conexiones WireGuard aún funcionan)"
|
||||
},
|
||||
"admin": {
|
||||
"label": "Es administrador"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"tab-interface": "Interfaz",
|
||||
"tab-peerdef": "Valores predeterminados del peer",
|
||||
"header-general": "General",
|
||||
"header-network": "Red",
|
||||
"header-crypto": "Criptografía",
|
||||
"header-hooks": "Hooks de interfaz",
|
||||
"header-peer-hooks": "Hooks",
|
||||
"header-state": "Estado",
|
||||
"identifier": {
|
||||
"label": "Identificador",
|
||||
"placeholder": "El identificador único de la interfaz"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo de Interfaz",
|
||||
"server": "Modo Servidor",
|
||||
"client": "Modo Cliente",
|
||||
"any": "Modo Desconocido"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Backend de la Interfaz",
|
||||
"invalid-label": "El backend original ya no está disponible, usando el backend local de WireGuard en su lugar.",
|
||||
"local": "Backend local de WireGuard"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Nombre para Mostrar",
|
||||
"placeholder": "El nombre descriptivo de la interfaz"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "La clave Privada",
|
||||
"placeholder": "La clave privada"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "La clave pública",
|
||||
"placeholder": "La clave pública"
|
||||
},
|
||||
"ip": {
|
||||
"label": "Direcciones IP",
|
||||
"placeholder": "Direcciones IP (formato CIDR)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "Puerto de Escucha",
|
||||
"placeholder": "El puerto de escucha"
|
||||
},
|
||||
"dns": {
|
||||
"label": "Servidor DNS",
|
||||
"placeholder": "Los servidores DNS que deben usarse"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Dominios de Búsqueda DNS",
|
||||
"placeholder": "Prefijos de búsqueda DNS"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "La MTU de la interfaz (0 = mantener por defecto)"
|
||||
},
|
||||
"firewall-mark": {
|
||||
"label": "Marca de Firewall",
|
||||
"placeholder": "Marca de firewall que se aplica al tráfico saliente. (0 = automático)"
|
||||
},
|
||||
"routing-table": {
|
||||
"label": "Tabla de Enrutamiento",
|
||||
"placeholder": "El ID de la tabla de enrutamiento",
|
||||
"description": "Casos especiales: off = no administrar rutas, 0 = automático"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Interfaz Deshabilitada"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "Guardar automáticamente la configuración de wg-quick"
|
||||
},
|
||||
"defaults": {
|
||||
"endpoint": {
|
||||
"label": "Dirección del Endpoint",
|
||||
"placeholder": "Dirección del Endpoint",
|
||||
"description": "La dirección del endpoint al que los peers se conectarán. (ej: wg.ejemplo.com o wg.ejemplo.com:51820)"
|
||||
},
|
||||
"networks": {
|
||||
"label": "Redes IP",
|
||||
"placeholder": "Direcciones de Red",
|
||||
"description": "Los peers obtendrán direcciones IP de esas subredes."
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Direcciones IP Permitidas",
|
||||
"placeholder": "Direcciones IP Permitidas por Defecto"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "La MTU del cliente (0 = mantener por defecto)"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Intervalo de Keep Alive",
|
||||
"placeholder": "Keepalive Persistente (0 = por defecto)"
|
||||
}
|
||||
},
|
||||
"button-apply-defaults": "Aplicar Valores Predeterminados de peers"
|
||||
},
|
||||
"peer-view": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"section-info": "Información del peer",
|
||||
"section-status": "Estado Actual",
|
||||
"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",
|
||||
"disabled-status": "Deshabilitado en",
|
||||
"traffic": "Tráfico",
|
||||
"connection-status": "Estadísticas de Conexión",
|
||||
"upload": "Bytes Subidos (del Servidor al peer)",
|
||||
"download": "Bytes Descargados (del peer al Servidor)",
|
||||
"pingable": "Alcanzable (ping)",
|
||||
"handshake": "Último handshake",
|
||||
"connected-since": "Conectado desde",
|
||||
"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"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Editar peer:",
|
||||
"headline-edit-endpoint": "Editar endpoint:",
|
||||
"headline-new-peer": "Crear peer",
|
||||
"headline-new-endpoint": "Crear endpoint",
|
||||
"header-general": "General",
|
||||
"header-network": "Red",
|
||||
"header-crypto": "Criptografía",
|
||||
"header-hooks": "Hooks (Ejecutados en el peer)",
|
||||
"header-state": "Estado",
|
||||
"display-name": {
|
||||
"label": "Nombre para mostrar",
|
||||
"placeholder": "El nombre descriptivo para el peer"
|
||||
},
|
||||
"linked-user": {
|
||||
"label": "Usuario Vinculado",
|
||||
"placeholder": "La cuenta de usuario que posee este peer"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Clave Privada",
|
||||
"placeholder": "Clave privada",
|
||||
"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": "Clave Pública",
|
||||
"placeholder": "La Clave pública"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Clave pre-compartida",
|
||||
"placeholder": "Clave pre-compartida opcional"
|
||||
},
|
||||
"endpoint": {
|
||||
"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)"
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Direcciones IP permitidas",
|
||||
"placeholder": "Direcciones IP permitidas (formato CIDR)"
|
||||
},
|
||||
"extra-allowed-ip": {
|
||||
"label": "Direcciones IP permitidas extra",
|
||||
"placeholder": "IPs extra permitidas (lado del servidor)",
|
||||
"description": "Esas IPs serán agregadas en la interfaz remota de WireGuard como direcciones IP permitidas."
|
||||
},
|
||||
"dns": {
|
||||
"label": "Servidor DNS",
|
||||
"placeholder": "Los servidores DNS que deben usarse"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Dominios de búsqueda DNS",
|
||||
"placeholder": "Prefijos de búsqueda DNS"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Intervalo de Keep Alive",
|
||||
"placeholder": "Keepalive Persistente (0 = por defecto)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "La MTU del cliente (0 = mantener por defecto)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Peer Deshabilitado"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "Ignorar configuración global"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "Fecha de expiración"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "Crear múltiples peers",
|
||||
"headline-endpoint": "Crear múltiples endpoints",
|
||||
"identifiers": {
|
||||
"label": "Identificadores de Usuario",
|
||||
"placeholder": "Identificadores de Usuario",
|
||||
"description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer."
|
||||
},
|
||||
"prefix": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"label": "Prefijo del nombre del peer a mostrar",
|
||||
"placeholder": "Prefijo",
|
||||
"description": "Un prefijo que se agregará al nombre visible de los peers."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,7 @@
|
||||
"label": "Пароль",
|
||||
"placeholder": "Пожалуйста, введите ваш пароль"
|
||||
},
|
||||
"button": "Войти",
|
||||
"button-webauthn": "Использовать Passkey"
|
||||
"button": "Войти"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Главная",
|
||||
@@ -38,12 +37,8 @@
|
||||
"users": "Пользователи",
|
||||
"lang": "Сменить язык",
|
||||
"profile": "Мой профиль",
|
||||
"settings": "Настройки",
|
||||
"audit": "Журнал аудита",
|
||||
"login": "Вход",
|
||||
"logout": "Выход",
|
||||
"keygen": "Генератор ключей",
|
||||
"calculator": "Калькулятор IP-адресов"
|
||||
"logout": "Выход"
|
||||
},
|
||||
"home": {
|
||||
"headline": "Портал VPN WireGuard®",
|
||||
@@ -105,8 +100,6 @@
|
||||
"interface": {
|
||||
"headline": "Статус интерфейса для",
|
||||
"backend": "бэкэнд",
|
||||
"unknown-backend": "Неизвестно",
|
||||
"wrong-backend": "Неверный бэкэнд, вместо него используется локальный сервер WireGuard!",
|
||||
"key": "Публичный ключ",
|
||||
"endpoint": "Публичная конечная точка",
|
||||
"port": "Порт прослушивания",
|
||||
@@ -119,7 +112,6 @@
|
||||
"dns": "DNS-серверы",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Интервал поддержания активности по умолчанию",
|
||||
"default-dns": "DNS-сервера по-умолчанию",
|
||||
"button-show-config": "Показать конфигурацию",
|
||||
"button-download-config": "Скачать конфигурацию",
|
||||
"button-store-config": "Сохранить конфигурацию для wg-quick",
|
||||
@@ -176,121 +168,6 @@
|
||||
"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": "Учетная запись пользователя:",
|
||||
@@ -303,7 +180,6 @@
|
||||
"lastname": "Фамилия",
|
||||
"phone": "Номер телефона",
|
||||
"department": "Отдел",
|
||||
"api-enabled": "API",
|
||||
"disabled": "Учетная запись отключена",
|
||||
"locked": "Учетная запись заблокирована",
|
||||
"no-peers": "У пользователя нет связанных пиров.",
|
||||
@@ -331,8 +207,7 @@
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"placeholder": "Надежный пароль",
|
||||
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль.",
|
||||
"too-weak": "Пароль слишком простой. Используйте более сложный пароль."
|
||||
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль."
|
||||
},
|
||||
"email": {
|
||||
"label": "Электронная почта",
|
||||
@@ -392,11 +267,6 @@
|
||||
"client": "Режим клиента",
|
||||
"any": "Неизвестный режим"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Бэкэнд интерфейса",
|
||||
"invalid-label": "Оригинальный бэкэнд больше недоступн, вместо нее используется локальная WireGuard-бэкэнд!",
|
||||
"local": "Локальный WireGuard-бэкэнд"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Отображаемое имя",
|
||||
"placeholder": "Описательное имя для интерфейса"
|
||||
@@ -494,8 +364,6 @@
|
||||
"section-config": "Конфигурация",
|
||||
"identifier": "Идентификатор",
|
||||
"ip": "IP-адреса",
|
||||
"allowed-ip": "Разрешённые IP-адреса",
|
||||
"extra-allowed-ip": "Разрешённые IP-адреса на стороне сервера",
|
||||
"user": "Связанный пользователь",
|
||||
"notes": "Заметки",
|
||||
"expiry-status": "Истекает в",
|
||||
@@ -508,10 +376,8 @@
|
||||
"handshake": "Последнее рукопожатие",
|
||||
"connected-since": "Подключен с",
|
||||
"endpoint": "Конечная точка",
|
||||
"endpoint-key": "Публичный ключ конечной точки",
|
||||
"button-download": "Скачать конфигурацию",
|
||||
"button-email": "Отправить конфигурацию по электронной почте",
|
||||
"style-label": "Вид конфигурации"
|
||||
"button-email": "Отправить конфигурацию по электронной почте"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Редактировать пира:",
|
||||
@@ -533,8 +399,7 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Приватный ключ",
|
||||
"placeholder": "Приватный ключ",
|
||||
"help": "Закрытый ключ надежно хранится на сервере. Если у пользователя уже есть копия, вы можете не указывать это поле. Сервер работает исключительно с открытым ключом клиента."
|
||||
"placeholder": "Приватный ключ"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Публичный ключ",
|
||||
@@ -566,61 +431,61 @@
|
||||
"description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса."
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS-сервер",
|
||||
"placeholder": "Используемые DNS-серверы"
|
||||
"label": "DNS Server",
|
||||
"placeholder": "The DNS servers that should be used"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Поисковые домены DNS",
|
||||
"placeholder": "Префиксы поиска DNS"
|
||||
"label": "DNS Search Domains",
|
||||
"placeholder": "DNS search prefixes"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Интервал поддержания активности",
|
||||
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
|
||||
"label": "Keep Alive Interval",
|
||||
"placeholder": "Persistent Keepalive (0 = default)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "MTU клиента (0 = использовать значение по умолчанию)"
|
||||
"placeholder": "The client MTU (0 = keep default)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Узел отключен"
|
||||
"label": "Peer Disabled"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "Игнорировать глобальные настройки"
|
||||
"label": "Ignore global settings"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "Дата истечения срока действия"
|
||||
"label": "Expiry date"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "Создать несколько узлов",
|
||||
"headline-endpoint": "Создать несколько конечных точек",
|
||||
"headline-peer": "Create multiple peers",
|
||||
"headline-endpoint": "Create multiple endpoints",
|
||||
"identifiers": {
|
||||
"label": "Идентификаторы пользователей",
|
||||
"placeholder": "Идентификаторы пользователей",
|
||||
"description": "Идентификатор пользователя (имя пользователя), для которого узел будет создан."
|
||||
"label": "User Identifiers",
|
||||
"placeholder": "User Identifiers",
|
||||
"description": "A user identifier (the username) for which a peer should be created."
|
||||
},
|
||||
"prefix": {
|
||||
"headline-peer": "Узел:",
|
||||
"headline-endpoint": "Конечная точка:",
|
||||
"label": "Префикс отображаемого имени",
|
||||
"placeholder": "Префикс",
|
||||
"description": "Префикс будет добавлен к отображаемому имени узла."
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"label": "Display Name Prefix",
|
||||
"placeholder": "The prefix",
|
||||
"description": "A prefix that is added to the peers display name."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,14 +72,6 @@ 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",
|
||||
@@ -130,7 +122,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', '/ip-calculator']
|
||||
const publicPages = ['/', '/login', '/key-generator']
|
||||
const authRequired = !publicPages.includes(to.path)
|
||||
|
||||
if (authRequired && !auth.IsAuthenticated) {
|
||||
|
||||
@@ -115,7 +115,6 @@ export const interfaceStore = defineStore('interfaces', {
|
||||
return apiWrapper.post(`${baseUrl}/new`, formData)
|
||||
.then(iface => {
|
||||
this.interfaces.push(iface)
|
||||
this.selected = iface.Identifier
|
||||
this.fetching = false
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -126,14 +126,9 @@ export const peerStore = defineStore('peers', {
|
||||
if (!statsResponse) {
|
||||
this.stats = {}
|
||||
this.statsEnabled = false
|
||||
} else {
|
||||
this.stats = statsResponse.Stats
|
||||
this.statsEnabled = statsResponse.Enabled
|
||||
}
|
||||
},
|
||||
async Reset() {
|
||||
this.setPeers([])
|
||||
this.setStats(undefined)
|
||||
this.stats = statsResponse.Stats
|
||||
this.statsEnabled = statsResponse.Enabled
|
||||
},
|
||||
async PreparePeer(interfaceId) {
|
||||
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
|
||||
@@ -191,10 +186,10 @@ export const peerStore = defineStore('peers', {
|
||||
async LoadStats(interfaceId) {
|
||||
// if no interfaceId is given, use the currently selected interface
|
||||
if (!interfaceId) {
|
||||
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
if (!interfaceId) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
}
|
||||
this.fetching = true
|
||||
|
||||
@@ -265,10 +260,10 @@ export const peerStore = defineStore('peers', {
|
||||
async LoadPeers(interfaceId) {
|
||||
// if no interfaceId is given, use the currently selected interface
|
||||
if (!interfaceId) {
|
||||
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
if (!interfaceId) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
}
|
||||
this.fetching = true
|
||||
|
||||
|
||||
@@ -151,17 +151,6 @@ export const profileStore = defineStore('profile', {
|
||||
})
|
||||
})
|
||||
},
|
||||
async changePassword(formData) {
|
||||
this.fetching = true
|
||||
let currentUser = authStore().user.Identifier
|
||||
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData)
|
||||
.then(this.fetching = false)
|
||||
.catch(error => {
|
||||
this.fetching = false;
|
||||
console.log("Failed to change password for ", currentUser, ": ", error);
|
||||
throw new Error(error);
|
||||
});
|
||||
},
|
||||
async LoadPeers() {
|
||||
this.fetching = true
|
||||
let currentUser = authStore().user.Identifier
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<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>
|
||||
@@ -218,12 +218,12 @@ onMounted(async () => {
|
||||
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.mtu') }}:</td>
|
||||
<td>{{interfaces.GetSelected.Mtu}}</td>
|
||||
<td>{{ $t('interfaces.interface.dns') }}:</td>
|
||||
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.default-dns') }}:</td>
|
||||
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td>
|
||||
<td>{{ $t('interfaces.interface.mtu') }}:</td>
|
||||
<td>{{interfaces.GetSelected.Mtu}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
|
||||
@@ -400,7 +400,7 @@ onMounted(async () => {
|
||||
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
|
||||
</td>
|
||||
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
|
||||
<td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td>
|
||||
<td>{{peer.UserIdentifier}}</td>
|
||||
<td>
|
||||
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
|
||||
</td>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {onMounted, ref} from "vue";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { authStore } from "../stores/auth";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
|
||||
const profile = profileStore()
|
||||
const settings = settingsStore()
|
||||
@@ -35,45 +34,6 @@ async function saveRename(credential) {
|
||||
console.error("Failed to rename credential:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const pwFormData = ref({
|
||||
OldPassword: '',
|
||||
Password: '',
|
||||
PasswordRepeat: '',
|
||||
})
|
||||
|
||||
const passwordWeak = computed(() => {
|
||||
return pwFormData.value.Password && pwFormData.value.Password.length > 0 && pwFormData.value.Password.length < settings.Setting('MinPasswordLength')
|
||||
})
|
||||
|
||||
const passwordChangeAllowed = computed(() => {
|
||||
return pwFormData.value.Password && pwFormData.value.Password.length >= settings.Setting('MinPasswordLength') &&
|
||||
pwFormData.value.Password === pwFormData.value.PasswordRepeat &&
|
||||
pwFormData.value.OldPassword && pwFormData.value.OldPassword.length > 0 && pwFormData.value.OldPassword !== pwFormData.value.Password;
|
||||
})
|
||||
|
||||
const updatePassword = async () => {
|
||||
try {
|
||||
await profile.changePassword(pwFormData.value);
|
||||
|
||||
pwFormData.value.OldPassword = '';
|
||||
pwFormData.value.Password = '';
|
||||
pwFormData.value.PasswordRepeat = '';
|
||||
notify({
|
||||
title: "Password changed!",
|
||||
text: "Your password has been changed successfully.",
|
||||
type: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
notify({
|
||||
title: "Failed to update password!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,45 +43,52 @@ const updatePassword = async () => {
|
||||
|
||||
<p class="lead">{{ $t('settings.abstract') }}</p>
|
||||
|
||||
<div class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'">
|
||||
<h2 class="display-7">{{ $t('settings.password.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.password.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label>
|
||||
<input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password">
|
||||
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
||||
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
|
||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('settings.api.active-description') }}</p>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
|
||||
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
|
||||
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group has-success">
|
||||
<label class="form-label mt-4" for="newpw">{{ $t('settings.password.new-label') }}</label>
|
||||
<input id="newpw" v-model="pwFormData.Password" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': pwFormData.Password !== '' && !passwordWeak }" type="password">
|
||||
<div class="invalid-feedback" v-if="passwordWeak">{{ $t('settings.password.weak-label') }}</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="form-group">
|
||||
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label>
|
||||
<input id="confirmnewpw" v-model="pwFormData.PasswordRepeat" class="form-control" :class="{ 'is-invalid': pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat, 'is-valid': pwFormData.PasswordRepeat !== '' && pwFormData.Password === pwFormData.PasswordRepeat && !passwordWeak }" type="password">
|
||||
<div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
|
||||
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed">
|
||||
<i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
</div>
|
||||
<div class="card border-secondary p-5" v-else>
|
||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('settings.api.inactive-description') }}</p>
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
||||
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,53 +173,4 @@ const updatePassword = async () => {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-5" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
||||
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
|
||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('settings.api.active-description') }}</p>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
|
||||
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
|
||||
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="form-group">
|
||||
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
|
||||
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border-secondary p-5" v-else>
|
||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('settings.api.inactive-description') }}</p>
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
||||
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
90
go.mod
90
go.mod
@@ -5,104 +5,98 @@ 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.17.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-pkgz/routegroup v1.6.0
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/go-pkgz/routegroup v1.5.2
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/vardius/message-bus v1.1.5
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
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.46.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sys v0.35.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.3
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/driver/sqlserver v1.6.1
|
||||
gorm.io/gorm v1.30.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // 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.11 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // 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.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-jose/go-jose/v4 v4.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.2 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // 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-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/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // 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.7 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // 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
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // 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-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.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.67.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.66.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
||||
210
go.sum
210
go.sum
@@ -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.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
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/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=
|
||||
@@ -30,16 +30,16 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
|
||||
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
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.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
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,79 +50,59 @@ 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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
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=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
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.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.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-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
|
||||
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/go-pkgz/routegroup v1.5.2 h1:/W/5GwsHaojeEBldiSB/fcqPLm0AE/eT36reCsMEbtY=
|
||||
github.com/go-pkgz/routegroup v1.5.2/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=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
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-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/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
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=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/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=
|
||||
@@ -133,14 +113,12 @@ 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=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -159,6 +137,10 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
@@ -172,25 +154,29 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
|
||||
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
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.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||
github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
|
||||
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/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=
|
||||
@@ -199,21 +185,19 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
||||
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
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.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/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
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=
|
||||
@@ -226,8 +210,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
@@ -252,12 +236,6 @@ github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
@@ -270,18 +248,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.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/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90=
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg=
|
||||
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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
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=
|
||||
@@ -299,10 +277,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.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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
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=
|
||||
@@ -310,8 +288,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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=
|
||||
@@ -332,8 +310,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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=
|
||||
@@ -362,23 +340,23 @@ 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
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=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -393,25 +371,23 @@ 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.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
|
||||
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
||||
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
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=
|
||||
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/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.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.66.6 h1:RyQpwAhM/19nXD8y3iejM/AjmKwY2TjxZTlUWTsWw2U=
|
||||
modernc.org/libc v1.66.6/go.mod h1:j8z0EYAuumoMQ3+cWXtmw6m+LYn3qm8dcZDFtFTSq+M=
|
||||
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=
|
||||
@@ -420,11 +396,11 @@ 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.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
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=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
|
||||
@@ -166,9 +166,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -85,8 +84,8 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) {
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
|
||||
shellCmd: "bash", // we only support bash at the moment
|
||||
resolvConfIfacePrefix: cfg.Backend.LocalResolvconfPrefix, // WireGuard interfaces have a tun. prefix in resolvconf
|
||||
shellCmd: "bash", // we only support bash at the moment
|
||||
resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
@@ -547,11 +546,7 @@ func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id doma
|
||||
|
||||
// region wg-quick-related
|
||||
|
||||
func (c LocalController) ExecuteInterfaceHook(
|
||||
_ context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
hookCmd string,
|
||||
) error {
|
||||
func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
|
||||
if hookCmd == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -565,7 +560,7 @@ func (c LocalController) ExecuteInterfaceHook(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
|
||||
func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
|
||||
if dnsStr == "" && dnsSearchStr == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -594,7 +589,7 @@ func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) UnsetDNS(_ context.Context, id domain.InterfaceIdentifier, _, _ string) error {
|
||||
func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error {
|
||||
dnsCommand := "resolvconf -d %resPref%i -f"
|
||||
|
||||
err := c.exec(dnsCommand, id)
|
||||
@@ -616,7 +611,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
|
||||
if len(stdin) > 0 {
|
||||
b := &bytes.Buffer{}
|
||||
for _, ln := range stdin {
|
||||
if _, err := fmt.Fprint(b, ln+"\n"); err != nil {
|
||||
if _, err := fmt.Fprint(b, ln); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -624,8 +619,6 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
|
||||
}
|
||||
out, err := cmd.CombinedOutput() // execute and wait for output
|
||||
if err != nil {
|
||||
slog.Warn("failed to executed shell command",
|
||||
"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err)
|
||||
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
|
||||
}
|
||||
slog.Debug("executed shell command",
|
||||
@@ -638,116 +631,49 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
|
||||
|
||||
// region routing-related
|
||||
|
||||
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c LocalController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
interfaceId := info.Interface.Identifier
|
||||
slog.Debug("setting linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark,
|
||||
"cidrs", info.AllowedIps)
|
||||
|
||||
link, err := c.nl.LinkByName(string(interfaceId))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find physical link for %s: %w", interfaceId, err)
|
||||
func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// update fwmark rules
|
||||
if err := c.setFwMarkRules(rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
|
||||
realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, info.Table, info.FwMark)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err)
|
||||
}
|
||||
wgDev, err := c.wg.Device(string(interfaceId))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get wg device for %s: %w", interfaceId, err)
|
||||
}
|
||||
currentFwMark := wgDev.FirewallMark
|
||||
if int(realFwMark) != currentFwMark {
|
||||
slog.Debug("updating fwmark for interface", "interface", interfaceId, "oldFwMark", currentFwMark,
|
||||
"newFwMark", realFwMark, "oldTable", info.Table, "newTable", realTable)
|
||||
if err := c.updateFwMarkOnInterface(interfaceId, int(realFwMark)); err != nil {
|
||||
return fmt.Errorf("failed to update fwmark for interface %s to %d: %w", interfaceId, realFwMark, err)
|
||||
}
|
||||
// update main rule
|
||||
if err := c.setMainRule(rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4); err != nil {
|
||||
return fmt.Errorf("failed to set v4 routes: %w", err)
|
||||
}
|
||||
if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6); err != nil {
|
||||
return fmt.Errorf("failed to set v6 routes: %w", err)
|
||||
// cleanup old main rules
|
||||
if err := c.cleanupMainRule(rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) setRoutesForFamily(
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
link netlink.Link,
|
||||
family int,
|
||||
table int,
|
||||
fwMark uint32,
|
||||
cidrs []domain.Cidr,
|
||||
) error {
|
||||
// first create or update the routes
|
||||
for _, cidr := range cidrs {
|
||||
err := c.nl.RouteReplace(&netlink.Route{
|
||||
LinkIndex: link.Attrs().Index,
|
||||
Dst: cidr.IpNet(),
|
||||
Table: table,
|
||||
Scope: unix.RT_SCOPE_LINK,
|
||||
Type: unix.RTN_UNICAST,
|
||||
})
|
||||
func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
|
||||
for _, rule := range rules {
|
||||
existingRules, err := c.nl.RuleList(int(rule.IpFamily))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add/update route %s on table %d for interface %s: %w",
|
||||
cidr.String(), table, interfaceId, err)
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err)
|
||||
}
|
||||
}
|
||||
|
||||
// next remove old routes
|
||||
rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{
|
||||
LinkIndex: link.Attrs().Index,
|
||||
Table: unix.RT_TABLE_UNSPEC, // all tables
|
||||
Scope: unix.RT_SCOPE_LINK,
|
||||
Type: unix.RTN_UNICAST,
|
||||
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w",
|
||||
interfaceId, family, err)
|
||||
}
|
||||
for _, rawRoute := range rawRoutes {
|
||||
if rawRoute.Dst == nil { // handle default route
|
||||
var netlinkAddr domain.Cidr
|
||||
if family == netlink.FAMILY_V4 {
|
||||
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
|
||||
} else {
|
||||
netlinkAddr, _ = domain.CidrFromString("::/0")
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
rawRoute.Dst = netlinkAddr.IpNet()
|
||||
}
|
||||
|
||||
route := domain.CidrFromIpNet(*rawRoute.Dst)
|
||||
if slices.Contains(cidrs, route) {
|
||||
continue
|
||||
if ruleExists {
|
||||
continue // rule already exists, no need to recreate it
|
||||
}
|
||||
|
||||
if err := c.nl.RouteDel(&rawRoute); err != nil {
|
||||
return fmt.Errorf("failed to remove deprecated route %s from interface %s: %w", route, interfaceId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// next, update route rules for normal routes
|
||||
if table == 0 {
|
||||
return nil // no need to update route rules as we are using the default table
|
||||
}
|
||||
existingRules, err := c.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family-id %d: %w", family, err)
|
||||
}
|
||||
ruleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool {
|
||||
return rule.Mark == fwMark && rule.Table == table
|
||||
})
|
||||
if !ruleExists {
|
||||
// create a missing rule
|
||||
if err := c.nl.RuleAdd(&netlink.Rule{
|
||||
Family: family,
|
||||
Table: table,
|
||||
Mark: fwMark,
|
||||
Family: int(rule.IpFamily),
|
||||
Table: rule.Table,
|
||||
Mark: rule.FwMark,
|
||||
Invert: true,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: -1,
|
||||
@@ -756,102 +682,15 @@ func (c LocalController) setRoutesForFamily(
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup rule for fwmark %d and table %d for family-id %d: %w",
|
||||
fwMark, table, family, err)
|
||||
return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w",
|
||||
rule.IpFamily, rule.FwMark, rule.Table, err)
|
||||
}
|
||||
}
|
||||
mainRuleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool {
|
||||
return rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN
|
||||
})
|
||||
if !mainRuleExists && domain.ContainsDefaultRoute(cidrs) {
|
||||
err = c.nl.RuleAdd(&netlink.Rule{
|
||||
Family: family,
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: 0,
|
||||
Priority: c.getMainRulePriority(existingRules),
|
||||
Mark: 0,
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// finally, clean up extra main rules - only one rule is allowed
|
||||
existingRules, err = c.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing main rules for family-id %d: %w", family, err)
|
||||
}
|
||||
mainRuleCount := 0
|
||||
for _, rule := range existingRules {
|
||||
if rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN {
|
||||
mainRuleCount++
|
||||
}
|
||||
if mainRuleCount > 1 {
|
||||
if err := c.nl.RuleDel(&rule); err != nil {
|
||||
return fmt.Errorf("failed to remove extra main rule for family-id %d: %w", family, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getOrCreateRoutingTableAndFwMark(
|
||||
link netlink.Link,
|
||||
tableIn int,
|
||||
fwMarkIn uint32,
|
||||
) (
|
||||
table int,
|
||||
fwmark uint32,
|
||||
err error,
|
||||
) {
|
||||
table = tableIn
|
||||
fwmark = fwMarkIn
|
||||
|
||||
if fwmark == 0 {
|
||||
// generate a new (temporary) firewall mark based on the interface index
|
||||
fwmark = uint32(c.cfg.Advanced.RouteTableOffset + link.Attrs().Index)
|
||||
}
|
||||
if table == 0 {
|
||||
table = int(fwmark) // generate a new routing table base on interface index
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c LocalController) updateFwMarkOnInterface(interfaceId domain.InterfaceIdentifier, fwMark int) error {
|
||||
// apply the new fwmark to the wireguard interface
|
||||
err := c.wg.ConfigureDevice(string(interfaceId), wgtypes.Config{
|
||||
FirewallMark: &fwMark,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update fwmark of interface %s to: %d: %w", interfaceId, fwMark, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
|
||||
prio := c.cfg.Advanced.RulePrioOffset
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == prio {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
prio++
|
||||
}
|
||||
}
|
||||
return prio
|
||||
}
|
||||
|
||||
func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
|
||||
prio := 32700 // linux main rule has a prio of 32766
|
||||
prio := 32700 // linux main rule has a priority of 32766
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
@@ -869,145 +708,126 @@ func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
|
||||
return prio
|
||||
}
|
||||
|
||||
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c LocalController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
interfaceId := info.Interface.Identifier
|
||||
slog.Debug("removing linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark,
|
||||
"cidrs", info.AllowedIps)
|
||||
|
||||
wgDev, err := c.wg.Device(string(interfaceId))
|
||||
if err != nil {
|
||||
slog.Debug("wg device already removed, route cleanup might be incomplete", "interface", interfaceId)
|
||||
wgDev = nil
|
||||
}
|
||||
link, err := c.nl.LinkByName(string(interfaceId))
|
||||
if err != nil {
|
||||
slog.Debug("physical link already removed, route cleanup might be incomplete", "interface", interfaceId)
|
||||
link = nil
|
||||
}
|
||||
|
||||
fwMark := info.FwMark
|
||||
if wgDev != nil && info.FwMark == 0 {
|
||||
fwMark = uint32(wgDev.FirewallMark)
|
||||
}
|
||||
table := info.Table
|
||||
if wgDev != nil && info.Table == 0 {
|
||||
table = wgDev.FirewallMark // use the fwMark as table, this is the default behavior
|
||||
}
|
||||
linkIndex := -1
|
||||
if link != nil {
|
||||
linkIndex = link.Attrs().Index
|
||||
}
|
||||
|
||||
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
|
||||
realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, table, fwMark)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err)
|
||||
}
|
||||
|
||||
if linkIndex > 0 {
|
||||
err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove v4 routes: %w", err)
|
||||
func (c LocalController) setMainRule(rules []domain.RouteRule) error {
|
||||
var family domain.IpFamily
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
family = rule.IpFamily
|
||||
if rule.HasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove v6 routes: %w", err)
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingRules, err := c.nl.RuleList(int(family))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
|
||||
}
|
||||
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if table > 0 {
|
||||
err = c.removeRouteRulesForTable(netlink.FAMILY_V4, realTable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove v4 route rules for %s: %w", interfaceId, err)
|
||||
if ruleExists {
|
||||
return nil // rule already exists, skip re-creation
|
||||
}
|
||||
|
||||
if err := c.nl.RuleAdd(&netlink.Rule{
|
||||
Family: int(family),
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: 0,
|
||||
Priority: c.getMainRulePriority(existingRules),
|
||||
Mark: 0,
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup rule for main table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
|
||||
priority := c.cfg.Advanced.RulePrioOffset
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == priority {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
err = c.removeRouteRulesForTable(netlink.FAMILY_V6, realTable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove v6 route rules for %s: %w", interfaceId, err)
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
priority++
|
||||
}
|
||||
}
|
||||
return priority
|
||||
}
|
||||
|
||||
func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error {
|
||||
var family domain.IpFamily
|
||||
for _, rule := range rules {
|
||||
family = rule.IpFamily
|
||||
break
|
||||
}
|
||||
|
||||
existingRules, err := c.nl.RuleList(int(family))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
|
||||
}
|
||||
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
if rule.HasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mainRules := 0
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
mainRules++
|
||||
}
|
||||
}
|
||||
|
||||
removalCount := 0
|
||||
if mainRules > 1 {
|
||||
removalCount = mainRules - 1 // we only want one single rule
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
removalCount = mainRules
|
||||
}
|
||||
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
if removalCount > 0 {
|
||||
existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
|
||||
if err := c.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete main rule: %w", err)
|
||||
}
|
||||
removalCount--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) removeRoutesForFamily(
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
link netlink.Link,
|
||||
family int,
|
||||
table int,
|
||||
fwMark uint32,
|
||||
cidrs []domain.Cidr,
|
||||
) error {
|
||||
// first remove all rules
|
||||
existingRules, err := c.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
|
||||
}
|
||||
for _, existingRule := range existingRules {
|
||||
if fwMark == existingRule.Mark && table == existingRule.Table {
|
||||
existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
|
||||
if err := c.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete old fwmark rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// next remove all routes
|
||||
rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{
|
||||
LinkIndex: link.Attrs().Index,
|
||||
Table: unix.RT_TABLE_UNSPEC, // all tables
|
||||
Scope: unix.RT_SCOPE_LINK,
|
||||
Type: unix.RTN_UNICAST,
|
||||
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w",
|
||||
interfaceId, family, err)
|
||||
}
|
||||
for _, rawRoute := range rawRoutes {
|
||||
if rawRoute.Dst == nil { // handle default route
|
||||
var netlinkAddr domain.Cidr
|
||||
if family == netlink.FAMILY_V4 {
|
||||
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
|
||||
} else {
|
||||
netlinkAddr, _ = domain.CidrFromString("::/0")
|
||||
}
|
||||
rawRoute.Dst = netlinkAddr.IpNet()
|
||||
}
|
||||
|
||||
if rawRoute.Table != table {
|
||||
continue // ignore routes from other tables
|
||||
}
|
||||
|
||||
route := domain.CidrFromIpNet(*rawRoute.Dst)
|
||||
if !slices.Contains(cidrs, route) {
|
||||
continue // only remove routes that were previously added
|
||||
}
|
||||
|
||||
if err := c.nl.RouteDel(&rawRoute); err != nil {
|
||||
return fmt.Errorf("failed to remove old route %s from interface %s: %w", route, interfaceId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) removeRouteRulesForTable(
|
||||
family int,
|
||||
table int,
|
||||
) error {
|
||||
existingRules, err := c.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing route rules for family-id %d: %w", family, err)
|
||||
}
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == table {
|
||||
err := c.nl.RuleDel(&existingRule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old rule for table %d and family-id %d: %w", table, family, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
// endregion routing-related
|
||||
|
||||
@@ -3,21 +3,19 @@ package wgcontroller
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
const MikrotikRouteDistance = 5
|
||||
const MikrotikDefaultRoutingTable = "main"
|
||||
|
||||
type MikrotikController struct {
|
||||
coreCfg *config.Config
|
||||
cfg *config.BackendMikrotik
|
||||
@@ -25,9 +23,8 @@ type MikrotikController struct {
|
||||
client *lowlevel.MikrotikApiClient
|
||||
|
||||
// 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
|
||||
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
|
||||
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) {
|
||||
@@ -44,7 +41,6 @@ func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik)
|
||||
|
||||
interfaceMutexes: sync.Map{},
|
||||
peerMutexes: sync.Map{},
|
||||
coreMutex: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -682,15 +678,11 @@ func (c *MikrotikController) updatePeer(
|
||||
extras := pp.GetExtras().(domain.MikrotikPeerExtras)
|
||||
peerId := extras.Id
|
||||
|
||||
endpoint := "" // by default, we have no endpoint (the peer does not initiate a connection)
|
||||
endpointPort := "0" // by default, we have no endpoint port (the peer does not initiate a connection)
|
||||
if !extras.IsResponder { // if the peer is not only a responder, it needs the endpoint to initiate a connection
|
||||
endpoint = pp.Endpoint
|
||||
endpointPort = "51820" // default port if not set
|
||||
if s := strings.Split(endpoint, ":"); len(s) == 2 {
|
||||
endpoint = s[0]
|
||||
endpointPort = s[1]
|
||||
}
|
||||
endpoint := pp.Endpoint
|
||||
endpointPort := "51820" // default port if not set
|
||||
if s := strings.Split(endpoint, ":"); len(s) == 2 {
|
||||
endpoint = s[0]
|
||||
endpointPort = s[1]
|
||||
}
|
||||
|
||||
allowedAddressStr := domain.CidrsToString(pp.AllowedIPs)
|
||||
@@ -768,404 +760,33 @@ func (c *MikrotikController) DeletePeer(
|
||||
|
||||
// region wg-quick-related
|
||||
|
||||
func (c *MikrotikController) ExecuteInterfaceHook(
|
||||
_ context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
_ string,
|
||||
) error {
|
||||
func (c *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
|
||||
// TODO implement me
|
||||
slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub")
|
||||
return nil
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *MikrotikController) SetDNS(
|
||||
ctx context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
dnsStr, _ string,
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
c.coreMutex.Lock()
|
||||
defer c.coreMutex.Unlock()
|
||||
|
||||
// check if the server is already configured
|
||||
wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{"servers"},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error)
|
||||
}
|
||||
|
||||
var existingServers []string
|
||||
existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
|
||||
|
||||
newServers := strings.Split(dnsStr, ",")
|
||||
|
||||
mergedServers := slices.Clone(existingServers)
|
||||
for _, s := range newServers {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(mergedServers, s) {
|
||||
mergedServers = append(mergedServers, s)
|
||||
}
|
||||
}
|
||||
mergedServersStr := strings.Join(mergedServers, ",")
|
||||
|
||||
reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{
|
||||
"servers": mergedServersStr,
|
||||
})
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
func (c *MikrotikController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *MikrotikController) UnsetDNS(
|
||||
ctx context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
dnsStr, _ string,
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
c.coreMutex.Lock()
|
||||
defer c.coreMutex.Unlock()
|
||||
|
||||
// retrieve current DNS settings
|
||||
wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{"servers"},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error)
|
||||
}
|
||||
|
||||
var existingServers []string
|
||||
existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
|
||||
|
||||
oldServers := strings.Split(dnsStr, ",")
|
||||
|
||||
mergedServers := make([]string, 0, len(existingServers))
|
||||
for _, s := range existingServers {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(oldServers, s) {
|
||||
mergedServers = append(mergedServers, s) // only keep the servers that are not in the old list
|
||||
}
|
||||
}
|
||||
mergedServersStr := strings.Join(mergedServers, ",")
|
||||
|
||||
reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{
|
||||
"servers": mergedServersStr,
|
||||
})
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
// endregion wg-quick-related
|
||||
|
||||
// region routing-related
|
||||
|
||||
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c *MikrotikController) SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
|
||||
interfaceId := info.Interface.Identifier
|
||||
slog.Debug("setting mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps)
|
||||
|
||||
// Mikrotik needs some time to apply the changes.
|
||||
// If we don't wait, the routes might get created multiple times as the dynamic routes are not yet available.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tableName, err := c.getOrCreateRoutingTables(ctx, info.Interface.Identifier, info.TableStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get or create routing table for %s: %v", interfaceId, err)
|
||||
}
|
||||
|
||||
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
|
||||
|
||||
err = c.setRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set IPv4 routes for %s: %v", interfaceId, err)
|
||||
}
|
||||
|
||||
err = c.setRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set IPv6 routes for %s: %v", interfaceId, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *MikrotikController) resolveRouteTableName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
var mikrotikTableName string
|
||||
switch strings.ToLower(name) {
|
||||
case "", "0":
|
||||
mikrotikTableName = MikrotikDefaultRoutingTable
|
||||
case MikrotikDefaultRoutingTable:
|
||||
return fmt.Sprintf("wgportal-%s",
|
||||
MikrotikDefaultRoutingTable) // if the Mikrotik Main table should be used, the table-name should be left empty or set to "0".
|
||||
default:
|
||||
mikrotikTableName = name
|
||||
}
|
||||
|
||||
return mikrotikTableName
|
||||
}
|
||||
|
||||
func (c *MikrotikController) getOrCreateRoutingTables(
|
||||
ctx context.Context,
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
table string,
|
||||
) (string, error) {
|
||||
// retrieve current routing tables
|
||||
wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "dynamic", "fib", "name",
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return "", fmt.Errorf("unable to query routing tables: %v", wgReply.Error)
|
||||
}
|
||||
|
||||
wantedTableName := c.resolveRouteTableName(table)
|
||||
|
||||
// check if the table already exists
|
||||
for _, table := range wgReply.Data {
|
||||
if table.GetString("name") == wantedTableName {
|
||||
return wantedTableName, nil // already exists, nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
// create the table if it does not exist
|
||||
createReply := c.client.Create(ctx, "/routing/table", lowlevel.GenericJsonObject{
|
||||
"name": wantedTableName,
|
||||
"comment": fmt.Sprintf("Routing Table for %s", interfaceId),
|
||||
"fib": strconv.FormatBool(true),
|
||||
})
|
||||
if createReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return "", fmt.Errorf("failed to create routing table %s: %v", wantedTableName, createReply.Error)
|
||||
}
|
||||
|
||||
return wantedTableName, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) setRoutesForFamily(
|
||||
ctx context.Context,
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
ipV6 bool,
|
||||
table string,
|
||||
cidrs []domain.Cidr,
|
||||
) error {
|
||||
apiPath := "/ip/route"
|
||||
if ipV6 {
|
||||
apiPath = "/ipv6/route"
|
||||
}
|
||||
|
||||
// retrieve current routes
|
||||
wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw",
|
||||
"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"gateway": string(interfaceId),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error)
|
||||
}
|
||||
|
||||
// first create or update the routes
|
||||
for _, cidr := range cidrs {
|
||||
// check if the route already exists
|
||||
exists := false
|
||||
for _, route := range wgReply.Data {
|
||||
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse route destination address",
|
||||
"cidr", route.GetString("dst-address"), "error", err)
|
||||
continue
|
||||
}
|
||||
if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue // route already exists, nothing to do
|
||||
}
|
||||
|
||||
// create the route
|
||||
reply := c.client.Create(ctx, apiPath, lowlevel.GenericJsonObject{
|
||||
"gateway": string(interfaceId),
|
||||
"dst-address": cidr.String(),
|
||||
"distance": strconv.Itoa(MikrotikRouteDistance),
|
||||
"disabled": strconv.FormatBool(false),
|
||||
"routing-table": table,
|
||||
})
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to create new route %s via %s: %v", cidr.String(), interfaceId, reply.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// finally, remove the routes that are not in the new list
|
||||
for _, route := range wgReply.Data {
|
||||
if route.GetBool("dynamic") {
|
||||
continue // dynamic routes are not managed by the controller, nothing to do
|
||||
}
|
||||
|
||||
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse route destination address",
|
||||
"cidr", route.GetString("dst-address"), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
valid := false
|
||||
for _, cidr := range cidrs {
|
||||
if existingRoute.EqualPrefix(cidr) {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
continue // route is still valid, nothing to do
|
||||
}
|
||||
|
||||
// remove the route
|
||||
reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to remove outdated route %s: %v", existingRoute.String(), reply.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c *MikrotikController) RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
|
||||
interfaceId := info.Interface.Identifier
|
||||
slog.Debug("removing mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps)
|
||||
|
||||
tableName := c.resolveRouteTableName(info.TableStr)
|
||||
|
||||
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
|
||||
|
||||
err := c.removeRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove IPv4 routes for %s: %v", interfaceId, err)
|
||||
}
|
||||
|
||||
err = c.removeRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove IPv6 routes for %s: %v", interfaceId, err)
|
||||
}
|
||||
|
||||
err = c.removeRoutingTable(ctx, tableName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove routing table for %s: %v", interfaceId, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) removeRoutesForFamily(
|
||||
ctx context.Context,
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
ipV6 bool,
|
||||
table string,
|
||||
cidrs []domain.Cidr,
|
||||
) error {
|
||||
apiPath := "/ip/route"
|
||||
if ipV6 {
|
||||
apiPath = "/ipv6/route"
|
||||
}
|
||||
|
||||
// retrieve current routes
|
||||
wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw",
|
||||
"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"gateway": string(interfaceId),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error)
|
||||
}
|
||||
|
||||
// remove the routes from the list
|
||||
for _, route := range wgReply.Data {
|
||||
if route.GetBool("dynamic") {
|
||||
continue // dynamic routes are not managed by the controller, nothing to do
|
||||
}
|
||||
|
||||
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse route destination address",
|
||||
"cidr", route.GetString("dst-address"), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
remove := false
|
||||
for _, cidr := range cidrs {
|
||||
if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table {
|
||||
remove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !remove {
|
||||
continue // route is still valid, nothing to do
|
||||
}
|
||||
|
||||
// remove the route
|
||||
reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to remove old route %s: %v", existingRoute.String(), reply.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) removeRoutingTable(
|
||||
ctx context.Context,
|
||||
table string,
|
||||
) error {
|
||||
if table == MikrotikDefaultRoutingTable {
|
||||
return nil // we cannot remove the default table
|
||||
}
|
||||
|
||||
// retrieve current routing tables
|
||||
wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "dynamic", "fib", "name",
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("unable to query routing tables: %v", wgReply.Error)
|
||||
}
|
||||
|
||||
for _, existingTable := range wgReply.Data {
|
||||
if existingTable.GetBool("dynamic") {
|
||||
continue // dynamic tables are not managed by the controller, nothing to do
|
||||
}
|
||||
if existingTable.GetString("name") != table {
|
||||
continue // not the table we want to remove
|
||||
}
|
||||
|
||||
// remove the table
|
||||
reply := c.client.Delete(ctx, "/routing/table/"+existingTable.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to remove routing table %s: %v", table, reply.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
// endregion routing-related
|
||||
|
||||
@@ -1,979 +0,0 @@
|
||||
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
|
||||
|
||||
113
internal/adapters/wgquick.go
Normal file
113
internal/adapters/wgquick.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package adapters
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// WgQuickRepo implements higher level wg-quick like interactions like setting DNS, routing tables or interface hooks.
|
||||
type WgQuickRepo struct {
|
||||
shellCmd string
|
||||
resolvConfIfacePrefix string
|
||||
}
|
||||
|
||||
// NewWgQuickRepo creates a new WgQuickRepo instance.
|
||||
func NewWgQuickRepo() *WgQuickRepo {
|
||||
return &WgQuickRepo{
|
||||
shellCmd: "bash",
|
||||
resolvConfIfacePrefix: "tun.",
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteInterfaceHook executes the given hook command.
|
||||
// The hook command can contain the following placeholders:
|
||||
//
|
||||
// %i: the interface identifier.
|
||||
func (r *WgQuickRepo) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
|
||||
if hookCmd == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Debug("executing interface hook", "interface", id, "hook", hookCmd)
|
||||
err := r.exec(hookCmd, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to exec hook: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDNS sets the DNS settings for the given interface. It uses resolvconf to set the DNS settings.
|
||||
func (r *WgQuickRepo) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
|
||||
if dnsStr == "" && dnsSearchStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dnsServers := internal.SliceString(dnsStr)
|
||||
dnsSearchDomains := internal.SliceString(dnsSearchStr)
|
||||
|
||||
dnsCommand := "resolvconf -a %resPref%i -m 0 -x"
|
||||
dnsCommandInput := make([]string, 0, len(dnsServers)+len(dnsSearchDomains))
|
||||
|
||||
for _, dnsServer := range dnsServers {
|
||||
dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("nameserver %s", dnsServer))
|
||||
}
|
||||
for _, searchDomain := range dnsSearchDomains {
|
||||
dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("search %s", searchDomain))
|
||||
}
|
||||
|
||||
err := r.exec(dnsCommand, id, dnsCommandInput...)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to set dns settings (is resolvconf available?, for systemd create this symlink: ln -s /usr/bin/resolvectl /usr/local/bin/resolvconf): %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsetDNS unsets the DNS settings for the given interface. It uses resolvconf to unset the DNS settings.
|
||||
func (r *WgQuickRepo) UnsetDNS(id domain.InterfaceIdentifier) error {
|
||||
dnsCommand := "resolvconf -d %resPref%i -f"
|
||||
|
||||
err := r.exec(dnsCommand, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unset dns settings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WgQuickRepo) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string {
|
||||
command = strings.ReplaceAll(command, "%resPref", r.resolvConfIfacePrefix)
|
||||
return strings.ReplaceAll(command, "%i", string(interfaceId))
|
||||
}
|
||||
|
||||
func (r *WgQuickRepo) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error {
|
||||
commandWithInterfaceName := r.replaceCommandPlaceHolders(command, interfaceId)
|
||||
cmd := exec.Command(r.shellCmd, "-ce", commandWithInterfaceName)
|
||||
if len(stdin) > 0 {
|
||||
b := &bytes.Buffer{}
|
||||
for _, ln := range stdin {
|
||||
if _, err := fmt.Fprint(b, ln); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cmd.Stdin = b
|
||||
}
|
||||
out, err := cmd.CombinedOutput() // execute and wait for output
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
|
||||
}
|
||||
slog.Debug("executed shell command",
|
||||
"command", commandWithInterfaceName,
|
||||
"output", string(out))
|
||||
return nil
|
||||
}
|
||||
@@ -1550,38 +1550,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/{id}/change-password": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Change the password for the given user.",
|
||||
"operationId": "users_handleChangePasswordPost",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/{id}/interfaces": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -2191,10 +2159,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserDisplayName": {
|
||||
"description": "the owner display name",
|
||||
"type": "string"
|
||||
},
|
||||
"UserIdentifier": {
|
||||
"description": "the owner",
|
||||
"type": "string"
|
||||
|
||||
@@ -322,9 +322,6 @@ definitions:
|
||||
allOf:
|
||||
- $ref: '#/definitions/model.ConfigOption-string'
|
||||
description: the routing table
|
||||
UserDisplayName:
|
||||
description: the owner display name
|
||||
type: string
|
||||
UserIdentifier:
|
||||
description: the owner
|
||||
type: string
|
||||
@@ -1445,27 +1442,6 @@ paths:
|
||||
summary: Enable the REST API for the given user.
|
||||
tags:
|
||||
- Users
|
||||
/user/{id}/change-password:
|
||||
post:
|
||||
operationId: users_handleChangePasswordPost
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.User'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/model.Error'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/model.Error'
|
||||
summary: Change the password for the given user.
|
||||
tags:
|
||||
- Users
|
||||
/user/{id}/interfaces:
|
||||
get:
|
||||
operationId: users_handleInterfacesGet
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"paths": {
|
||||
"/interface/all": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -47,16 +52,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/by-id/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -105,14 +110,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
],
|
||||
"description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -177,14 +182,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -236,16 +241,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/new": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -303,16 +308,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/prepare": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -347,16 +352,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-interface/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -405,16 +410,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-peer/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -463,16 +468,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-user/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -521,16 +526,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-id/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -580,14 +585,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
],
|
||||
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -652,14 +657,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -711,16 +716,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-interface/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -760,16 +765,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-user/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -810,16 +815,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/new": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -877,16 +882,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/prepare/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -942,16 +947,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-config": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"text/plain",
|
||||
@@ -1008,16 +1013,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-qr": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"image/png",
|
||||
@@ -1074,16 +1079,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/user-info": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1144,16 +1149,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/new-peer": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1211,16 +1216,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/all": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1251,16 +1256,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/by-id/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1310,14 +1315,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
],
|
||||
"description": "Only admins can update existing records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1382,14 +1387,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1441,16 +1446,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/new": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Only admins can create new records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1508,12 +1513,7 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2216,9 +2216,7 @@
|
||||
"description": "The source of the user. This field is optional.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"db",
|
||||
"ldap",
|
||||
"oauth"
|
||||
"db"
|
||||
],
|
||||
"example": "db"
|
||||
}
|
||||
|
||||
@@ -561,8 +561,6 @@ definitions:
|
||||
description: The source of the user. This field is optional.
|
||||
enum:
|
||||
- db
|
||||
- ldap
|
||||
- oauth
|
||||
example: db
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
@@ -157,37 +155,6 @@ 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"))))
|
||||
}
|
||||
|
||||
@@ -215,67 +182,3 @@ 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
@@ -72,44 +70,6 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
|
||||
return u.users.DeactivateApi(ctx, id)
|
||||
}
|
||||
|
||||
func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) {
|
||||
oldPassword = strings.TrimSpace(oldPassword)
|
||||
newPassword = strings.TrimSpace(newPassword)
|
||||
|
||||
if newPassword == "" {
|
||||
return nil, fmt.Errorf("new password must not be empty")
|
||||
}
|
||||
|
||||
// ensure that the new password is different from the old one
|
||||
if oldPassword == newPassword {
|
||||
return nil, fmt.Errorf("new password must be different from the old one")
|
||||
}
|
||||
|
||||
user, err := u.users.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// ensure that the user uses the database backend; otherwise we can't change the password
|
||||
if user.Source != domain.UserSourceDatabase {
|
||||
return nil, fmt.Errorf("user source %s does not support password changes", user.Source)
|
||||
}
|
||||
|
||||
// validate old password
|
||||
if user.CheckPassword(oldPassword) != nil {
|
||||
return nil, fmt.Errorf("current password is invalid")
|
||||
}
|
||||
|
||||
user.Password = domain.PrivateString(newPassword)
|
||||
|
||||
// ensure that the new password is strong enough
|
||||
if err := user.HasWeakPassword(u.cfg.Auth.MinPasswordLength); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u.users.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
|
||||
return u.wg.GetUserPeers(ctx, id)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ type UserService interface {
|
||||
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||
// DeactivateApi disables the API for the user with the given id.
|
||||
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||
// ChangePassword changes the password for the user with the given id.
|
||||
ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error)
|
||||
// GetUserPeers returns all peers for the given user.
|
||||
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
||||
// GetUserPeerStats returns all peer stats for the given user.
|
||||
@@ -77,7 +75,6 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost())
|
||||
}
|
||||
|
||||
// handleAllGet returns a gorm Handler function.
|
||||
@@ -394,68 +391,3 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
|
||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||
}
|
||||
}
|
||||
|
||||
// handleChangePasswordPost returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleChangePasswordPost
|
||||
// @Tags Users
|
||||
// @Summary Change the password for the given user.
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.User
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/{id}/change-password [post]
|
||||
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
||||
if userId == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
var passwordData struct {
|
||||
OldPassword string `json:"OldPassword"`
|
||||
Password string `json:"Password"`
|
||||
PasswordRepeat string `json:"PasswordRepeat"`
|
||||
}
|
||||
if err := request.BodyJson(r, &passwordData); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.OldPassword == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "old password missing"})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.Password == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "new password missing"})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.OldPassword == passwordData.Password {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "password did not change"})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.Password != passwordData.PasswordRepeat {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "password mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId),
|
||||
passwordData.OldPassword, passwordData.Password)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ type Peer struct {
|
||||
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
|
||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
|
||||
UserIdentifier string `json:"UserIdentifier"` // the owner
|
||||
UserDisplayName string `json:"UserDisplayName"` // the owner display name
|
||||
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
|
||||
Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down)
|
||||
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
|
||||
@@ -81,7 +80,7 @@ type Peer struct {
|
||||
}
|
||||
|
||||
func NewPeer(src *domain.Peer) *Peer {
|
||||
p := &Peer{
|
||||
return &Peer{
|
||||
Identifier: string(src.Identifier),
|
||||
DisplayName: src.DisplayName,
|
||||
UserIdentifier: string(src.UserIdentifier),
|
||||
@@ -112,12 +111,6 @@ func NewPeer(src *domain.Peer) *Peer {
|
||||
PostDown: ConfigOptionFromDomain(src.Interface.PostDown),
|
||||
Filename: src.GetConfigFileName(),
|
||||
}
|
||||
|
||||
if src.User != nil {
|
||||
p.UserDisplayName = src.User.DisplayName()
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func NewPeers(src []domain.Peer) []Peer {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ldap oauth" example:"db"`
|
||||
Source string `json:"Source" binding:"oneof=db" 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.
|
||||
|
||||
@@ -52,12 +52,8 @@ func Initialize(
|
||||
// Switch to admin user context
|
||||
startupContext = domain.SetUserInfo(startupContext, domain.SystemAdminContextUserInfo())
|
||||
|
||||
if !cfg.Core.AdminUserDisabled {
|
||||
if err := a.createDefaultUser(startupContext); err != nil {
|
||||
return fmt.Errorf("failed to create default user: %w", err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("Local Admin user disabled!")
|
||||
if err := a.createDefaultUser(startupContext); err != nil {
|
||||
return fmt.Errorf("failed to create default user: %w", err)
|
||||
}
|
||||
|
||||
if err := a.importNewInterfaces(startupContext); err != nil {
|
||||
|
||||
@@ -125,27 +125,11 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
|
||||
// It sets up the external authentication providers (OIDC, OAuth, LDAP) and retries in case of errors.
|
||||
func (a *Authenticator) StartBackgroundJobs(ctx context.Context) {
|
||||
go func() {
|
||||
slog.Debug("setting up external auth providers...")
|
||||
|
||||
// Initialize local copies of authentication providers to allow retry in case of errors
|
||||
oidcQueue := a.cfg.OpenIDConnect
|
||||
oauthQueue := a.cfg.OAuth
|
||||
ldapQueue := a.cfg.Ldap
|
||||
|
||||
// Immediate attempt
|
||||
failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue)
|
||||
if len(failedOidc) == 0 && len(failedOauth) == 0 && len(failedLdap) == 0 {
|
||||
slog.Info("successfully setup all external auth providers")
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare for retries with only the failed ones
|
||||
oidcQueue = failedOidc
|
||||
oauthQueue = failedOauth
|
||||
ldapQueue = failedLdap
|
||||
slog.Warn("failed to setup some external auth providers, retrying in 30 seconds",
|
||||
"failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap))
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second) // Ticker for delay between retries
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -374,15 +358,12 @@ func (a *Authenticator) passwordAuthentication(
|
||||
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrNotFound) {
|
||||
slog.Warn("failed to fetch ldap user info",
|
||||
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||
slog.Warn("failed to fetch ldap user info", "identifier", identifier, "error", err)
|
||||
}
|
||||
continue // user not found / other ldap error
|
||||
}
|
||||
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse ldap user info",
|
||||
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -395,14 +376,10 @@ func (a *Authenticator) passwordAuthentication(
|
||||
}
|
||||
|
||||
if userSource == "" {
|
||||
slog.Warn("no user source found for user",
|
||||
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if userSource == domain.UserSourceLdap && ldapProvider == nil {
|
||||
slog.Warn("no ldap provider found for user",
|
||||
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
||||
return nil, errors.New("ldap provider not found")
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,18 @@ type LdapAuthenticator struct {
|
||||
}
|
||||
|
||||
func newLdapAuthenticator(_ context.Context, cfg *config.LdapProvider) (*LdapAuthenticator, error) {
|
||||
return &LdapAuthenticator{cfg: cfg}, nil
|
||||
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
|
||||
}
|
||||
|
||||
// GetName returns the name of the LDAP authenticator.
|
||||
@@ -43,7 +54,7 @@ func (l LdapAuthenticator) PlaintextAuthentication(userId domain.UserIdentifier,
|
||||
|
||||
attrs := []string{"dn"}
|
||||
|
||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1)
|
||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
l.cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
||||
@@ -89,7 +100,7 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
|
||||
|
||||
attrs := internal.LdapSearchAttributes(&l.cfg.FieldMap)
|
||||
|
||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1)
|
||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
l.cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
||||
@@ -102,13 +113,10 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 0 {
|
||||
slog.Debug("LDAP user not found", "source", l.GetName(), "userId", userId, "filter", loginFilter)
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
|
||||
if len(sr.Entries) > 1 {
|
||||
slog.Debug("LDAP user not unique",
|
||||
"source", l.GetName(), "userId", userId, "filter", loginFilter, "entries", len(sr.Entries))
|
||||
return nil, domain.ErrNotUnique
|
||||
}
|
||||
|
||||
@@ -143,3 +151,40 @@ 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
|
||||
}
|
||||
|
||||
@@ -19,16 +19,15 @@ import (
|
||||
// PlainOauthAuthenticator is an authenticator that uses OAuth for authentication.
|
||||
// User information is retrieved from the specified user info endpoint.
|
||||
type PlainOauthAuthenticator struct {
|
||||
name string
|
||||
cfg *oauth2.Config
|
||||
userInfoEndpoint string
|
||||
client *http.Client
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
sensitiveInfoLogging bool
|
||||
allowedDomains []string
|
||||
name string
|
||||
cfg *oauth2.Config
|
||||
userInfoEndpoint string
|
||||
client *http.Client
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
allowedDomains []string
|
||||
}
|
||||
|
||||
func newPlainOauthAuthenticator(
|
||||
@@ -58,7 +57,6 @@ func newPlainOauthAuthenticator(
|
||||
provider.userAdminMapping = &cfg.AdminMapping
|
||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||
provider.userInfoLogging = cfg.LogUserInfo
|
||||
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
|
||||
provider.allowedDomains = cfg.AllowedDomains
|
||||
|
||||
return provider, nil
|
||||
@@ -112,10 +110,6 @@ func (p PlainOauthAuthenticator) GetUserInfo(
|
||||
|
||||
response, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
if p.sensitiveInfoLogging {
|
||||
slog.Debug("OAuth: failed to get user info", "endpoint", p.userInfoEndpoint,
|
||||
"token", token, "error", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
defer internal.LogClose(response.Body)
|
||||
@@ -127,15 +121,11 @@ func (p PlainOauthAuthenticator) GetUserInfo(
|
||||
var userFields map[string]any
|
||||
err = json.Unmarshal(contents, &userFields)
|
||||
if err != nil {
|
||||
if p.sensitiveInfoLogging {
|
||||
slog.Debug("OAuth: failed to parse user info", "endpoint", p.userInfoEndpoint,
|
||||
"token", token, "contents", contents, "error", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse user info: %w", err)
|
||||
}
|
||||
|
||||
if p.userInfoLogging {
|
||||
slog.Debug("OAuth: user info debug",
|
||||
slog.Debug("OAuth user info",
|
||||
"source", p.name,
|
||||
"info", string(contents))
|
||||
}
|
||||
|
||||
@@ -16,16 +16,15 @@ import (
|
||||
|
||||
// OidcAuthenticator is an authenticator for OpenID Connect providers.
|
||||
type OidcAuthenticator struct {
|
||||
name string
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cfg *oauth2.Config
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
sensitiveInfoLogging bool
|
||||
allowedDomains []string
|
||||
name string
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cfg *oauth2.Config
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
allowedDomains []string
|
||||
}
|
||||
|
||||
func newOidcAuthenticator(
|
||||
@@ -59,7 +58,6 @@ func newOidcAuthenticator(
|
||||
provider.userAdminMapping = &cfg.AdminMapping
|
||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||
provider.userInfoLogging = cfg.LogUserInfo
|
||||
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
|
||||
provider.allowedDomains = cfg.AllowedDomains
|
||||
|
||||
return provider, nil
|
||||
@@ -104,40 +102,24 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
|
||||
) {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
if o.sensitiveInfoLogging {
|
||||
slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce)
|
||||
}
|
||||
return nil, errors.New("token does not contain id_token")
|
||||
}
|
||||
idToken, err := o.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
if o.sensitiveInfoLogging {
|
||||
slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce,
|
||||
"error",
|
||||
err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to validate id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != nonce {
|
||||
if o.sensitiveInfoLogging {
|
||||
slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce)
|
||||
}
|
||||
return nil, errors.New("nonce mismatch")
|
||||
}
|
||||
|
||||
var tokenFields map[string]any
|
||||
if err = idToken.Claims(&tokenFields); err != nil {
|
||||
if o.sensitiveInfoLogging {
|
||||
slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce,
|
||||
"error",
|
||||
err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse extra claims: %w", err)
|
||||
}
|
||||
|
||||
if o.userInfoLogging {
|
||||
contents, _ := json.Marshal(tokenFields)
|
||||
slog.Debug("OIDC: user info debug",
|
||||
slog.Debug("OIDC user info",
|
||||
"source", o.name,
|
||||
"info", string(contents))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
@@ -72,7 +71,7 @@ func NewMailManager(
|
||||
users UserDatabaseRepo,
|
||||
wg WireguardDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle, cfg.Mail.TemplatesPath)
|
||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
|
||||
}
|
||||
@@ -102,15 +101,29 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
|
||||
}
|
||||
|
||||
if peer.UserIdentifier == "" {
|
||||
return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
|
||||
slog.Debug("skipping peer email",
|
||||
"peer", peerId,
|
||||
"reason", "no user linked")
|
||||
continue
|
||||
}
|
||||
|
||||
email, user := m.resolveEmail(ctx, peer)
|
||||
if email == "" {
|
||||
return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId)
|
||||
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
||||
if err != nil {
|
||||
slog.Debug("skipping peer email",
|
||||
"peer", peerId,
|
||||
"reason", "unable to fetch user",
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
|
||||
if user.Email == "" {
|
||||
slog.Debug("skipping peer email",
|
||||
"peer", peerId,
|
||||
"reason", "user has no mail address")
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
||||
}
|
||||
@@ -181,37 +194,3 @@ func (m Manager) sendPeerEmail(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
|
||||
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
||||
if err != nil {
|
||||
if m.cfg.Mail.AllowPeerEmail {
|
||||
_, err := mail.ParseAddress(string(peer.UserIdentifier)) // test if the user identifier is a valid email address
|
||||
if err == nil {
|
||||
slog.Debug("peer email: using user-identifier as email",
|
||||
"peer", peer.Identifier, "email", peer.UserIdentifier)
|
||||
return string(peer.UserIdentifier), domain.User{}
|
||||
} else {
|
||||
slog.Debug("peer email: skipping peer email",
|
||||
"peer", peer.Identifier,
|
||||
"reason", "peer has no user linked and user-identifier is not a valid email address")
|
||||
return "", domain.User{}
|
||||
}
|
||||
} else {
|
||||
slog.Debug("peer email: skipping peer email",
|
||||
"peer", peer.Identifier,
|
||||
"reason", "user has no user linked")
|
||||
return "", domain.User{}
|
||||
}
|
||||
}
|
||||
|
||||
if user.Email == "" {
|
||||
slog.Debug("peer email: skipping peer email",
|
||||
"peer", peer.Identifier,
|
||||
"reason", "user has no mail address")
|
||||
return "", domain.User{}
|
||||
}
|
||||
|
||||
slog.Debug("peer email: using user email", "peer", peer.Identifier, "email", user.Email)
|
||||
return user.Email, *user
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import (
|
||||
"fmt"
|
||||
htmlTemplate "html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
@@ -26,50 +22,15 @@ type TemplateHandler struct {
|
||||
textTemplates *template.Template
|
||||
}
|
||||
|
||||
func newTemplateHandler(portalUrl, portalName string, basePath string) (*TemplateHandler, error) {
|
||||
// Always parse embedded defaults first
|
||||
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
|
||||
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse embedded html template files: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse 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 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)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse text template files: %w", err)
|
||||
}
|
||||
|
||||
handler := &TemplateHandler{
|
||||
@@ -82,71 +43,24 @@ func newTemplateHandler(portalUrl, portalName string, basePath string) (*Templat
|
||||
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,
|
||||
"PortalName": c.portalName,
|
||||
"User": user,
|
||||
"Link": link,
|
||||
"PortalUrl": c.portalUrl,
|
||||
})
|
||||
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,
|
||||
"PortalName": c.portalName,
|
||||
"User": user,
|
||||
"Link": link,
|
||||
"PortalUrl": c.portalUrl,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)
|
||||
|
||||
@@ -4,23 +4,25 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
// region dependencies
|
||||
|
||||
type ControllerManager interface {
|
||||
// GetController returns the controller for the given interface.
|
||||
GetController(iface domain.Interface) domain.InterfaceController
|
||||
}
|
||||
|
||||
type InterfaceAndPeerDatabaseRepo interface {
|
||||
// GetInterface returns the interface with the given identifier.
|
||||
GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
|
||||
// GetAllInterfaces returns all interfaces
|
||||
GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
|
||||
// GetInterfacePeers returns all peers for a given interface
|
||||
GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error)
|
||||
}
|
||||
|
||||
type EventBus interface {
|
||||
@@ -28,13 +30,6 @@ type EventBus interface {
|
||||
Subscribe(topic string, fn interface{}) error
|
||||
}
|
||||
|
||||
type RoutesController interface {
|
||||
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error
|
||||
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error
|
||||
}
|
||||
|
||||
// endregion dependencies
|
||||
|
||||
type routeRuleInfo struct {
|
||||
@@ -50,27 +45,28 @@ type routeRuleInfo struct {
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wgController ControllerManager
|
||||
|
||||
mux *sync.Mutex
|
||||
bus EventBus
|
||||
wg lowlevel.WireGuardClient
|
||||
nl lowlevel.NetlinkClient
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
}
|
||||
|
||||
// NewRouteManager creates a new route manager instance.
|
||||
func NewRouteManager(
|
||||
cfg *config.Config,
|
||||
bus EventBus,
|
||||
db InterfaceAndPeerDatabaseRepo,
|
||||
wgController ControllerManager,
|
||||
) (*Manager, error) {
|
||||
func NewRouteManager(cfg *config.Config, bus EventBus, db InterfaceAndPeerDatabaseRepo) (*Manager, error) {
|
||||
wg, err := wgctrl.New()
|
||||
if err != nil {
|
||||
panic("failed to init wgctrl: " + err.Error())
|
||||
}
|
||||
|
||||
nl := &lowlevel.NetlinkManager{}
|
||||
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
bus: bus,
|
||||
|
||||
db: db,
|
||||
wgController: wgController,
|
||||
mux: &sync.Mutex{},
|
||||
db: db,
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
}
|
||||
|
||||
m.connectToMessageBus()
|
||||
@@ -89,82 +85,419 @@ func (m Manager) StartBackgroundJobs(_ context.Context) {
|
||||
// this is a no-op for now
|
||||
}
|
||||
|
||||
func (m Manager) handleRouteUpdateEvent(info domain.RoutingTableInfo) {
|
||||
m.mux.Lock() // ensure that only one route update is processed at a time
|
||||
defer m.mux.Unlock()
|
||||
func (m Manager) handleRouteUpdateEvent(srcDescription string) {
|
||||
slog.Debug("handling route update event", "source", srcDescription)
|
||||
|
||||
slog.Debug("handling route update event", "info", info.String())
|
||||
|
||||
if !info.ManagementEnabled() {
|
||||
return // route management disabled
|
||||
}
|
||||
|
||||
err := m.syncRoutes(context.Background(), info)
|
||||
err := m.syncRoutes(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("failed to synchronize routes",
|
||||
"info", info.String(), "error", err)
|
||||
return
|
||||
"source", srcDescription,
|
||||
"error", err)
|
||||
}
|
||||
|
||||
slog.Debug("routes synchronized", "info", info.String())
|
||||
slog.Debug("routes synchronized", "source", srcDescription)
|
||||
}
|
||||
|
||||
func (m Manager) handleRouteRemoveEvent(info domain.RoutingTableInfo) {
|
||||
m.mux.Lock() // ensure that only one route update is processed at a time
|
||||
defer m.mux.Unlock()
|
||||
|
||||
slog.Debug("handling route remove event", "info", info.String())
|
||||
|
||||
if !info.ManagementEnabled() {
|
||||
return // route management disabled
|
||||
}
|
||||
|
||||
err := m.removeRoutes(context.Background(), info)
|
||||
if err != nil {
|
||||
slog.Error("failed to synchronize routes",
|
||||
"info", info.String(), "error", err)
|
||||
return
|
||||
if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V4); err != nil {
|
||||
slog.Error("failed to remove v4 fwmark rules", "error", err)
|
||||
}
|
||||
if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V6); err != nil {
|
||||
slog.Error("failed to remove v6 fwmark rules", "error", err)
|
||||
}
|
||||
|
||||
slog.Debug("routes removed", "info", info.String())
|
||||
slog.Debug("routes removed", "table", info.String())
|
||||
}
|
||||
|
||||
func (m Manager) syncRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
|
||||
rc, ok := m.wgController.GetController(info.Interface).(RoutesController)
|
||||
if !ok {
|
||||
slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.Interface.ManageRoutingTable() {
|
||||
slog.Debug("interface does not manage routing table, skipping route update",
|
||||
"interface", info.Interface.Identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := rc.SetRoutes(ctx, info)
|
||||
func (m Manager) syncRoutes(ctx context.Context) error {
|
||||
interfaces, err := m.db.GetAllInterfaces(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set routes for interface %s: %w", info.Interface.Identifier, err)
|
||||
return fmt.Errorf("failed to find all interfaces: %w", err)
|
||||
}
|
||||
|
||||
rules := map[int][]routeRuleInfo{
|
||||
netlink.FAMILY_V4: nil,
|
||||
netlink.FAMILY_V6: nil,
|
||||
}
|
||||
for _, iface := range interfaces {
|
||||
if iface.IsDisabled() {
|
||||
continue // disabled interface does not need route entries
|
||||
}
|
||||
if !iface.ManageRoutingTable() {
|
||||
continue
|
||||
}
|
||||
|
||||
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find peers for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
allowedIPs := iface.GetAllowedIPs(peers)
|
||||
defRouteV4, defRouteV6 := m.containsDefaultRoute(allowedIPs)
|
||||
|
||||
link, err := m.nl.LinkByName(string(iface.Identifier))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find physical link for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
table, fwmark, err := m.getRoutingTableAndFwMark(&iface, link)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get table and fwmark for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
if err := m.setInterfaceRoutes(link, table, allowedIPs); err != nil {
|
||||
return fmt.Errorf("failed to set routes for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V4, allowedIPs); err != nil {
|
||||
return fmt.Errorf("failed to remove deprecated v4 routes for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V6, allowedIPs); err != nil {
|
||||
return fmt.Errorf("failed to remove deprecated v6 routes for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
if table != 0 {
|
||||
rules[netlink.FAMILY_V4] = append(rules[netlink.FAMILY_V4], routeRuleInfo{
|
||||
ifaceId: iface.Identifier,
|
||||
fwMark: fwmark,
|
||||
table: table,
|
||||
family: netlink.FAMILY_V4,
|
||||
hasDefault: defRouteV4,
|
||||
})
|
||||
}
|
||||
if table != 0 {
|
||||
rules[netlink.FAMILY_V6] = append(rules[netlink.FAMILY_V6], routeRuleInfo{
|
||||
ifaceId: iface.Identifier,
|
||||
fwMark: fwmark,
|
||||
table: table,
|
||||
family: netlink.FAMILY_V6,
|
||||
hasDefault: defRouteV6,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return m.syncRouteRules(rules)
|
||||
}
|
||||
|
||||
func (m Manager) syncRouteRules(allRules map[int][]routeRuleInfo) error {
|
||||
for family, rules := range allRules {
|
||||
// update fwmark rules
|
||||
if err := m.setFwMarkRules(rules, family); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update main rule
|
||||
if err := m.setMainRule(rules, family); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanup old main rules
|
||||
if err := m.cleanupMainRule(rules, family); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) setFwMarkRules(rules []routeRuleInfo, family int) error {
|
||||
for _, rule := range rules {
|
||||
existingRules, err := m.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
|
||||
}
|
||||
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if rule.fwMark == existingRule.Mark && rule.table == existingRule.Table {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ruleExists {
|
||||
continue // rule already exists, no need to recreate it
|
||||
}
|
||||
|
||||
// create missing rule
|
||||
if err := m.nl.RuleAdd(&netlink.Rule{
|
||||
Family: family,
|
||||
Table: rule.table,
|
||||
Mark: rule.fwMark,
|
||||
Invert: true,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: -1,
|
||||
Priority: m.getRulePriority(existingRules),
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup rule for fwmark %d and table %d: %w", rule.fwMark, rule.table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) removeRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
|
||||
rc, ok := m.wgController.GetController(info.Interface).(RoutesController)
|
||||
if !ok {
|
||||
slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.Interface.ManageRoutingTable() {
|
||||
slog.Debug("interface does not manage routing table, skipping route removal",
|
||||
"interface", info.Interface.Identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := rc.RemoveRoutes(ctx, info)
|
||||
func (m Manager) removeFwMarkRules(fwmark uint32, table int, family int) error {
|
||||
existingRules, err := m.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove routes for interface %s: %w", info.Interface.Identifier, err)
|
||||
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
|
||||
}
|
||||
|
||||
for _, existingRule := range existingRules {
|
||||
if fwmark == existingRule.Mark && table == existingRule.Table {
|
||||
existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
|
||||
if err := m.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete fwmark rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) setMainRule(rules []routeRuleInfo, family int) error {
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
if rule.hasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingRules, err := m.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
|
||||
}
|
||||
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ruleExists {
|
||||
return nil // rule already exists, skip re-creation
|
||||
}
|
||||
|
||||
if err := m.nl.RuleAdd(&netlink.Rule{
|
||||
Family: family,
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: 0,
|
||||
Priority: m.getMainRulePriority(existingRules),
|
||||
Mark: 0,
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup rule for main table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) cleanupMainRule(rules []routeRuleInfo, family int) error {
|
||||
existingRules, err := m.nl.RuleList(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
|
||||
}
|
||||
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
if rule.hasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mainRules := 0
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
mainRules++
|
||||
}
|
||||
}
|
||||
|
||||
removalCount := 0
|
||||
if mainRules > 1 {
|
||||
removalCount = mainRules - 1 // we only want one single rule
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
removalCount = mainRules
|
||||
}
|
||||
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
if removalCount > 0 {
|
||||
existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
|
||||
if err := m.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete main rule: %w", err)
|
||||
}
|
||||
removalCount--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) getMainRulePriority(existingRules []netlink.Rule) int {
|
||||
prio := m.cfg.Advanced.RulePrioOffset
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == prio {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
prio++
|
||||
}
|
||||
}
|
||||
return prio
|
||||
}
|
||||
|
||||
func (m Manager) getRulePriority(existingRules []netlink.Rule) int {
|
||||
prio := 32700 // linux main rule has a prio of 32766
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == prio {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
prio--
|
||||
}
|
||||
}
|
||||
return prio
|
||||
}
|
||||
|
||||
func (m Manager) setInterfaceRoutes(link netlink.Link, table int, allowedIPs []domain.Cidr) error {
|
||||
for _, allowedIP := range allowedIPs {
|
||||
err := m.nl.RouteReplace(&netlink.Route{
|
||||
LinkIndex: link.Attrs().Index,
|
||||
Dst: allowedIP.IpNet(),
|
||||
Table: table,
|
||||
Scope: unix.RT_SCOPE_LINK,
|
||||
Type: unix.RTN_UNICAST,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add/update route %s: %w", allowedIP.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) removeDeprecatedRoutes(link netlink.Link, family int, allowedIPs []domain.Cidr) error {
|
||||
rawRoutes, err := m.nl.RouteListFiltered(family, &netlink.Route{
|
||||
LinkIndex: link.Attrs().Index,
|
||||
Table: unix.RT_TABLE_UNSPEC, // all tables
|
||||
Scope: unix.RT_SCOPE_LINK,
|
||||
Type: unix.RTN_UNICAST,
|
||||
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch raw routes: %w", err)
|
||||
}
|
||||
for _, rawRoute := range rawRoutes {
|
||||
if rawRoute.Dst == nil { // handle default route
|
||||
var netlinkAddr domain.Cidr
|
||||
if family == netlink.FAMILY_V4 {
|
||||
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
|
||||
} else {
|
||||
netlinkAddr, _ = domain.CidrFromString("::/0")
|
||||
}
|
||||
rawRoute.Dst = netlinkAddr.IpNet()
|
||||
}
|
||||
|
||||
netlinkAddr := domain.CidrFromIpNet(*rawRoute.Dst)
|
||||
remove := true
|
||||
for _, allowedIP := range allowedIPs {
|
||||
if netlinkAddr == allowedIP {
|
||||
remove = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !remove {
|
||||
continue
|
||||
}
|
||||
|
||||
err := m.nl.RouteDel(&rawRoute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove deprecated route %s: %w", netlinkAddr.String(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) getRoutingTableAndFwMark(iface *domain.Interface, link netlink.Link) (
|
||||
table int,
|
||||
fwmark uint32,
|
||||
err error,
|
||||
) {
|
||||
table = iface.GetRoutingTable()
|
||||
fwmark = iface.FirewallMark
|
||||
|
||||
if fwmark == 0 {
|
||||
// generate a new (temporary) firewall mark based on the interface index
|
||||
fwmark = uint32(m.cfg.Advanced.RouteTableOffset + link.Attrs().Index)
|
||||
slog.Debug("using fwmark to handle routes",
|
||||
"interface", iface.Identifier,
|
||||
"fwmark", fwmark)
|
||||
|
||||
// apply the temporary fwmark to the wireguard interface
|
||||
err = m.setFwMark(iface.Identifier, int(fwmark))
|
||||
}
|
||||
if table == 0 {
|
||||
table = int(fwmark) // generate a new routing table base on interface index
|
||||
slog.Debug("using routing table to handle default routes",
|
||||
"interface", iface.Identifier,
|
||||
"table", table)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m Manager) setFwMark(id domain.InterfaceIdentifier, fwmark int) error {
|
||||
err := m.wg.ConfigureDevice(string(id), wgtypes.Config{
|
||||
FirewallMark: &fwmark,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update fwmark to: %d: %w", fwmark, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) containsDefaultRoute(allowedIPs []domain.Cidr) (ipV4, ipV6 bool) {
|
||||
for _, allowedIP := range allowedIPs {
|
||||
if ipV4 && ipV6 {
|
||||
break // speed up
|
||||
}
|
||||
|
||||
if allowedIP.Prefix().Bits() == 0 {
|
||||
if allowedIP.IsV4() {
|
||||
ipV4 = true
|
||||
} else {
|
||||
ipV6 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -551,12 +551,6 @@ 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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -11,9 +12,33 @@ import (
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type InterfaceController interface {
|
||||
GetId() domain.InterfaceBackend
|
||||
GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error)
|
||||
GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error)
|
||||
GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error)
|
||||
SaveInterface(
|
||||
_ context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error
|
||||
DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error
|
||||
SavePeer(
|
||||
_ context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error
|
||||
DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error
|
||||
PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error)
|
||||
}
|
||||
|
||||
type backendInstance struct {
|
||||
Config config.BackendBase // Config is the configuration for the backend instance.
|
||||
Implementation domain.InterfaceController
|
||||
Implementation InterfaceController
|
||||
}
|
||||
|
||||
type ControllerManager struct {
|
||||
@@ -44,10 +69,6 @@ func (c *ControllerManager) init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.registerPfsenseControllers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.logRegisteredControllers()
|
||||
|
||||
return nil
|
||||
@@ -61,9 +82,8 @@ func (c *ControllerManager) registerLocalController() error {
|
||||
|
||||
c.controllers[config.LocalBackendName] = backendInstance{
|
||||
Config: config.BackendBase{
|
||||
Id: config.LocalBackendName,
|
||||
DisplayName: "Local WireGuard Controller",
|
||||
IgnoredInterfaces: c.cfg.Backend.IgnoredLocalInterfaces,
|
||||
Id: config.LocalBackendName,
|
||||
DisplayName: "Local WireGuard Controller",
|
||||
},
|
||||
Implementation: localController,
|
||||
}
|
||||
@@ -90,26 +110,6 @@ 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",
|
||||
@@ -117,18 +117,18 @@ func (c *ControllerManager) logRegisteredControllers() {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) domain.InterfaceController {
|
||||
return c.getController(backend, "").Implementation
|
||||
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController {
|
||||
return c.getController(backend, "")
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetController(iface domain.Interface) domain.InterfaceController {
|
||||
return c.getController(iface.Backend, iface.Identifier).Implementation
|
||||
func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController {
|
||||
return c.getController(iface.Backend, iface.Identifier)
|
||||
}
|
||||
|
||||
func (c *ControllerManager) getController(
|
||||
backend domain.InterfaceBackend,
|
||||
ifaceId domain.InterfaceIdentifier,
|
||||
) backendInstance {
|
||||
) InterfaceController {
|
||||
if backend == "" {
|
||||
// If no backend is specified, use the local controller.
|
||||
// This might be the case for interfaces created in previous WireGuard Portal versions.
|
||||
@@ -145,13 +145,13 @@ func (c *ControllerManager) getController(
|
||||
slog.Warn("controller for backend not found, using local controller",
|
||||
"backend", backend, "interface", ifaceId)
|
||||
}
|
||||
return controller
|
||||
return controller.Implementation
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetAllControllers() []backendInstance {
|
||||
var backendInstances = make([]backendInstance, 0, len(c.controllers))
|
||||
func (c *ControllerManager) GetAllControllers() []InterfaceController {
|
||||
var backendInstances = make([]InterfaceController, 0, len(c.controllers))
|
||||
for instance := range maps.Values(c.controllers) {
|
||||
backendInstances = append(backendInstances, instance)
|
||||
backendInstances = append(backendInstances, instance.Implementation)
|
||||
}
|
||||
return backendInstances
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ type InterfaceAndPeerDatabaseRepo interface {
|
||||
}
|
||||
|
||||
type WgQuickController interface {
|
||||
ExecuteInterfaceHook(ctx context.Context, id domain.InterfaceIdentifier, hookCmd string) error
|
||||
SetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
UnsetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error
|
||||
SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
UnsetDNS(id domain.InterfaceIdentifier) error
|
||||
}
|
||||
|
||||
type EventBus interface {
|
||||
@@ -53,10 +53,11 @@ type EventBus interface {
|
||||
// endregion dependencies
|
||||
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wg *ControllerManager
|
||||
cfg *config.Config
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wg *ControllerManager
|
||||
quick WgQuickController
|
||||
|
||||
userLockMap *sync.Map
|
||||
}
|
||||
@@ -65,6 +66,7 @@ func NewWireGuardManager(
|
||||
cfg *config.Config,
|
||||
bus EventBus,
|
||||
wg *ControllerManager,
|
||||
quick WgQuickController,
|
||||
db InterfaceAndPeerDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
m := &Manager{
|
||||
@@ -72,6 +74,7 @@ func NewWireGuardManager(
|
||||
bus: bus,
|
||||
wg: wg,
|
||||
db: db,
|
||||
quick: quick,
|
||||
userLockMap: &sync.Map{},
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
@@ -16,6 +15,26 @@ import (
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// GetImportableInterfaces returns all physical interfaces that are available on the system.
|
||||
// This function also returns interfaces that are already available in the database.
|
||||
func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allPhysicalInterfaces []domain.PhysicalInterface
|
||||
for _, wgBackend := range m.wg.GetAllControllers() {
|
||||
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allPhysicalInterfaces = append(allPhysicalInterfaces, physicalInterfaces...)
|
||||
}
|
||||
|
||||
return allPhysicalInterfaces, nil
|
||||
}
|
||||
|
||||
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
|
||||
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.Interface,
|
||||
@@ -91,62 +110,52 @@ func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier)
|
||||
}
|
||||
|
||||
// ImportNewInterfaces imports all new physical interfaces that are available on the system.
|
||||
// If a filter is set, only interfaces that match the filter will be imported.
|
||||
func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) (int, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var existingInterfaceIds []domain.InterfaceIdentifier
|
||||
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, existingInterface := range existingInterfaces {
|
||||
existingInterfaceIds = append(existingInterfaceIds, existingInterface.Identifier)
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, wgBackend := range m.wg.GetAllControllers() {
|
||||
physicalInterfaces, err := wgBackend.Implementation.GetInterfaces(ctx)
|
||||
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// if no filter is given, exclude already existing interfaces
|
||||
var excludedInterfaces []domain.InterfaceIdentifier
|
||||
if len(filter) == 0 {
|
||||
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, existingInterface := range existingInterfaces {
|
||||
excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
for _, physicalInterface := range physicalInterfaces {
|
||||
if slices.Contains(wgBackend.Config.IgnoredInterfaces, string(physicalInterface.Identifier)) {
|
||||
slog.Info("ignoring interface due to backend filter restrictions",
|
||||
"interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces,
|
||||
"backend", wgBackend.Config.Id)
|
||||
continue // skip ignored interfaces
|
||||
}
|
||||
|
||||
if slices.Contains(existingInterfaceIds, physicalInterface.Identifier) {
|
||||
continue // skip interfaces that already exist
|
||||
}
|
||||
|
||||
if len(filter) > 0 && !slices.Contains(filter, physicalInterface.Identifier) {
|
||||
slog.Info("ignoring interface due to filter restrictions",
|
||||
"interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces,
|
||||
"backend", wgBackend.Config.Id)
|
||||
if slices.Contains(excludedInterfaces, physicalInterface.Identifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("importing new interface",
|
||||
"interface", physicalInterface.Identifier, "backend", wgBackend.Config.Id)
|
||||
if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
physicalPeers, err := wgBackend.Implementation.GetPeers(ctx, physicalInterface.Identifier)
|
||||
slog.Info("importing new interface", "interface", physicalInterface.Identifier)
|
||||
|
||||
physicalPeers, err := wgBackend.GetPeers(ctx, physicalInterface.Identifier)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
err = m.importInterface(ctx, wgBackend.Implementation, &physicalInterface, physicalPeers)
|
||||
err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
|
||||
}
|
||||
|
||||
slog.Info("imported new interface",
|
||||
"interface", physicalInterface.Identifier, "peers", len(physicalPeers), "backend", wgBackend.Config.Id)
|
||||
slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers))
|
||||
imported++
|
||||
}
|
||||
}
|
||||
@@ -212,11 +221,9 @@ func (m Manager) RestoreInterfaceState(
|
||||
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
controller := m.wg.GetController(iface)
|
||||
|
||||
_, err = controller.GetInterface(ctx, iface.Identifier)
|
||||
_, err = m.wg.GetController(iface).GetInterface(ctx, iface.Identifier)
|
||||
if err != nil && !iface.IsDisabled() {
|
||||
slog.Debug("creating missing interface", "interface", iface.Identifier, "backend", controller.GetId())
|
||||
slog.Debug("creating missing interface", "interface", iface.Identifier)
|
||||
|
||||
// temporarily disable interface in database so that the current state is reflected correctly
|
||||
_ = m.db.SaveInterface(ctx, iface.Identifier,
|
||||
@@ -243,8 +250,7 @@ func (m Manager) RestoreInterfaceState(
|
||||
return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("restoring interface state",
|
||||
"interface", iface.Identifier, "disabled", iface.IsDisabled(), "backend", controller.GetId())
|
||||
slog.Debug("restoring interface state", "interface", iface.Identifier, "disabled", iface.IsDisabled())
|
||||
|
||||
// try to move interface to stored state
|
||||
_, err = m.saveInterface(ctx, &iface)
|
||||
@@ -272,13 +278,13 @@ func (m Manager) RestoreInterfaceState(
|
||||
for _, peer := range peers {
|
||||
switch {
|
||||
case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers
|
||||
if err := controller.DeletePeer(ctx, iface.Identifier,
|
||||
if err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier,
|
||||
peer.Identifier); err != nil {
|
||||
return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w",
|
||||
peer.Identifier, iface.Identifier, err)
|
||||
}
|
||||
default: // update peer
|
||||
err := controller.SavePeer(ctx, iface.Identifier, peer.Identifier,
|
||||
err := m.wg.GetController(iface).SavePeer(ctx, iface.Identifier, peer.Identifier,
|
||||
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
|
||||
domain.MergeToPhysicalPeer(pp, &peer)
|
||||
return pp, nil
|
||||
@@ -291,7 +297,7 @@ func (m Manager) RestoreInterfaceState(
|
||||
}
|
||||
|
||||
// remove non-wgportal peers
|
||||
physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier)
|
||||
physicalPeers, _ := m.wg.GetController(iface).GetPeers(ctx, iface.Identifier)
|
||||
for _, physicalPeer := range physicalPeers {
|
||||
isWgPortalPeer := false
|
||||
for _, peer := range peers {
|
||||
@@ -301,7 +307,7 @@ func (m Manager) RestoreInterfaceState(
|
||||
}
|
||||
}
|
||||
if !isWgPortalPeer {
|
||||
err := controller.DeletePeer(ctx, iface.Identifier,
|
||||
err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier,
|
||||
domain.PeerIdentifier(physicalPeer.PublicKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w",
|
||||
@@ -454,7 +460,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
||||
return err
|
||||
}
|
||||
|
||||
existingInterface, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
|
||||
existingInterface, err := m.db.GetInterface(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find interface %s: %w", id, err)
|
||||
}
|
||||
@@ -463,29 +469,21 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
||||
return fmt.Errorf("deletion not allowed: %w", err)
|
||||
}
|
||||
|
||||
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
|
||||
Interface: *existingInterface,
|
||||
AllowedIps: existingInterface.GetAllowedIPs(existingPeers),
|
||||
FwMark: existingInterface.FirewallMark,
|
||||
Table: existingInterface.GetRoutingTable(),
|
||||
TableStr: existingInterface.RoutingTable,
|
||||
IsDeleted: true,
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
existingInterface.Disabled = &now // simulate a disabled interface
|
||||
existingInterface.DisabledReason = domain.DisabledReasonDeleted
|
||||
|
||||
if err := m.handleInterfacePreSaveHooks(ctx, existingInterface, !existingInterface.IsDisabled(),
|
||||
false); err != nil {
|
||||
physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id)
|
||||
|
||||
if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
|
||||
return fmt.Errorf("pre-delete hooks failed: %w", err)
|
||||
}
|
||||
|
||||
if err := m.handleInterfacePreSaveActions(ctx, existingInterface); err != nil {
|
||||
if err := m.handleInterfacePreSaveActions(existingInterface); err != nil {
|
||||
return fmt.Errorf("pre-delete actions failed: %w", err)
|
||||
}
|
||||
|
||||
if err := m.deleteInterfacePeers(ctx, existingInterface, existingPeers); err != nil {
|
||||
if err := m.deleteInterfacePeers(ctx, id); err != nil {
|
||||
return fmt.Errorf("peer deletion failure: %w", err)
|
||||
}
|
||||
|
||||
@@ -497,12 +495,16 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
||||
return fmt.Errorf("deletion failure: %w", err)
|
||||
}
|
||||
|
||||
if err := m.handleInterfacePostSaveHooks(
|
||||
ctx,
|
||||
existingInterface,
|
||||
!existingInterface.IsDisabled(),
|
||||
false,
|
||||
); err != nil {
|
||||
fwMark := existingInterface.FirewallMark
|
||||
if physicalInterface != nil && fwMark == 0 {
|
||||
fwMark = physicalInterface.FirewallMark
|
||||
}
|
||||
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
|
||||
FwMark: fwMark,
|
||||
Table: existingInterface.GetRoutingTable(),
|
||||
})
|
||||
|
||||
if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
|
||||
return fmt.Errorf("post-delete hooks failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -521,21 +523,17 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
return nil, fmt.Errorf("interface validation failed: %w", err)
|
||||
}
|
||||
|
||||
oldEnabled, newEnabled, routeTableChanged := false, !iface.IsDisabled(), false // if the interface did not exist, we assume it was not enabled
|
||||
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
|
||||
if err == nil {
|
||||
oldEnabled, newEnabled, routeTableChanged = m.getInterfaceStateHistory(oldInterface, iface)
|
||||
}
|
||||
oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface)
|
||||
|
||||
if err := m.handleInterfacePreSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
|
||||
if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil {
|
||||
return nil, fmt.Errorf("pre-save hooks failed: %w", err)
|
||||
}
|
||||
|
||||
if err := m.handleInterfacePreSaveActions(ctx, iface); err != nil {
|
||||
if err := m.handleInterfacePreSaveActions(iface); err != nil {
|
||||
return nil, fmt.Errorf("pre-save actions failed: %w", err)
|
||||
}
|
||||
|
||||
err = m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
|
||||
err := m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
|
||||
iface.CopyCalculatedAttributes(i)
|
||||
|
||||
err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier,
|
||||
@@ -553,60 +551,21 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
return nil, fmt.Errorf("failed to save interface: %w", err)
|
||||
}
|
||||
|
||||
// update the interface type of peers in db
|
||||
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
|
||||
}
|
||||
for _, peer := range peers {
|
||||
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
|
||||
switch iface.Type {
|
||||
case domain.InterfaceTypeAny:
|
||||
peer.Interface.Type = domain.InterfaceTypeAny
|
||||
case domain.InterfaceTypeClient:
|
||||
peer.Interface.Type = domain.InterfaceTypeServer
|
||||
case domain.InterfaceTypeServer:
|
||||
peer.Interface.Type = domain.InterfaceTypeClient
|
||||
}
|
||||
|
||||
return &peer, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update peer %s for interface %s: %w", peer.Identifier,
|
||||
iface.Identifier, err)
|
||||
}
|
||||
}
|
||||
|
||||
if iface.IsDisabled() {
|
||||
physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier)
|
||||
fwMark := iface.FirewallMark
|
||||
if physicalInterface != nil && fwMark == 0 {
|
||||
fwMark = physicalInterface.FirewallMark
|
||||
}
|
||||
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
|
||||
Interface: *iface,
|
||||
AllowedIps: iface.GetAllowedIPs(peers),
|
||||
FwMark: iface.FirewallMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
TableStr: iface.RoutingTable,
|
||||
FwMark: fwMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
})
|
||||
} else {
|
||||
m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
|
||||
Interface: *iface,
|
||||
AllowedIps: iface.GetAllowedIPs(peers),
|
||||
FwMark: iface.FirewallMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
TableStr: iface.RoutingTable,
|
||||
})
|
||||
// if the route table changed, ensure that the old entries are remove
|
||||
if routeTableChanged {
|
||||
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
|
||||
Interface: *oldInterface,
|
||||
AllowedIps: oldInterface.GetAllowedIPs(peers),
|
||||
FwMark: oldInterface.FirewallMark,
|
||||
Table: oldInterface.GetRoutingTable(),
|
||||
TableStr: oldInterface.RoutingTable,
|
||||
IsDeleted: true, // mark the old entries as deleted
|
||||
})
|
||||
}
|
||||
m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier))
|
||||
}
|
||||
|
||||
if err := m.handleInterfacePostSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
|
||||
if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); err != nil {
|
||||
return nil, fmt.Errorf("post-save hooks failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -642,90 +601,60 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
return iface, nil
|
||||
}
|
||||
|
||||
func (m Manager) getInterfaceStateHistory(
|
||||
oldInterface *domain.Interface,
|
||||
iface *domain.Interface,
|
||||
) (oldEnabled, newEnabled, routeTableChanged bool) {
|
||||
return !oldInterface.IsDisabled(), !iface.IsDisabled(), oldInterface.RoutingTable != iface.RoutingTable
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePreSaveActions(ctx context.Context, iface *domain.Interface) error {
|
||||
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
|
||||
if !ok {
|
||||
slog.Warn("failed to perform pre-save actions", "interface", iface.Identifier,
|
||||
"error", "no capable controller found")
|
||||
return nil
|
||||
func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) {
|
||||
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled
|
||||
}
|
||||
|
||||
// update DNS settings only for client interfaces
|
||||
if iface.Type == domain.InterfaceTypeClient || iface.Type == domain.InterfaceTypeAny {
|
||||
if !iface.IsDisabled() {
|
||||
if err := wgQuickController.SetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
|
||||
return fmt.Errorf("failed to update dns settings: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := wgQuickController.UnsetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
|
||||
return fmt.Errorf("failed to clear dns settings: %w", err)
|
||||
}
|
||||
return !oldInterface.IsDisabled(), !iface.IsDisabled()
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
|
||||
if !iface.IsDisabled() {
|
||||
if err := m.quick.SetDNS(iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
|
||||
return fmt.Errorf("failed to update dns settings: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := m.quick.UnsetDNS(iface.Identifier); err != nil {
|
||||
return fmt.Errorf("failed to clear dns settings: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePreSaveHooks(
|
||||
ctx context.Context,
|
||||
iface *domain.Interface,
|
||||
oldEnabled, newEnabled bool,
|
||||
) error {
|
||||
func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
|
||||
if oldEnabled == newEnabled {
|
||||
return nil // do nothing if state did not change
|
||||
}
|
||||
|
||||
slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled)
|
||||
|
||||
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
|
||||
if !ok {
|
||||
slog.Warn("failed to execute pre-save hooks", "interface", iface.Identifier, "up", newEnabled,
|
||||
"error", "no capable controller found")
|
||||
return nil
|
||||
}
|
||||
|
||||
if newEnabled {
|
||||
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreUp); err != nil {
|
||||
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil {
|
||||
return fmt.Errorf("failed to execute pre-up hook: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreDown); err != nil {
|
||||
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreDown); err != nil {
|
||||
return fmt.Errorf("failed to execute pre-down hook: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePostSaveHooks(
|
||||
ctx context.Context,
|
||||
iface *domain.Interface,
|
||||
oldEnabled, newEnabled bool,
|
||||
) error {
|
||||
func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
|
||||
if oldEnabled == newEnabled {
|
||||
return nil // do nothing if state did not change
|
||||
}
|
||||
|
||||
slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled)
|
||||
|
||||
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
|
||||
if !ok {
|
||||
slog.Warn("failed to execute post-save hooks", "interface", iface.Identifier, "up", newEnabled,
|
||||
"error", "no capable controller found")
|
||||
return nil
|
||||
}
|
||||
|
||||
if newEnabled {
|
||||
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostUp); err != nil {
|
||||
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil {
|
||||
return fmt.Errorf("failed to execute post-up hook: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostDown); err != nil {
|
||||
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostDown); err != nil {
|
||||
return fmt.Errorf("failed to execute post-down hook: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -853,7 +782,7 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
|
||||
|
||||
func (m Manager) importInterface(
|
||||
ctx context.Context,
|
||||
backend domain.InterfaceController,
|
||||
backend InterfaceController,
|
||||
in *domain.PhysicalInterface,
|
||||
peers []domain.PhysicalPeer,
|
||||
) error {
|
||||
@@ -868,17 +797,6 @@ 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:
|
||||
@@ -916,61 +834,6 @@ 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)
|
||||
@@ -1021,9 +884,13 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) deleteInterfacePeers(ctx context.Context, iface *domain.Interface, allPeers []domain.Peer) error {
|
||||
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, peer := range allPeers {
|
||||
err := m.wg.GetController(*iface).DeletePeer(ctx, iface.Identifier, peer.Identifier)
|
||||
err = m.wg.GetController(*iface).DeletePeer(ctx, id, peer.Identifier)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
||||
@@ -188,8 +188,6 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
||||
|
||||
sessionUser := domain.GetUserInfo(ctx)
|
||||
|
||||
peer.Identifier = domain.PeerIdentifier(peer.Interface.PublicKey) // ensure that identifier corresponds to the public key
|
||||
|
||||
// Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set
|
||||
if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 {
|
||||
peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier)
|
||||
@@ -388,20 +386,9 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||
return fmt.Errorf("failed to delete peer %s: %w", id, err)
|
||||
}
|
||||
|
||||
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
m.bus.Publish(app.TopicPeerDeleted, *peer)
|
||||
// Update routes after peers have changed
|
||||
m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
|
||||
Interface: *iface,
|
||||
AllowedIps: iface.GetAllowedIPs(peers),
|
||||
FwMark: iface.FirewallMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
TableStr: iface.RoutingTable,
|
||||
})
|
||||
m.bus.Publish(app.TopicRouteUpdate, "peers updated")
|
||||
// Update interface after peers have changed
|
||||
m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier)
|
||||
|
||||
@@ -449,26 +436,20 @@ func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier)
|
||||
// region helper-functions
|
||||
|
||||
func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
|
||||
interfaces := make(map[domain.InterfaceIdentifier]domain.Interface)
|
||||
interfaces := make(map[domain.InterfaceIdentifier]struct{})
|
||||
|
||||
for _, peer := range peers {
|
||||
// get interface from db if it is not yet in the map
|
||||
if _, ok := interfaces[peer.InterfaceIdentifier]; !ok {
|
||||
iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
|
||||
}
|
||||
interfaces[peer.InterfaceIdentifier] = *iface
|
||||
iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
|
||||
}
|
||||
|
||||
iface := interfaces[peer.InterfaceIdentifier]
|
||||
|
||||
// Always save the peer to the backend, regardless of disabled/expired state
|
||||
// The backend will handle the disabled state appropriately
|
||||
err := m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
|
||||
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
|
||||
peer.CopyCalculatedAttributes(p)
|
||||
|
||||
err := m.wg.GetController(iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
|
||||
err := m.wg.GetController(*iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
|
||||
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
|
||||
domain.MergeToPhysicalPeer(pp, peer)
|
||||
return pp, nil
|
||||
@@ -492,22 +473,13 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
|
||||
Peer: *peer,
|
||||
},
|
||||
})
|
||||
|
||||
interfaces[peer.InterfaceIdentifier] = struct{}{}
|
||||
}
|
||||
|
||||
// Update routes after peers have changed
|
||||
for id, iface := range interfaces {
|
||||
interfacePeers, err := m.db.GetInterfacePeers(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-load peers for interface %s: %w", id, err)
|
||||
}
|
||||
|
||||
m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
|
||||
Interface: iface,
|
||||
AllowedIps: iface.GetAllowedIPs(interfacePeers),
|
||||
FwMark: iface.FirewallMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
TableStr: iface.RoutingTable,
|
||||
})
|
||||
if len(interfaces) != 0 {
|
||||
m.bus.Publish(app.TopicRouteUpdate, "peers updated")
|
||||
}
|
||||
|
||||
for iface := range interfaces {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// --- Test mocks ---
|
||||
|
||||
type mockBus struct{}
|
||||
|
||||
func (f *mockBus) Publish(topic string, args ...any) {}
|
||||
func (f *mockBus) Subscribe(topic string, fn interface{}) error { return nil }
|
||||
|
||||
type mockController struct{}
|
||||
|
||||
func (f *mockController) GetId() domain.InterfaceBackend { return "local" }
|
||||
func (f *mockController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *mockController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
return &domain.PhysicalInterface{Identifier: id}, nil
|
||||
}
|
||||
func (f *mockController) GetPeers(_ context.Context, _ domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *mockController) SaveInterface(
|
||||
_ context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error {
|
||||
_, _ = updateFunc(&domain.PhysicalInterface{})
|
||||
return nil
|
||||
}
|
||||
func (f *mockController) DeleteInterface(_ context.Context, _ domain.InterfaceIdentifier) error {
|
||||
return nil
|
||||
}
|
||||
func (f *mockController) SavePeer(
|
||||
_ context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
_ domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error {
|
||||
_, _ = updateFunc(&domain.PhysicalPeer{})
|
||||
return nil
|
||||
}
|
||||
func (f *mockController) DeletePeer(_ context.Context, _ domain.InterfaceIdentifier, _ domain.PeerIdentifier) error {
|
||||
return nil
|
||||
}
|
||||
func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.PingerResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type mockDB struct {
|
||||
savedPeers map[domain.PeerIdentifier]*domain.Peer
|
||||
iface *domain.Interface
|
||||
}
|
||||
|
||||
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
|
||||
if f.iface != nil && f.iface.Identifier == id {
|
||||
return f.iface, nil
|
||||
}
|
||||
return &domain.Interface{Identifier: id}, nil
|
||||
}
|
||||
func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.Interface,
|
||||
[]domain.Peer,
|
||||
error,
|
||||
) {
|
||||
return f.iface, nil, nil
|
||||
}
|
||||
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil }
|
||||
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *mockDB) SaveInterface(
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(in *domain.Interface) (*domain.Interface, error),
|
||||
) error {
|
||||
if f.iface == nil {
|
||||
f.iface = &domain.Interface{Identifier: id}
|
||||
}
|
||||
var err error
|
||||
f.iface, err = updateFunc(f.iface)
|
||||
return err
|
||||
}
|
||||
func (f *mockDB) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
return nil
|
||||
}
|
||||
func (f *mockDB) GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *mockDB) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *mockDB) SavePeer(
|
||||
ctx context.Context,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(in *domain.Peer) (*domain.Peer, error),
|
||||
) error {
|
||||
if f.savedPeers == nil {
|
||||
f.savedPeers = make(map[domain.PeerIdentifier]*domain.Peer)
|
||||
}
|
||||
existing := f.savedPeers[id]
|
||||
if existing == nil {
|
||||
existing = &domain.Peer{Identifier: id}
|
||||
}
|
||||
updated, err := updateFunc(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.savedPeers[updated.Identifier] = updated
|
||||
return nil
|
||||
}
|
||||
func (f *mockDB) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error { return nil }
|
||||
func (f *mockDB) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
func (f *mockDB) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (
|
||||
map[domain.Cidr][]domain.Cidr,
|
||||
error,
|
||||
) {
|
||||
return map[domain.Cidr][]domain.Cidr{}, nil
|
||||
}
|
||||
|
||||
// --- Test ---
|
||||
|
||||
func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
|
||||
// Arrange
|
||||
cfg := &config.Config{}
|
||||
cfg.Core.SelfProvisioningAllowed = true
|
||||
cfg.Core.EditableKeys = true
|
||||
cfg.Advanced.LimitAdditionalUserPeers = 0
|
||||
|
||||
bus := &mockBus{}
|
||||
|
||||
// Prepare a controller manager with our mock controller
|
||||
ctrlMgr := &ControllerManager{
|
||||
controllers: map[domain.InterfaceBackend]backendInstance{
|
||||
config.LocalBackendName: {Implementation: &mockController{}},
|
||||
},
|
||||
}
|
||||
|
||||
db := &mockDB{iface: &domain.Interface{Identifier: "wg0", Type: domain.InterfaceTypeServer}}
|
||||
|
||||
m := Manager{
|
||||
cfg: cfg,
|
||||
bus: bus,
|
||||
db: db,
|
||||
wg: ctrlMgr,
|
||||
}
|
||||
|
||||
userId := domain.UserIdentifier("user@example.com")
|
||||
ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: false})
|
||||
|
||||
pubKey := "TEST_PUBLIC_KEY_ABC123"
|
||||
|
||||
input := &domain.Peer{
|
||||
Identifier: "should_be_overwritten",
|
||||
UserIdentifier: userId,
|
||||
InterfaceIdentifier: domain.InterfaceIdentifier("wg0"),
|
||||
Interface: domain.PeerInterfaceConfig{
|
||||
KeyPair: domain.KeyPair{PublicKey: pubKey},
|
||||
},
|
||||
}
|
||||
|
||||
// Act
|
||||
out, err := m.CreatePeer(ctx, input)
|
||||
|
||||
// Assert
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePeer returned error: %v", err)
|
||||
}
|
||||
|
||||
expectedId := domain.PeerIdentifier(pubKey)
|
||||
if out.Identifier != expectedId {
|
||||
t.Fatalf("expected Identifier to be set from public key %q, got %q", expectedId, out.Identifier)
|
||||
}
|
||||
|
||||
// Ensure the saved peer in DB also has the expected identifier
|
||||
if db.savedPeers[expectedId] == nil {
|
||||
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"time"
|
||||
@@ -126,45 +125,6 @@ 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.
|
||||
@@ -208,8 +168,6 @@ 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"`
|
||||
@@ -218,19 +176,6 @@ 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.
|
||||
@@ -266,10 +211,6 @@ type OpenIDConnectProvider struct {
|
||||
|
||||
// If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level.
|
||||
LogUserInfo bool `yaml:"log_user_info"`
|
||||
|
||||
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
|
||||
// This also includes OAuth tokens! Keep this disabled in production!
|
||||
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
|
||||
}
|
||||
|
||||
// OAuthProvider contains the configuration for the OAuth provider.
|
||||
@@ -311,10 +252,6 @@ type OAuthProvider struct {
|
||||
|
||||
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
|
||||
LogUserInfo bool `yaml:"log_user_info"`
|
||||
|
||||
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
|
||||
// This also includes OAuth tokens! Keep this disabled in production!
|
||||
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
|
||||
}
|
||||
|
||||
// WebauthnConfig contains the configuration for the WebAuthn authenticator.
|
||||
|
||||
@@ -10,15 +10,7 @@ const LocalBackendName = "local"
|
||||
type Backend struct {
|
||||
Default string `yaml:"default"` // The default backend to use (defaults to the internal backend)
|
||||
|
||||
// Local Backend-specific configuration
|
||||
|
||||
IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
|
||||
LocalResolvconfPrefix string `yaml:"local_resolvconf_prefix"` // The prefix to use for interface names when passing them to resolvconf.
|
||||
|
||||
// External Backend-specific configuration
|
||||
|
||||
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
|
||||
Pfsense []BackendPfsense `yaml:"pfsense"`
|
||||
}
|
||||
|
||||
// Validate checks the backend configuration for errors.
|
||||
@@ -37,15 +29,6 @@ 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 {
|
||||
@@ -59,8 +42,6 @@ func (b *Backend) Validate() error {
|
||||
type BackendBase struct {
|
||||
Id string `yaml:"id"` // A unique id for the backend
|
||||
DisplayName string `yaml:"display_name"` // A display name for the backend
|
||||
|
||||
IgnoredInterfaces []string `yaml:"ignored_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
|
||||
}
|
||||
|
||||
// GetDisplayName returns the display name of the backend.
|
||||
@@ -111,42 +92,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/a8m/envsubst"
|
||||
@@ -16,10 +14,9 @@ import (
|
||||
type Config struct {
|
||||
Core struct {
|
||||
// AdminUser defines the default administrator account that will be created
|
||||
AdminUserDisabled bool `yaml:"disable_admin_user"`
|
||||
AdminUser string `yaml:"admin_user"`
|
||||
AdminPassword string `yaml:"admin_password"`
|
||||
AdminApiToken string `yaml:"admin_api_token"` // if set, the API access is enabled automatically
|
||||
AdminUser string `yaml:"admin_user"`
|
||||
AdminPassword string `yaml:"admin_password"`
|
||||
AdminApiToken string `yaml:"admin_api_token"` // if set, the API access is enabled automatically
|
||||
|
||||
EditableKeys bool `yaml:"editable_keys"`
|
||||
CreateDefaultPeer bool `yaml:"create_default_peer"`
|
||||
@@ -116,96 +113,78 @@ func (c *Config) LogStartupValues() {
|
||||
func defaultConfig() *Config {
|
||||
cfg := &Config{}
|
||||
|
||||
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.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.Database = DatabaseConfig{
|
||||
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", ""),
|
||||
Type: "sqlite",
|
||||
DSN: "data/sqlite.db",
|
||||
}
|
||||
|
||||
cfg.Backend = Backend{
|
||||
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: getEnvStr("WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX", "tun."),
|
||||
Default: LocalBackendName, // local backend is the default (using wgcrtl)
|
||||
}
|
||||
|
||||
cfg.Web = WebConfig{
|
||||
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", ""),
|
||||
RequestLogging: false,
|
||||
ExternalUrl: "http://localhost:8888",
|
||||
ListeningAddress: ":8888",
|
||||
SessionIdentifier: "wgPortalSession",
|
||||
SessionSecret: "very_secret",
|
||||
CsrfSecret: "extremely_secret",
|
||||
SiteTitle: "WireGuard Portal",
|
||||
SiteCompanyName: "WireGuard Portal",
|
||||
}
|
||||
|
||||
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.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.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.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.Mail = MailConfig{
|
||||
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", ""),
|
||||
Host: "127.0.0.1",
|
||||
Port: 25,
|
||||
Encryption: MailEncryptionNone,
|
||||
CertValidation: true,
|
||||
Username: "",
|
||||
Password: "",
|
||||
AuthType: MailAuthPlain,
|
||||
From: "Wireguard Portal <noreply@wireguard.local>",
|
||||
LinkOnly: false,
|
||||
}
|
||||
|
||||
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.Webhook.Url = "" // no webhook by default
|
||||
cfg.Webhook.Authentication = ""
|
||||
cfg.Webhook.Timeout = 10 * time.Second
|
||||
|
||||
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)
|
||||
cfg.Auth.WebAuthn.Enabled = true
|
||||
cfg.Auth.MinPasswordLength = 16
|
||||
cfg.Auth.HideLoginForm = false
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -238,11 +217,6 @@ 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
|
||||
}
|
||||
@@ -265,75 +239,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -41,10 +41,4 @@ type MailConfig struct {
|
||||
From string `yaml:"from"`
|
||||
// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ 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() {
|
||||
|
||||
@@ -5,7 +5,6 @@ package domain
|
||||
const (
|
||||
ControllerTypeMikrotik = "mikrotik"
|
||||
ControllerTypeLocal = "wgctrl"
|
||||
ControllerTypePfsense = "pfsense"
|
||||
)
|
||||
|
||||
// Controller extras can be used to store additional information available for specific controllers only.
|
||||
@@ -31,20 +30,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -133,30 +132,17 @@ func (i *Interface) GetConfigFileName() string {
|
||||
return filename
|
||||
}
|
||||
|
||||
// GetAllowedIPs returns the allowed IPs for the interface depending on the interface type and peers.
|
||||
// For example, if the interface type is Server, the allowed IPs are the IPs of the peers.
|
||||
// If the interface type is Client, the allowed IPs correspond to the AllowedIPsStr of the peers.
|
||||
func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr {
|
||||
var allowedCidrs []Cidr
|
||||
|
||||
switch i.Type {
|
||||
case InterfaceTypeServer, InterfaceTypeAny:
|
||||
for _, peer := range peers {
|
||||
for _, ip := range peer.Interface.Addresses {
|
||||
allowedCidrs = append(allowedCidrs, ip.HostAddr())
|
||||
}
|
||||
if peer.ExtraAllowedIPsStr != "" {
|
||||
extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
|
||||
if err == nil {
|
||||
allowedCidrs = append(allowedCidrs, extraIPs...)
|
||||
}
|
||||
}
|
||||
for _, peer := range peers {
|
||||
for _, ip := range peer.Interface.Addresses {
|
||||
allowedCidrs = append(allowedCidrs, ip.HostAddr())
|
||||
}
|
||||
case InterfaceTypeClient:
|
||||
for _, peer := range peers {
|
||||
allowedIPs, err := CidrsFromString(peer.AllowedIPsStr.GetValue())
|
||||
if peer.ExtraAllowedIPsStr != "" {
|
||||
extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
|
||||
if err == nil {
|
||||
allowedCidrs = append(allowedCidrs, allowedIPs...)
|
||||
allowedCidrs = append(allowedCidrs, extraIPs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +159,6 @@ func (i *Interface) ManageRoutingTable() bool {
|
||||
//
|
||||
// -1 if RoutingTable was set to "off" or an error occurred
|
||||
func (i *Interface) GetRoutingTable() int {
|
||||
|
||||
routingTableStr := strings.ToLower(i.RoutingTable)
|
||||
switch {
|
||||
case routingTableStr == "":
|
||||
@@ -181,9 +166,6 @@ func (i *Interface) GetRoutingTable() int {
|
||||
case routingTableStr == "off":
|
||||
return -1
|
||||
case strings.HasPrefix(routingTableStr, "0x"):
|
||||
if i.Backend != config.LocalBackendName {
|
||||
return 0 // ignore numeric routing table numbers for non-local controllers
|
||||
}
|
||||
numberStr := strings.ReplaceAll(routingTableStr, "0x", "")
|
||||
routingTable, err := strconv.ParseUint(numberStr, 16, 64)
|
||||
if err != nil {
|
||||
@@ -196,9 +178,6 @@ func (i *Interface) GetRoutingTable() int {
|
||||
}
|
||||
return int(routingTable)
|
||||
default:
|
||||
if i.Backend != config.LocalBackendName {
|
||||
return 0 // ignore numeric routing table numbers for non-local controllers
|
||||
}
|
||||
routingTable, err := strconv.Atoi(routingTableStr)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err)
|
||||
@@ -240,8 +219,7 @@ func (p *PhysicalInterface) GetExtras() any {
|
||||
func (p *PhysicalInterface) SetExtras(extras any) {
|
||||
switch extras.(type) {
|
||||
case MikrotikInterfaceExtras: // OK
|
||||
case PfsenseInterfaceExtras: // OK
|
||||
default: // we only support MikrotikInterfaceExtras and PfsenseInterfaceExtras for now
|
||||
default: // we only support MikrotikInterfaceExtras for now
|
||||
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
|
||||
}
|
||||
|
||||
@@ -304,14 +282,6 @@ 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
|
||||
@@ -334,28 +304,16 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
type RoutingTableInfo struct {
|
||||
Interface Interface
|
||||
AllowedIps []Cidr
|
||||
FwMark uint32
|
||||
Table int
|
||||
TableStr string // the routing table number as string (used by mikrotik, linux uses the numeric value)
|
||||
IsDeleted bool // true if the interface was deleted, false otherwise
|
||||
FwMark uint32
|
||||
Table int
|
||||
}
|
||||
|
||||
func (r RoutingTableInfo) String() string {
|
||||
v4, v6 := CidrsPerFamily(r.AllowedIps)
|
||||
return fmt.Sprintf("%s: fwmark=%d; table=%d; routes_4=%d; routes_6=%d", r.Interface.Identifier, r.FwMark, r.Table,
|
||||
len(v4), len(v6))
|
||||
return fmt.Sprintf("%d -> %d", r.FwMark, r.Table)
|
||||
}
|
||||
|
||||
func (r RoutingTableInfo) ManagementEnabled() bool {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "context"
|
||||
|
||||
type InterfaceController interface {
|
||||
GetId() InterfaceBackend
|
||||
GetInterfaces(_ context.Context) ([]PhysicalInterface, error)
|
||||
GetInterface(_ context.Context, id InterfaceIdentifier) (*PhysicalInterface, error)
|
||||
GetPeers(_ context.Context, deviceId InterfaceIdentifier) ([]PhysicalPeer, error)
|
||||
SaveInterface(
|
||||
_ context.Context,
|
||||
id InterfaceIdentifier,
|
||||
updateFunc func(pi *PhysicalInterface) (*PhysicalInterface, error),
|
||||
) error
|
||||
DeleteInterface(_ context.Context, id InterfaceIdentifier) error
|
||||
SavePeer(
|
||||
_ context.Context,
|
||||
deviceId InterfaceIdentifier,
|
||||
id PeerIdentifier,
|
||||
updateFunc func(pp *PhysicalPeer) (*PhysicalPeer, error),
|
||||
) error
|
||||
DeletePeer(_ context.Context, deviceId InterfaceIdentifier, id PeerIdentifier) error
|
||||
PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*PingerResult, error)
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) {
|
||||
@@ -39,9 +37,8 @@ func TestInterface_GetConfigFileNameReturnsCorrectFileName(t *testing.T) {
|
||||
assert.Equal(t, expected, iface.GetConfigFileName())
|
||||
}
|
||||
|
||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(t *testing.T) {
|
||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) {
|
||||
peer1 := Peer{
|
||||
AllowedIPsStr: ConfigOption[string]{Value: "192.168.2.2/32"},
|
||||
Interface: PeerInterfaceConfig{
|
||||
Addresses: []Cidr{
|
||||
{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32},
|
||||
@@ -49,45 +46,16 @@ func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(t *testing.T) {
|
||||
},
|
||||
}
|
||||
peer2 := Peer{
|
||||
AllowedIPsStr: ConfigOption[string]{Value: "10.0.2.2/32"},
|
||||
ExtraAllowedIPsStr: "10.20.2.2/32",
|
||||
Interface: PeerInterfaceConfig{
|
||||
Addresses: []Cidr{
|
||||
{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32},
|
||||
},
|
||||
},
|
||||
}
|
||||
iface := &Interface{Type: InterfaceTypeServer}
|
||||
iface := &Interface{}
|
||||
expected := []Cidr{
|
||||
{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32},
|
||||
{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32},
|
||||
{Cidr: "10.20.2.2/32", Addr: "10.20.2.2", NetLength: 32},
|
||||
}
|
||||
assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2}))
|
||||
}
|
||||
|
||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrsClientMode(t *testing.T) {
|
||||
peer1 := Peer{
|
||||
AllowedIPsStr: ConfigOption[string]{Value: "192.168.2.2/32"},
|
||||
Interface: PeerInterfaceConfig{
|
||||
Addresses: []Cidr{
|
||||
{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32},
|
||||
},
|
||||
},
|
||||
}
|
||||
peer2 := Peer{
|
||||
AllowedIPsStr: ConfigOption[string]{Value: "10.0.2.2/32"},
|
||||
ExtraAllowedIPsStr: "10.20.2.2/32",
|
||||
Interface: PeerInterfaceConfig{
|
||||
Addresses: []Cidr{
|
||||
{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32},
|
||||
},
|
||||
},
|
||||
}
|
||||
iface := &Interface{Type: InterfaceTypeClient}
|
||||
expected := []Cidr{
|
||||
{Cidr: "192.168.2.2/32", Addr: "192.168.2.2", NetLength: 32},
|
||||
{Cidr: "10.0.2.2/32", Addr: "10.0.2.2", NetLength: 32},
|
||||
}
|
||||
assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2}))
|
||||
}
|
||||
@@ -98,22 +66,10 @@ func TestInterface_ManageRoutingTableReturnsCorrectValue(t *testing.T) {
|
||||
|
||||
iface.RoutingTable = "100"
|
||||
assert.True(t, iface.ManageRoutingTable())
|
||||
|
||||
iface = &Interface{RoutingTable: "off", Backend: config.LocalBackendName}
|
||||
assert.False(t, iface.ManageRoutingTable())
|
||||
|
||||
iface.RoutingTable = "100"
|
||||
assert.True(t, iface.ManageRoutingTable())
|
||||
|
||||
iface = &Interface{RoutingTable: "off", Backend: "mikrotik-xxx"}
|
||||
assert.False(t, iface.ManageRoutingTable())
|
||||
|
||||
iface.RoutingTable = "100"
|
||||
assert.True(t, iface.ManageRoutingTable())
|
||||
}
|
||||
|
||||
func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) {
|
||||
iface := &Interface{RoutingTable: "", Backend: config.LocalBackendName}
|
||||
iface := &Interface{RoutingTable: ""}
|
||||
assert.Equal(t, 0, iface.GetRoutingTable())
|
||||
|
||||
iface.RoutingTable = "off"
|
||||
@@ -125,17 +81,3 @@ func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) {
|
||||
iface.RoutingTable = "200"
|
||||
assert.Equal(t, 200, iface.GetRoutingTable())
|
||||
}
|
||||
|
||||
func TestInterface_GetRoutingTableNonLocal(t *testing.T) {
|
||||
iface := &Interface{RoutingTable: "off", Backend: "something different"}
|
||||
assert.Equal(t, -1, iface.GetRoutingTable())
|
||||
|
||||
iface.RoutingTable = "0"
|
||||
assert.Equal(t, 0, iface.GetRoutingTable())
|
||||
|
||||
iface.RoutingTable = "100"
|
||||
assert.Equal(t, 0, iface.GetRoutingTable())
|
||||
|
||||
iface.RoutingTable = "abc"
|
||||
assert.Equal(t, 0, iface.GetRoutingTable())
|
||||
}
|
||||
|
||||
@@ -26,10 +26,6 @@ func (c Cidr) IsValid() bool {
|
||||
return c.Prefix().IsValid()
|
||||
}
|
||||
|
||||
func (c Cidr) EqualPrefix(other Cidr) bool {
|
||||
return c.Addr == other.Addr && c.NetLength == other.NetLength
|
||||
}
|
||||
|
||||
func CidrFromString(str string) (Cidr, error) {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(str))
|
||||
if err != nil {
|
||||
@@ -203,26 +199,3 @@ func (c Cidr) Contains(other Cidr) bool {
|
||||
|
||||
return subnet.Contains(otherIP)
|
||||
}
|
||||
|
||||
// ContainsDefaultRoute returns true if the given CIDRs contain a default route.
|
||||
func ContainsDefaultRoute(cidrs []Cidr) bool {
|
||||
for _, allowedIP := range cidrs {
|
||||
if allowedIP.Prefix().Bits() == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CidrsPerFamily returns a slice of CIDRs, one for each family (IPv4 and IPv6).
|
||||
func CidrsPerFamily(cidrs []Cidr) (ipv4, ipv6 []Cidr) {
|
||||
for _, cidr := range cidrs {
|
||||
if cidr.IsV4() {
|
||||
ipv4 = append(ipv4, cidr)
|
||||
} else {
|
||||
ipv6 = append(ipv6, cidr)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
@@ -46,7 +44,6 @@ type Peer struct {
|
||||
DisplayName string // a nice display name/ description for the peer
|
||||
Identifier PeerIdentifier `gorm:"primaryKey;column:identifier"` // peer unique identifier
|
||||
UserIdentifier UserIdentifier `gorm:"index;column:user_identifier"` // the owner
|
||||
User *User `gorm:"-"` // the owner user object; loaded automatically after fetch
|
||||
InterfaceIdentifier InterfaceIdentifier `gorm:"index;column:interface_identifier"` // the interface id
|
||||
Disabled *time.Time `gorm:"column:disabled"` // if this field is set, the peer is disabled
|
||||
DisabledReason string // the reason why the peer has been disabled
|
||||
@@ -240,8 +237,7 @@ func (p *PhysicalPeer) SetExtras(extras any) {
|
||||
switch extras.(type) {
|
||||
case MikrotikPeerExtras: // OK
|
||||
case LocalPeerExtras: // OK
|
||||
case PfsensePeerExtras: // OK
|
||||
default: // we only support MikrotikPeerExtras, LocalPeerExtras, and PfsensePeerExtras for now
|
||||
default: // we only support MikrotikPeerExtras and LocalPeerExtras for now
|
||||
panic(fmt.Sprintf("unsupported peer backend extras type %T", extras))
|
||||
}
|
||||
|
||||
@@ -302,26 +298,6 @@ 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
|
||||
@@ -329,33 +305,22 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
|
||||
|
||||
func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
|
||||
pp.Identifier = p.Identifier
|
||||
pp.PresharedKey = p.PresharedKey
|
||||
pp.PublicKey = p.Interface.PublicKey
|
||||
|
||||
switch p.Interface.Type {
|
||||
case InterfaceTypeClient: // this means that the corresponding interface in wgportal is a server interface
|
||||
allowedIPs := make([]Cidr, len(p.Interface.Addresses))
|
||||
for i, ip := range p.Interface.Addresses {
|
||||
allowedIPs[i] = ip.HostAddr() // add the peer's host address to the allowed IPs
|
||||
}
|
||||
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
|
||||
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
|
||||
case InterfaceTypeServer: // this means that the corresponding interface in wgportal is a client interface
|
||||
pp.Endpoint = p.Endpoint.GetValue()
|
||||
if p.Interface.Type == InterfaceTypeServer {
|
||||
allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue())
|
||||
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
|
||||
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
|
||||
pp.Endpoint = p.Endpoint.GetValue()
|
||||
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
|
||||
case InterfaceTypeAny: // this means that the corresponding interface in wgportal has no specific type
|
||||
} else {
|
||||
allowedIPs := make([]Cidr, len(p.Interface.Addresses))
|
||||
for i, ip := range p.Interface.Addresses {
|
||||
allowedIPs[i] = ip.HostAddr() // add the peer's host address to the allowed IPs
|
||||
allowedIPs[i] = ip.HostAddr()
|
||||
}
|
||||
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
|
||||
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
|
||||
pp.Endpoint = p.Endpoint.GetValue()
|
||||
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
|
||||
}
|
||||
pp.PresharedKey = p.PresharedKey
|
||||
pp.PublicKey = p.Interface.PublicKey
|
||||
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
|
||||
|
||||
switch pp.ImportSource {
|
||||
case ControllerTypeMikrotik:
|
||||
@@ -363,7 +328,7 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
|
||||
Id: "",
|
||||
Name: p.DisplayName,
|
||||
Comment: p.Notes,
|
||||
IsResponder: p.Interface.Type == InterfaceTypeClient,
|
||||
IsResponder: false,
|
||||
Disabled: p.IsDisabled(),
|
||||
ClientEndpoint: p.Endpoint.GetValue(),
|
||||
ClientAddress: CidrsToString(p.Interface.Addresses),
|
||||
@@ -376,18 +341,6 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,26 +348,3 @@ type PeerCreationRequest struct {
|
||||
UserIdentifiers []string
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// AfterFind is a GORM hook that automatically loads the associated User object
|
||||
// based on the UserIdentifier field. If the identifier is empty or no user is
|
||||
// found, the User field is set to nil.
|
||||
func (p *Peer) AfterFind(tx *gorm.DB) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.UserIdentifier == "" {
|
||||
p.User = nil
|
||||
return nil
|
||||
}
|
||||
var u User
|
||||
if err := tx.Where("identifier = ?", p.UserIdentifier).First(&u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
p.User = nil
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.User = &u
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -185,27 +185,6 @@ func (u *User) CopyCalculatedAttributes(src *User) {
|
||||
u.LinkedPeerCount = src.LinkedPeerCount
|
||||
}
|
||||
|
||||
// DisplayName returns the display name of the user.
|
||||
// The display name is the first and last name, or the email address of the user.
|
||||
// If none of these fields are set, the user identifier is returned.
|
||||
func (u *User) DisplayName() string {
|
||||
var displayName string
|
||||
switch {
|
||||
case u.Firstname != "" && u.Lastname != "":
|
||||
displayName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
|
||||
case u.Firstname != "":
|
||||
displayName = u.Firstname
|
||||
case u.Lastname != "":
|
||||
displayName = u.Lastname
|
||||
case u.Email != "":
|
||||
displayName = u.Email
|
||||
default:
|
||||
displayName = string(u.Identifier)
|
||||
}
|
||||
|
||||
return displayName
|
||||
}
|
||||
|
||||
// region webauthn
|
||||
|
||||
func (u *User) WebAuthnID() []byte {
|
||||
@@ -230,7 +209,19 @@ func (u *User) WebAuthnName() string {
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnDisplayName() string {
|
||||
return u.DisplayName()
|
||||
var userName string
|
||||
switch {
|
||||
case u.Firstname != "" && u.Lastname != "":
|
||||
userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
|
||||
case u.Firstname != "":
|
||||
userName = u.Firstname
|
||||
case u.Lastname != "":
|
||||
userName = u.Lastname
|
||||
default:
|
||||
userName = string(u.Identifier)
|
||||
}
|
||||
|
||||
return userName
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnCredentials() []webauthn.Credential {
|
||||
|
||||
@@ -267,7 +267,6 @@ func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiRespons
|
||||
}
|
||||
|
||||
defer func(Body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, Body) // ensure to empty the body
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -6,12 +6,8 @@ repo_name: h44z/wg-portal
|
||||
repo_url: https://github.com/h44z/wg-portal
|
||||
copyright: Copyright © 2023-2025 WireGuard Portal Project
|
||||
|
||||
extra_javascript:
|
||||
- javascript/img-comparison-slider.js
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
- stylesheets/img-comparison-slider.css
|
||||
|
||||
theme:
|
||||
name: material
|
||||
@@ -81,7 +77,6 @@ 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
|
||||
@@ -89,7 +84,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user