Compare commits

..

20 Commits

Author SHA1 Message Date
Christoph
d1a4ddde10 Merge branch 'master' into stable 2025-11-23 21:00:12 +01:00
Christoph Haas
b1637b0c4e Merge branch 'master' into stable
# Conflicts:
#	internal/domain/peer.go
2025-10-19 13:25:07 +02:00
h44z
0cc7ebb83e ensure hooks run after restart (#494) (#497)
(cherry picked from commit 99df4ca3cd)
2025-09-03 22:48:45 +02:00
h44z
eb6a787cfc ensure that LDAP filter values are escaped (#512)
(cherry picked from commit 0cbca61c15)
2025-09-03 22:47:40 +02:00
Christoph Haas
b546eec4ed fix multi-peer generation, fix prefix handling (#491)
(cherry picked from commit c20f17cddf)
2025-08-12 21:25:48 +02:00
h44z
9be2133220 fix migration tool (#495) (#496)
(cherry picked from commit 9884d8c002)
2025-08-12 21:23:30 +02:00
Christoph Haas
b05837b2d9 ensure that v2 (or just 2) tags are only published for stable releases (#493)
(cherry picked from commit b099e8abfa)
2025-08-12 21:23:28 +02:00
Christoph Haas
08c8f8eac0 backport username display bugfix (#456) 2025-06-12 19:11:25 +02:00
Christoph Haas
d864e24145 improve logging of OAuth login issues, decrease auth-code exchange timeout (#451)
(cherry picked from commit e3b65ca337)
2025-06-12 19:07:46 +02:00
Christoph Haas
5b56e58fe9 fix self-provisioned peer-generation (#452)
(cherry picked from commit 61d8aa6589)
2025-06-09 17:41:29 +02:00
Christoph Haas
930ef7b573 Merge branch 'master' into stable 2025-05-16 09:58:14 +02:00
Christoph Haas
18296673d7 Merge branch 'master' into stable 2025-05-13 20:25:27 +02:00
Christoph Haas
4ccc59c109 Merge branch 'master' into stable
# Conflicts:
#	.github/workflows/docker-publish.yml
#	README.md
#	assets/tpl/admin_index.html
#	assets/tpl/user_index.html
#	cmd/wg-portal/main.go
#	docker-compose.yml
#	go.mod
#	go.sum
#	internal/common/util.go
#	internal/server/docs/docs.go
#	internal/server/handlers_common.go
#	internal/server/server.go
#	internal/wireguard/peermanager.go
2025-05-04 20:38:55 +02:00
onyx-flame
e6b01a9903 Feature (v1): add latest handshake data to API response (#203)
* feature: updated handshake-related fields type

* feature: updated handshake representations in templates

* feature: added handshake field to Swagger schema
2023-12-23 12:56:52 +01:00
Christoph Haas
2f79dd04c0 adopt github actions 2023-10-26 11:29:34 +02:00
Christoph Haas
e5ed9736b3 update docker build settings, move to new docker hub repository, use stable branch and major version tags 2023-10-26 11:22:58 +02:00
Christoph Haas
c8353b85ae Merge branch 'replace_ext_lib' into stable 2023-10-26 10:40:06 +02:00
Christoph Haas
6142031387 update gin 2023-10-26 10:39:01 +02:00
Christoph Haas
dd86d0ff49 replace inaccessible external lib 2023-10-26 10:31:29 +02:00
Christoph Haas
bdd426a679 populate peer device type (#170) 2023-10-26 10:20:08 +02:00
41 changed files with 262 additions and 2315 deletions

View File

@@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
@@ -35,7 +35,7 @@ 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@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.x'
@@ -60,7 +60,7 @@ jobs:
permissions:
packages: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
@@ -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@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: |
wgportal/wg-portal
@@ -115,7 +115,7 @@ jobs:
name: binaries
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
files: 'wg-portal_linux*'
generate_release_notes: true

View File

@@ -15,11 +15,11 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: 3.x

View File

@@ -50,7 +50,7 @@ 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
# Setup timezone

View File

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

View File

@@ -84,7 +84,7 @@ func main() {
internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
internal.AssertNoError(err)
authenticator.StartBackgroundJobs(ctx)

View File

@@ -11,12 +11,7 @@ core:
web:
external_url: http://localhost:8888
base_path: ""
request_logging: true
frontend_filepath: ""
mail:
templates_path: ""
webhook:
url: ""
@@ -99,15 +94,3 @@ auth:
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

View File

@@ -74,7 +74,6 @@ mail:
from: Wireguard Portal <noreply@wireguard.local>
link_only: false
allow_peer_email: false
templates_path: ""
auth:
oidc: []
@@ -88,7 +87,6 @@ auth:
web:
listening_address: :8888
external_url: http://localhost:8888
base_path: ""
site_company_name: WireGuard Portal
site_title: WireGuard Portal
session_identifier: wgPortalSession
@@ -98,7 +96,6 @@ web:
expose_host_info: false
cert_file: ""
key_File: ""
frontend_filepath: ""
webhook:
url: ""
@@ -488,11 +485,6 @@ To send emails to all peers that have a valid email-address as user-identifier,
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
@@ -802,15 +794,8 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
- **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.
The external URL must not contain a path component or trailing slash. If you want to serve WireGuard Portal on a subpath, use the `base_path` setting.
**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.
### `base_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_BASE_PATH`
- **Description:** The base path for the web server (e.g., `/wgportal`).
By default (meaning an empty value), the portal will be served from the root path `/`.
### `site_company_name`
- **Default:** `WireGuard Portal`
- **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME`
@@ -856,14 +841,6 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
- **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

View File

@@ -84,16 +84,6 @@ web:
external_url: https://wg.domain.com
```
If you want to serve the web interface on a different base-path, you can also set the `web.base_path` option:
```yaml
web:
external_url: https://wg.domain.com
base_path: /subpath
```
The WireGuard Portal will then be available at `https://wg.domain.com/subpath`.
### Built-in TLS
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.

View File

@@ -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).
@@ -56,36 +55,3 @@ 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.

View File

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

View File

@@ -9,7 +9,6 @@
<script>
// global config, will be overridden by backend if available
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
let WGPORTAL_BASE_PATH="";
let WGPORTAL_VERSION="unknown";
let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";

View File

@@ -23,15 +23,15 @@
"is-ip": "^5.0.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
"vite": "^7.2.2"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -548,13 +548,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.2.2",
"@intlify/shared": "11.2.2"
"@intlify/message-compiler": "11.1.12",
"@intlify/shared": "11.1.12"
},
"engines": {
"node": ">= 16"
@@ -564,12 +564,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.2.2",
"@intlify/shared": "11.1.12",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -580,9 +580,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
"license": "MIT",
"engines": {
"node": ">= 16"
@@ -921,15 +921,16 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.50",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
"integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==",
"dev": true,
"license": "MIT"
},
@@ -1255,13 +1256,13 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
"integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.50"
"@rolldown/pluginutils": "1.0.0-beta.29"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -1286,39 +1287,39 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.25",
"@vue/shared": "3.5.24",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-core": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.25",
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25",
"@vue/compiler-core": "3.5.24",
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
@@ -1326,13 +1327,13 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-dom": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/devtools-api": {
@@ -1369,53 +1370,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.25"
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.25",
"@vue/runtime-core": "3.5.25",
"@vue/shared": "3.5.25",
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
"@vue/shared": "3.5.24",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
},
"peerDependencies": {
"vue": "3.5.25"
"vue": "3.5.24"
}
},
"node_modules/@vue/shared": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
"license": "MIT"
},
"node_modules/birpc": {
@@ -1564,9 +1565,9 @@
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -2078,6 +2079,7 @@
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"buffer-builder": "^0.2.0",
@@ -2569,6 +2571,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2605,11 +2608,12 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -2703,6 +2707,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2711,16 +2716,17 @@
}
},
"node_modules/vue": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
"@vue/runtime-dom": "3.5.25",
"@vue/server-renderer": "3.5.25",
"@vue/shared": "3.5.25"
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",
"@vue/runtime-dom": "3.5.24",
"@vue/server-renderer": "3.5.24",
"@vue/shared": "3.5.24"
},
"peerDependencies": {
"typescript": "*"
@@ -2732,13 +2738,13 @@
}
},
"node_modules/vue-i18n": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.2.2",
"@intlify/shared": "11.2.2",
"@intlify/core-base": "11.1.12",
"@intlify/shared": "11.1.12",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@@ -23,14 +23,14 @@
"is-ip": "^5.0.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
"vite": "^7.2.2"
}
}

View File

@@ -85,7 +85,6 @@ const languageFlag = computed(() => {
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear())
const webBasePath = ref(WGPORTAL_BASE_PATH);
const userDisplayName = computed(() => {
let displayName = "Unknown";
@@ -114,7 +113,7 @@ const userDisplayName = computed(() => {
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<RouterLink class="navbar-brand" :to="{ name: 'home' }"><img :alt="companyName" :src="webBasePath + '/img/header-logo.png'" /></RouterLink>
<a class="navbar-brand" href="/"><img :alt="companyName" src="/img/header-logo.png" /></a>
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
<span class="navbar-toggler-icon"></span>

View File

@@ -9,8 +9,6 @@ const profile = profileStore()
const settings = settingsStore()
const auth = authStore()
const webBasePath = ref(WGPORTAL_BASE_PATH);
onMounted(async () => {
await profile.LoadUser()
await auth.LoadWebAuthnCredentials()
@@ -243,7 +241,7 @@ const updatePassword = async () => {
</button>
</div>
<div class="col-6">
<a :href="webBasePath + '/api/v1/doc.html'" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div>
</div>
</div>

59
go.mod
View File

@@ -5,7 +5,7 @@ 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.16.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
@@ -21,9 +21,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.39.0
golang.org/x/crypto v0.44.0
golang.org/x/oauth2 v0.33.0
golang.org/x/sys v0.38.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
@@ -34,27 +34,27 @@ require (
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.10 // 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-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-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
@@ -65,7 +65,7 @@ require (
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.6 // 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
@@ -77,32 +77,31 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect
github.com/microsoft/go-mssqldb v1.9.3 // 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.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // 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-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.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
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.40.1 // indirect
modernc.org/sqlite v1.39.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

138
go.sum
View File

@@ -20,8 +20,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/Azure/go-ntlmssp v0.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=
@@ -38,8 +38,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -50,8 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -62,33 +62,29 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-openapi/jsonpointer v0.22.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/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/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-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -121,8 +117,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/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,8 +129,6 @@ 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=
@@ -181,16 +175,16 @@ github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHi
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.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.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
github.com/microsoft/go-mssqldb v1.9.3/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/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=
@@ -203,17 +197,15 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/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=
@@ -270,18 +262,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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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 +291,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -310,8 +302,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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -332,8 +324,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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -362,16 +354,16 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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=
@@ -398,20 +390,18 @@ gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQ
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/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
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,8 +410,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/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=

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -6,9 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>WireGuard Portal API</title>
<meta name="description" content="WireGuard Portal API">
<link rel="stylesheet" href="{{$.BasePath}}/css/bootstrap.min.css">
<link rel="stylesheet" href="{{$.BasePath}}/fonts/fontawesome-all.min.css">
<link rel="shortcut icon" type="image/x-icon" href="{{$.BasePath}}/app/favicon.ico">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="shortcut icon" type="image/x-icon" href="/app/favicon.ico">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
@@ -25,7 +25,7 @@
<div class="card-header">SPA Api</div>
<div class="card-body">
<p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p>
<a href="{{$.BasePath}}/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
<a href="/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div>
</div>
</div>
@@ -34,7 +34,7 @@
<div class="card-header"><b>Version 1</b></div>
<div class="card-body">
<p class="card-text">This is the current main API endpoint.</p>
<a href="{{$.BasePath}}/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
<a href="/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div>
</div>
</div>
@@ -43,17 +43,17 @@
<div class="card-header">Version 2</div>
<div class="card-body">
<p class="card-text">This will be a future API version, it is currently work in progress.</p>
<a href="{{$.BasePath}}/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
<a href="/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div>
</div>
</div>
</div>
</div>
{{template "prt_footer.gohtml" .}}
<script src="{{$.BasePath}}/js/jquery.min.js"></script>
<script src="{{$.BasePath}}/js/jquery.easing.js"></script>
<script src="{{$.BasePath}}/js/popper.min.js"></script>
<script src="{{$.BasePath}}/js/bootstrap.bundle.min.js"></script>
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/"><img src="{{$.BasePath}}/img/header-logo.png" alt="Prolicht"/></a>
<a class="navbar-brand" href="/"><img src="/img/header-logo.png" alt="Prolicht"/></a>
<div id="topNavbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
</ul>

View File

@@ -22,7 +22,7 @@
allow-spec-file-load="false"
allow-spec-file-download="true"
>
<img slot="logo" src="{{$.BasePath}}/img/header-logo-small.png" style="width:50px; height:50px"/>
<img slot="logo" src="/img/header-logo-small.png" style="width:50px; height:50px"/>
<p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" >
Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}}

View File

@@ -4,14 +4,10 @@ import (
"context"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-pkgz/routegroup"
@@ -39,7 +35,6 @@ type ApiEndpointSetupFunc func() (ApiVersion, GroupSetupFn)
type Server struct {
cfg *config.Config
server *routegroup.Bundle
root *routegroup.Bundle // root is the web-root (potentially with path prefix)
tpl *respond.TemplateRenderer
versions map[ApiVersion]*routegroup.Bundle
}
@@ -80,42 +75,13 @@ func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server,
template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")),
)
// Mount base path if configured
s.root = s.server
if s.cfg.Web.BasePath != "" {
s.root = s.server.Mount(s.cfg.Web.BasePath)
}
// Serve static files (under base path if configured)
// Serve static files
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
s.root.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.root.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.root.HandleFiles("/img", imgFs)
s.root.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
if cfg.Web.BasePath == "" {
s.root.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
} else {
customV0File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v0_swagger.yaml")
customV1File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v1_swagger.yaml")
customV0File = []byte(strings.Replace(string(customV0File),
"basePath: /api/v0", "basePath: "+cfg.Web.BasePath+"/api/v0", 1))
customV1File = []byte(strings.Replace(string(customV1File),
"basePath: /api/v1", "basePath: "+cfg.Web.BasePath+"/api/v1", 1))
s.root.HandleFunc("GET /doc/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v0_swagger.yaml" {
respond.Data(w, http.StatusOK, "application/yaml", customV0File)
return
}
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v1_swagger.yaml" {
respond.Data(w, http.StatusOK, "application/yaml", customV1File)
return
}
respond.Status(w, http.StatusNotFound)
})
}
s.server.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.server.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.server.HandleFiles("/img", imgFs)
s.server.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
s.server.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
// Setup routes
s.setupRoutes(endpoints...)
@@ -160,14 +126,14 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
}
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
s.root.HandleFunc("GET /api", s.landingPage)
s.server.HandleFunc("GET /api", s.landingPage)
s.versions = make(map[ApiVersion]*routegroup.Bundle)
for _, setupFunc := range endpoints {
version, groupSetupFn := setupFunc()
if _, ok := s.versions[version]; !ok {
s.versions[version] = s.root.Mount(fmt.Sprintf("/api/%s", version))
s.versions[version] = s.server.Mount(fmt.Sprintf("/api/%s", version))
// OpenAPI documentation (via RapiDoc)
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
@@ -181,80 +147,19 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
func (s *Server) setupFrontendRoutes() {
// Serve static files
s.root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app")
s.server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app")
})
s.root.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app/favicon.ico")
s.server.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
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.
useEmbeddedFrontend := true
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)
useEmbeddedFrontend = false
}
}
}
var fileServer http.Handler
if useEmbeddedFrontend {
fileServer = http.FileServer(http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
} else {
fileServer = http.FileServer(http.Dir(s.cfg.Web.FrontendFilePath))
}
fileServer = http.StripPrefix(s.cfg.Web.BasePath+"/app", fileServer)
// Modify index.html and CSS to include the correct base path.
var customIndexFile, customCssFile []byte
var customCssFileName string
if s.cfg.Web.BasePath != "" {
customIndexFile, customCssFile, customCssFileName = s.updateBasePathInFrontend(useEmbeddedFrontend)
}
s.root.HandleFunc("GET /app/", func(w http.ResponseWriter, r *http.Request) {
// serve a custom index.html file with the correct base path applied
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/" {
respond.Data(w, http.StatusOK, "text/html", customIndexFile)
return
}
// serve a custom CSS file with the correct base path applied
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/assets/"+customCssFileName {
respond.Data(w, http.StatusOK, "text/css", customCssFile)
return
}
// pass all other requests to the file server
fileServer.ServeHTTP(w, r)
})
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
}
func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{
"BasePath": s.cfg.Web.BasePath,
"Version": internal.Version,
"Year": time.Now().Year(),
})
@@ -263,111 +168,17 @@ func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{
"RapiDocSource": s.cfg.Web.BasePath + "/js/rapidoc-min.js",
"BasePath": s.cfg.Web.BasePath,
"ApiSpecUrl": fmt.Sprintf("%s/doc/%s_swagger.yaml", s.cfg.Web.BasePath, version),
"RapiDocSource": "/js/rapidoc-min.js",
"ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version),
"Version": internal.Version,
"Year": time.Now().Year(),
})
}
}
func (s *Server) updateBasePathInFrontend(useEmbeddedFrontend bool) ([]byte, []byte, string) {
if s.cfg.Web.BasePath == "" {
return nil, nil, "" // nothing to do
}
var customIndexFile []byte
if useEmbeddedFrontend {
customIndexFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "index.html")
} else {
customIndexFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "index.html"))
}
newIndexStr := strings.ReplaceAll(string(customIndexFile), "src=\"/", "src=\""+s.cfg.Web.BasePath+"/")
newIndexStr = strings.ReplaceAll(newIndexStr, "href=\"/", "href=\""+s.cfg.Web.BasePath+"/")
re := regexp.MustCompile(`/app/assets/(index-.+.css)`)
match := re.FindStringSubmatch(newIndexStr)
cssFileName := match[1]
var customCssFile []byte
if useEmbeddedFrontend {
customCssFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "assets/"+cssFileName)
} else {
customCssFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "/assets/", cssFileName))
}
newCssStr := strings.ReplaceAll(string(customCssFile), "/app/assets/", s.cfg.Web.BasePath+"/app/assets/")
return []byte(newIndexStr), []byte(newCssStr), cssFileName
}
func fsMust(f fs.FS, err error) fs.FS {
if err != nil {
panic(err)
}
return f
}
// dirHasFiles returns true if the directory contains at least one file (non-directory).
func dirHasFiles(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
defer d.Close()
// Read a few entries; if any entry exists, consider it having files/dirs.
// We want to know if there is at least one file; if only subdirs exist, still treat as content.
entries, err := d.Readdir(-1)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
// check recursively
has, err := dirHasFiles(filepath.Join(dir, e.Name()))
if err == nil && has {
return true, nil
}
continue
}
// regular file
return true, nil
}
return false, nil
}
// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk.
func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error {
return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dstDir, path)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
// ensure parent dir exists
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
// open source file
f, err := srcFS.Open(path)
if err != nil {
return err
}
defer f.Close()
out, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(out, f); err != nil {
_ = out.Close()
return err
}
return out.Close()
})
}

View File

@@ -67,8 +67,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
// @Router /config/frontend.js [get]
func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
basePath := e.cfg.Web.BasePath
backendUrl := fmt.Sprintf("%s%s/api/v0", e.cfg.Web.ExternalUrl, basePath)
backendUrl := fmt.Sprintf("%s/api/v0", e.cfg.Web.ExternalUrl)
if request.Header(r, "x-wg-dev") != "" {
referer := request.Header(r, "Referer")
host := "localhost"
@@ -77,13 +76,12 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
if err == nil {
host, port, _ = net.SplitHostPort(parsedReferer.Host)
}
backendUrl = fmt.Sprintf("http://%s:%s%s/api/v0", host,
port, basePath) // override if request comes from frontend started with npm run dev
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host,
port) // override if request comes from frontend started with npm run dev
}
e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{
"BackendUrl": backendUrl,
"BasePath": basePath,
"Version": internal.Version,
"SiteTitle": e.cfg.Web.SiteTitle,
"SiteCompanyName": e.cfg.Web.SiteCompanyName,

View File

@@ -1,5 +1,4 @@
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
WGPORTAL_BASE_PATH="{{ $.BasePath }}";
WGPORTAL_VERSION="{{ $.Version }}";
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";

View File

@@ -49,11 +49,7 @@ func NewSessionWrapper(cfg *config.Config) *SessionWrapper {
sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https")
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
if cfg.Web.BasePath != "" {
sessionManager.Cookie.Path = cfg.Web.BasePath
} else {
sessionManager.Cookie.Path = "/"
}
sessionManager.Cookie.Persist = false
wrappedSessionManager := &SessionWrapper{sessionManager}

View File

@@ -99,7 +99,7 @@ type Authenticator struct {
}
// NewAuthenticator creates a new Authenticator instance.
func NewAuthenticator(cfg *config.Auth, extUrl, basePath string, bus EventBus, users UserManager) (
func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserManager) (
*Authenticator,
error,
) {
@@ -107,7 +107,7 @@ func NewAuthenticator(cfg *config.Auth, extUrl, basePath string, bus EventBus, u
cfg: cfg,
bus: bus,
users: users,
callbackUrlPrefix: fmt.Sprintf("%s%s/api/v0", extUrl, basePath),
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl),
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
}

View File

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

View File

@@ -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,51 +43,6 @@ 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
@@ -136,7 +52,6 @@ func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reade
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
@@ -146,7 +61,6 @@ func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reade
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)

View File

@@ -44,10 +44,6 @@ func (c *ControllerManager) init() error {
return err
}
if err := c.registerPfsenseControllers(); err != nil {
return err
}
c.logRegisteredControllers()
return nil
@@ -90,26 +86,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",

View File

@@ -7,7 +7,6 @@ import (
"log/slog"
"os"
"slices"
"strings"
"time"
"github.com/h44z/wg-portal/internal/app"
@@ -218,6 +217,15 @@ func (m Manager) RestoreInterfaceState(
if err != nil && !iface.IsDisabled() {
slog.Debug("creating missing interface", "interface", iface.Identifier, "backend", controller.GetId())
// temporarily disable interface in database so that the current state is reflected correctly
_ = m.db.SaveInterface(ctx, iface.Identifier,
func(in *domain.Interface) (*domain.Interface, error) {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = domain.DisabledReasonInterfaceMissing
return in, nil
})
// temporarily disable interface in database so that the current state is reflected correctly
_ = m.db.SaveInterface(ctx, iface.Identifier,
func(in *domain.Interface) (*domain.Interface, error) {
@@ -868,17 +876,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 +913,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)

View File

@@ -18,7 +18,6 @@ type Backend struct {
// External Backend-specific configuration
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
Pfsense []BackendPfsense `yaml:"pfsense"`
}
// Validate checks the backend configuration for errors.
@@ -37,15 +36,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 {
@@ -111,42 +101,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
}

View File

@@ -149,7 +149,6 @@ func defaultConfig() *Config {
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"),
BasePath: getEnvStr("WG_PORTAL_WEB_BASE_PATH", ""),
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"),
@@ -158,7 +157,6 @@ func defaultConfig() *Config {
SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"),
CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""),
KeyFile: getEnvStr("WG_PORTAL_WEB_KEY_FILE", ""),
FrontendFilePath: getEnvStr("WG_PORTAL_WEB_FRONTEND_FILEPATH", ""),
}
cfg.Advanced.LogLevel = getEnvStr("WG_PORTAL_ADVANCED_LOG_LEVEL", "info")
@@ -197,7 +195,6 @@ func defaultConfig() *Config {
From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"),
LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false),
AllowPeerEmail: getEnvBool("WG_PORTAL_MAIL_ALLOW_PEER_EMAIL", false),
TemplatesPath: getEnvStr("WG_PORTAL_MAIL_TEMPLATES_PATH", ""),
}
cfg.Webhook.Url = getEnvStr("WG_PORTAL_WEBHOOK_URL", "") // no webhook by default

View File

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

View File

@@ -11,10 +11,6 @@ type WebConfig struct {
// ExternalUrl is the URL where a client can access WireGuard Portal.
// This is used for the callback URL of the OAuth providers.
ExternalUrl string `yaml:"external_url"`
// BasePath is an optional URL path prefix under which the whole web UI and API are served.
// Example: "/wg" will make the UI available at /wg/app and the API at /wg/api.
// Empty string means no prefix (served from root path).
BasePath string `yaml:"base_path"`
// ListeningAddress is the address and port for the web server.
ListeningAddress string `yaml:"listening_address"`
// SessionIdentifier is the session identifier for the web frontend.
@@ -31,20 +27,8 @@ 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() {
c.ExternalUrl = strings.TrimRight(c.ExternalUrl, "/")
// normalize BasePath: allow empty, otherwise ensure leading slash, no trailing slash
p := strings.TrimSpace(c.BasePath)
p = strings.TrimRight(p, "/")
if p != "" && !strings.HasPrefix(p, "/") {
p = "/" + p
}
c.BasePath = p
}

View File

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

View File

@@ -240,8 +240,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 +303,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,12 +325,6 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
Disabled: i.IsDisabled(),
}
pi.SetExtras(extras)
case ControllerTypePfsense:
extras := PfsenseInterfaceExtras{
Comment: i.DisplayName,
Disabled: i.IsDisabled(),
}
pi.SetExtras(extras)
}
}

View File

@@ -240,8 +240,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 +301,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
@@ -376,18 +355,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)
}
}

View File

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

View File

@@ -81,7 +81,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 +88,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