mirror of
https://github.com/h44z/wg-portal.git
synced 2025-12-14 18:46:17 +00:00
Compare commits
5 Commits
stable
...
custom_tem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee454c5d60 | ||
|
|
fb607a26b7 | ||
|
|
54ca1d8aed | ||
|
|
a318118ee6 | ||
|
|
a8b4b23742 |
6
.github/workflows/chart.yml
vendored
6
.github/workflows/chart.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
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@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
|
||||
6
.github/workflows/docker-publish.yml
vendored
6
.github/workflows/docker-publish.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- 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@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
wgportal/wg-portal
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
name: binaries
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
files: 'wg-portal_linux*'
|
||||
generate_release_notes: true
|
||||
|
||||
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ COPY --from=builder /build/dist/wg-portal /
|
||||
######
|
||||
# Final image
|
||||
######
|
||||
FROM alpine:3.22
|
||||
FROM alpine:3.23
|
||||
# Install OS-level dependencies
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
|
||||
# Setup timezone
|
||||
|
||||
@@ -32,7 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
||||
* Docker ready
|
||||
* Can be used with existing WireGuard setups
|
||||
* Support for multiple WireGuard interfaces
|
||||
* Supports multiple WireGuard backends (wgctrl or MikroTik)
|
||||
* Supports multiple WireGuard backends (wgctrl, MikroTik, or pfSense)
|
||||
* Peer Expiry Feature
|
||||
* Handles route and DNS settings like wg-quick does
|
||||
* Exposes Prometheus metrics for monitoring and alerting
|
||||
|
||||
@@ -12,6 +12,18 @@ core:
|
||||
web:
|
||||
external_url: http://localhost:8888
|
||||
request_logging: true
|
||||
# Optional path where custom frontend files are stored.
|
||||
# If this folder contains at least one file, it will override the embedded frontend.
|
||||
# If the folder is empty or does not exist on startup, the embedded frontend will be
|
||||
# written into it. Leave empty to use the embedded frontend only.
|
||||
frontend_filepath: ""
|
||||
|
||||
mail:
|
||||
# Path where custom email templates (.gotpl and .gohtml) are stored.
|
||||
# If the directory is empty on startup, the default embedded templates
|
||||
# will be written there so you can modify them.
|
||||
# Leave empty to use embedded templates only.
|
||||
templates_path: ""
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
@@ -94,3 +106,15 @@ 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
|
||||
@@ -74,6 +74,7 @@ mail:
|
||||
from: Wireguard Portal <noreply@wireguard.local>
|
||||
link_only: false
|
||||
allow_peer_email: false
|
||||
templates_path: ""
|
||||
|
||||
auth:
|
||||
oidc: []
|
||||
@@ -96,6 +97,7 @@ web:
|
||||
expose_host_info: false
|
||||
cert_file: ""
|
||||
key_File: ""
|
||||
frontend_filepath: ""
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
@@ -485,6 +487,11 @@ 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
|
||||
@@ -841,6 +848,14 @@ 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
|
||||
|
||||
@@ -8,6 +8,7 @@ A global default backend determines where newly created interfaces go (unless yo
|
||||
**Supported backends:**
|
||||
- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server.
|
||||
- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+.
|
||||
- **pfSense** (_alpha_): Manages interfaces and peers on pfSense firewalls via the pfSense REST API.
|
||||
|
||||
How backend selection works:
|
||||
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend).
|
||||
@@ -55,3 +56,36 @@ 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.
|
||||
|
||||
49
docs/documentation/usage/mail-templates.md
Normal file
49
docs/documentation/usage/mail-templates.md
Normal file
@@ -0,0 +1,49 @@
|
||||
WireGuard Portal sends emails when you share a configuration with a user.
|
||||
By default, the application uses embedded templates. You can fully customize these emails by pointing the Portal
|
||||
to a folder containing your own templates. If the folder is empty on startup, the default embedded templates
|
||||
are written there to get you started.
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable custom templates, set the `mail.templates_path` option in the application configuration file
|
||||
or the `WG_PORTAL_MAIL_TEMPLATES_PATH` environment variable to a valid folder path.
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
mail:
|
||||
# ... other mail options ...
|
||||
# Path where custom email templates (.gotpl and .gohtml) are stored.
|
||||
# If the directory is empty on startup, the default embedded templates
|
||||
# will be written there so you can modify them.
|
||||
# Leave empty to use embedded templates only.
|
||||
templates_path: "/opt/wg-portal/mail-templates"
|
||||
```
|
||||
|
||||
## Template files and names
|
||||
|
||||
The system expects the following template names. Place files with these names in your `templates_path` to override the defaults.
|
||||
You do not need to override all templates, only the ones you want to customize should be present.
|
||||
|
||||
- Text templates (`.gotpl`):
|
||||
- `mail_with_link.gotpl`
|
||||
- `mail_with_attachment.gotpl`
|
||||
- HTML templates (`.gohtml`):
|
||||
- `mail_with_link.gohtml`
|
||||
- `mail_with_attachment.gohtml`
|
||||
|
||||
Both [text](https://pkg.go.dev/text/template) and [HTML templates](https://pkg.go.dev/html/template) are standard Go
|
||||
templates and receive the following data fields, depending on the email type:
|
||||
|
||||
- Common fields:
|
||||
- `PortalUrl` (string) - external URL of the Portal
|
||||
- `PortalName` (string) - site title/company name
|
||||
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
|
||||
- Link email (`mail_with_link.*`):
|
||||
- `Link` (string) - the download link
|
||||
- Attachment email (`mail_with_attachment.*`):
|
||||
- `ConfigFileName` (string) - filename of the attached WireGuard config
|
||||
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
|
||||
|
||||
Tip: You can inspect the embedded templates in the repository under [`internal/app/mail/tpl_files/`](https://github.com/h44z/wg-portal/tree/master/internal/app/mail/tpl_files) for reference.
|
||||
When the directory at `templates_path` is empty, these files are copied to your folder so you can edit them in place.
|
||||
180
frontend/package-lock.json
generated
180
frontend/package-lock.json
generated
@@ -23,15 +23,15 @@
|
||||
"is-ip": "^5.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-prism-component": "github:h44z/vue-prism-component",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -548,13 +548,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
|
||||
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
|
||||
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "11.1.12",
|
||||
"@intlify/shared": "11.1.12"
|
||||
"@intlify/message-compiler": "11.2.2",
|
||||
"@intlify/shared": "11.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -564,12 +564,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
|
||||
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
|
||||
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.1.12",
|
||||
"@intlify/shared": "11.2.2",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -580,9 +580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
|
||||
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
|
||||
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -921,16 +921,15 @@
|
||||
"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.29",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
|
||||
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==",
|
||||
"version": "1.0.0-beta.50",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
|
||||
"integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1256,13 +1255,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
|
||||
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-beta.29"
|
||||
"@rolldown/pluginutils": "1.0.0-beta.50"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -1287,39 +1286,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
|
||||
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/shared": "3.5.25",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
|
||||
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
|
||||
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
|
||||
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-ssr": "3.5.25",
|
||||
"@vue/shared": "3.5.25",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -1327,13 +1326,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"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==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
|
||||
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -1370,53 +1369,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
|
||||
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
|
||||
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/reactivity": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
|
||||
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/reactivity": "3.5.25",
|
||||
"@vue/runtime-core": "3.5.25",
|
||||
"@vue/shared": "3.5.25",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.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==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
|
||||
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-ssr": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.24"
|
||||
"vue": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
|
||||
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
|
||||
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
@@ -1565,9 +1564,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
@@ -2079,7 +2078,6 @@
|
||||
"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",
|
||||
@@ -2571,7 +2569,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2608,12 +2605,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -2707,7 +2703,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2716,17 +2711,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
|
||||
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-sfc": "3.5.24",
|
||||
"@vue/runtime-dom": "3.5.24",
|
||||
"@vue/server-renderer": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-sfc": "3.5.25",
|
||||
"@vue/runtime-dom": "3.5.25",
|
||||
"@vue/server-renderer": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
@@ -2738,13 +2732,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
|
||||
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
|
||||
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.1.12",
|
||||
"@intlify/shared": "11.1.12",
|
||||
"@intlify/core-base": "11.2.2",
|
||||
"@intlify/shared": "11.2.2",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
"is-ip": "^5.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-prism-component": "github:h44z/vue-prism-component",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
59
go.mod
59
go.mod
@@ -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.16.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-pkgz/routegroup v1.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.44.0
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
@@ -34,27 +34,27 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/spec v0.22.2 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
@@ -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.6 // indirect
|
||||
github.com/google/go-tpm v0.9.7 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
@@ -77,31 +77,32 @@ require (
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.3 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
|
||||
golang.org/x/mod v0.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.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/libc v1.67.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.39.0 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
138
go.sum
138
go.sum
@@ -20,8 +20,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
@@ -38,8 +38,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -50,8 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
@@ -62,29 +62,33 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
|
||||
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
|
||||
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -117,8 +121,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -129,6 +133,8 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -175,16 +181,16 @@ github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHi
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
|
||||
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
@@ -197,15 +203,17 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -262,18 +270,18 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.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/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -291,10 +299,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -302,8 +310,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -324,8 +332,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -354,16 +362,16 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
@@ -390,18 +398,20 @@ 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.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/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -410,8 +420,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
979
internal/adapters/wgcontroller/pfsense.go
Normal file
979
internal/adapters/wgcontroller/pfsense.go
Normal file
@@ -0,0 +1,979 @@
|
||||
package wgcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
// PfsenseController implements the InterfaceController interface for pfSense firewalls.
|
||||
// It uses the pfSense REST API (https://pfrest.org/) to manage WireGuard interfaces and peers.
|
||||
// API endpoint paths and field names should be verified against the Swagger documentation:
|
||||
// https://pfrest.org/api-docs/
|
||||
|
||||
type PfsenseController struct {
|
||||
coreCfg *config.Config
|
||||
cfg *config.BackendPfsense
|
||||
|
||||
client *lowlevel.PfsenseApiClient
|
||||
|
||||
// Add mutexes to prevent race conditions
|
||||
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
|
||||
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
|
||||
coreMutex sync.Mutex // for updating the core configuration such as routing table or DNS settings
|
||||
}
|
||||
|
||||
func NewPfsenseController(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseController, error) {
|
||||
client, err := lowlevel.NewPfsenseApiClient(coreCfg, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pfSense API client: %w", err)
|
||||
}
|
||||
|
||||
return &PfsenseController{
|
||||
coreCfg: coreCfg,
|
||||
cfg: cfg,
|
||||
|
||||
client: client,
|
||||
|
||||
interfaceMutexes: sync.Map{},
|
||||
peerMutexes: sync.Map{},
|
||||
coreMutex: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) GetId() domain.InterfaceBackend {
|
||||
return domain.InterfaceBackend(c.cfg.Id)
|
||||
}
|
||||
|
||||
// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications
|
||||
func (c *PfsenseController) getInterfaceMutex(id domain.InterfaceIdentifier) *sync.Mutex {
|
||||
mutex, _ := c.interfaceMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||
return mutex.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// getPeerMutex returns a mutex for the given peer to prevent concurrent modifications
|
||||
func (c *PfsenseController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex {
|
||||
mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||
return mutex.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// region wireguard-related
|
||||
|
||||
func (c *PfsenseController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
|
||||
// Query WireGuard tunnels from pfSense API
|
||||
// Using pfSense REST API v2 endpoints: GET /api/v2/vpn/wireguard/tunnels
|
||||
// Field names should be verified against Swagger docs: https://pfrest.org/api-docs/
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query interfaces: %v", wgReply.Error)
|
||||
}
|
||||
|
||||
// Parallelize loading of interface details to speed up overall latency.
|
||||
// Use a bounded semaphore to avoid overloading the pfSense device.
|
||||
maxConcurrent := c.cfg.GetConcurrency()
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
interfaces := make([]domain.PhysicalInterface, 0, len(wgReply.Data))
|
||||
var mu sync.Mutex
|
||||
var wgWait sync.WaitGroup
|
||||
var firstErr error
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
for _, wgObj := range wgReply.Data {
|
||||
wgWait.Add(1)
|
||||
sem <- struct{}{} // block if more than maxConcurrent requests are processing
|
||||
go func(wg lowlevel.GenericJsonObject) {
|
||||
defer wgWait.Done()
|
||||
defer func() { <-sem }() // read from the semaphore and make space for the next entry
|
||||
if firstErr != nil {
|
||||
return
|
||||
}
|
||||
pi, err := c.loadInterfaceData(ctx2, wg)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
interfaces = append(interfaces, *pi)
|
||||
mu.Unlock()
|
||||
}(wgObj)
|
||||
}
|
||||
|
||||
wgWait.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
// First, get the tunnel ID by querying by name
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error)
|
||||
}
|
||||
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil, fmt.Errorf("interface %s not found", id)
|
||||
}
|
||||
|
||||
tunnelId := wgReply.Data[0].GetString("id")
|
||||
|
||||
// Query the specific tunnel endpoint to get full details including addresses
|
||||
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
if tunnelId != "" {
|
||||
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"id": tunnelId,
|
||||
},
|
||||
})
|
||||
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
|
||||
// Use the detailed tunnel response which includes addresses
|
||||
return c.loadInterfaceData(ctx, tunnelReply.Data)
|
||||
}
|
||||
// Fall back to list response if detail query fails
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("failed to query detailed tunnel info, using list response", "interface", id, "tunnel_id", tunnelId)
|
||||
}
|
||||
}
|
||||
|
||||
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||
}
|
||||
|
||||
func (c *PfsenseController) loadInterfaceData(
|
||||
ctx context.Context,
|
||||
wireGuardObj lowlevel.GenericJsonObject,
|
||||
) (*domain.PhysicalInterface, error) {
|
||||
deviceName := wireGuardObj.GetString("name")
|
||||
deviceId := wireGuardObj.GetString("id")
|
||||
|
||||
// Extract addresses from the tunnel data
|
||||
// The tunnel response may include an "addresses" array when queried via /tunnel?id={id}
|
||||
addresses := c.extractAddresses(wireGuardObj, nil)
|
||||
|
||||
// If addresses weren't found in the tunnel object and we have a tunnel ID,
|
||||
// query the specific tunnel endpoint to get full details including addresses
|
||||
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
if len(addresses) == 0 && deviceId != "" {
|
||||
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"id": deviceId,
|
||||
},
|
||||
})
|
||||
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
|
||||
// Extract addresses from the detailed tunnel response
|
||||
parsedAddrs := c.extractAddresses(tunnelReply.Data, nil)
|
||||
if len(parsedAddrs) > 0 {
|
||||
addresses = parsedAddrs
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("loaded addresses from detailed tunnel query", "interface", deviceName, "count", len(addresses))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, nil, addresses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err)
|
||||
}
|
||||
return &interfaceModel, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) extractAddresses(
|
||||
wgObj lowlevel.GenericJsonObject,
|
||||
ifaceObj lowlevel.GenericJsonObject,
|
||||
) []domain.Cidr {
|
||||
addresses := make([]domain.Cidr, 0)
|
||||
|
||||
// Try to get addresses from ifaceObj first
|
||||
if ifaceObj != nil {
|
||||
addrStr := ifaceObj.GetString("addresses")
|
||||
if addrStr != "" {
|
||||
// Addresses might be comma-separated or in an array
|
||||
addrs, _ := domain.CidrsFromString(addrStr)
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get addresses from wgObj - check if it's an array first
|
||||
if len(addresses) == 0 {
|
||||
if addressesValue, ok := wgObj["addresses"]; ok && addressesValue != nil {
|
||||
if addressesArray, ok := addressesValue.([]any); ok {
|
||||
// Parse addresses array (from /tunnel?id={id} response)
|
||||
// Each object has "address" and "mask" fields
|
||||
for _, addrItem := range addressesArray {
|
||||
if addrObj, ok := addrItem.(map[string]any); ok {
|
||||
address := ""
|
||||
mask := 0
|
||||
|
||||
// Extract address
|
||||
if addrVal, ok := addrObj["address"]; ok {
|
||||
if addrStr, ok := addrVal.(string); ok {
|
||||
address = addrStr
|
||||
} else {
|
||||
address = fmt.Sprintf("%v", addrVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mask
|
||||
if maskVal, ok := addrObj["mask"]; ok {
|
||||
if maskInt, ok := maskVal.(int); ok {
|
||||
mask = maskInt
|
||||
} else if maskFloat, ok := maskVal.(float64); ok {
|
||||
mask = int(maskFloat)
|
||||
} else if maskStr, ok := maskVal.(string); ok {
|
||||
if maskInt, err := strconv.Atoi(maskStr); err == nil {
|
||||
mask = maskInt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to CIDR format
|
||||
if address != "" && mask > 0 {
|
||||
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
} else if address != "" {
|
||||
// Try parsing as CIDR string directly
|
||||
if cidr, err := domain.CidrFromString(address); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if addrStr, ok := addressesValue.(string); ok {
|
||||
// Fallback: try parsing as comma-separated string
|
||||
addrs, _ := domain.CidrsFromString(addrStr)
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
} else {
|
||||
// Try as string field
|
||||
addrStr := wgObj.GetString("addresses")
|
||||
if addrStr != "" {
|
||||
addrs, _ := domain.CidrsFromString(addrStr)
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
// parseAddressArray parses an array of address objects from the pfSense API
|
||||
// Each object has "address" and "mask" fields (similar to allowedips structure)
|
||||
func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr {
|
||||
addresses := make([]domain.Cidr, 0, len(addressArray))
|
||||
|
||||
for _, addrObj := range addressArray {
|
||||
address := addrObj.GetString("address")
|
||||
mask := addrObj.GetInt("mask")
|
||||
|
||||
if address != "" && mask > 0 {
|
||||
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
} else if address != "" {
|
||||
// Try parsing as CIDR string directly
|
||||
if cidr, err := domain.CidrFromString(address); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
func (c *PfsenseController) convertWireGuardInterface(
|
||||
wg, iface lowlevel.GenericJsonObject,
|
||||
addresses []domain.Cidr,
|
||||
) (
|
||||
domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
// Map pfSense field names to our domain model
|
||||
// Field names should be verified against the Swagger UI: https://pfrest.org/api-docs/
|
||||
// The implementation attempts to handle both camelCase and kebab-case variations
|
||||
privateKey := wg.GetString("privatekey")
|
||||
if privateKey == "" {
|
||||
privateKey = wg.GetString("private-key")
|
||||
}
|
||||
publicKey := wg.GetString("publickey")
|
||||
if publicKey == "" {
|
||||
publicKey = wg.GetString("public-key")
|
||||
}
|
||||
|
||||
listenPort := wg.GetInt("listenport")
|
||||
if listenPort == 0 {
|
||||
listenPort = wg.GetInt("listen-port")
|
||||
}
|
||||
|
||||
mtu := wg.GetInt("mtu")
|
||||
running := wg.GetBool("running")
|
||||
disabled := wg.GetBool("disabled")
|
||||
|
||||
// TODO: Interface statistics (rx/tx bytes) are not currently supported
|
||||
// by the pfSense REST API. This functionality is reserved for future implementation.
|
||||
var rxBytes, txBytes uint64
|
||||
|
||||
pi := domain.PhysicalInterface{
|
||||
Identifier: domain.InterfaceIdentifier(wg.GetString("name")),
|
||||
KeyPair: domain.KeyPair{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
},
|
||||
ListenPort: listenPort,
|
||||
Addresses: addresses,
|
||||
Mtu: mtu,
|
||||
FirewallMark: 0,
|
||||
DeviceUp: running && !disabled,
|
||||
ImportSource: domain.ControllerTypePfsense,
|
||||
DeviceType: domain.ControllerTypePfsense,
|
||||
BytesUpload: txBytes,
|
||||
BytesDownload: rxBytes,
|
||||
}
|
||||
|
||||
// Extract description - pfSense API uses "descr" field
|
||||
description := wg.GetString("descr")
|
||||
if description == "" {
|
||||
description = wg.GetString("description")
|
||||
}
|
||||
if description == "" {
|
||||
description = wg.GetString("comment")
|
||||
}
|
||||
|
||||
pi.SetExtras(domain.PfsenseInterfaceExtras{
|
||||
Id: wg.GetString("id"),
|
||||
Comment: description,
|
||||
Disabled: disabled,
|
||||
})
|
||||
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) (
|
||||
[]domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
// Query all peers and filter by interface client-side
|
||||
// Using pfSense REST API v2 endpoints (https://pfrest.org/)
|
||||
// The API uses query parameters like ?id=0 for specific items, but we need to filter
|
||||
// by interface (tun field), so we fetch all peers and filter client-side
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error)
|
||||
}
|
||||
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Filter peers client-side by checking the "tun" field in each peer
|
||||
// pfSense peer responses use "tun" field to indicate which tunnel/interface the peer belongs to
|
||||
peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data))
|
||||
for _, peer := range wgReply.Data {
|
||||
// Check if this peer belongs to the requested interface
|
||||
// pfSense uses "tun" field with the interface name (e.g., "tun_wg0")
|
||||
peerTun := peer.GetString("tun")
|
||||
if peerTun == "" {
|
||||
// Try alternative field names as fallback
|
||||
peerTun = peer.GetString("interface")
|
||||
if peerTun == "" {
|
||||
peerTun = peer.GetString("tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
// Only include peers that match the requested interface name
|
||||
if peerTun != string(deviceId) {
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("skipping peer - interface mismatch",
|
||||
"peer", peer.GetString("name"),
|
||||
"peer_tun", peerTun,
|
||||
"requested_interface", deviceId,
|
||||
"peer_id", peer.GetString("id"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Use peer data directly from the list response
|
||||
peerModel, err := c.convertWireGuardPeer(peer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer convert failed for %v: %w", peer.GetString("name"), err)
|
||||
}
|
||||
peers = append(peers, peerModel)
|
||||
}
|
||||
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("filtered peers for interface",
|
||||
"interface", deviceId,
|
||||
"total_peers_from_api", len(wgReply.Data),
|
||||
"filtered_peers", len(peers))
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) (
|
||||
domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
publicKey := peer.GetString("publickey")
|
||||
if publicKey == "" {
|
||||
publicKey = peer.GetString("public-key")
|
||||
}
|
||||
|
||||
privateKey := peer.GetString("privatekey")
|
||||
if privateKey == "" {
|
||||
privateKey = peer.GetString("private-key")
|
||||
}
|
||||
|
||||
presharedKey := peer.GetString("presharedkey")
|
||||
if presharedKey == "" {
|
||||
presharedKey = peer.GetString("preshared-key")
|
||||
}
|
||||
|
||||
// pfSense returns allowedips as an array of objects with "address" and "mask" fields
|
||||
// Example: [{"address": "10.1.2.3", "mask": 32, ...}, ...]
|
||||
var allowedAddresses []domain.Cidr
|
||||
if allowedIPsValue, ok := peer["allowedips"]; ok {
|
||||
if allowedIPsArray, ok := allowedIPsValue.([]any); ok {
|
||||
// Parse array of objects
|
||||
for _, item := range allowedIPsArray {
|
||||
if itemObj, ok := item.(map[string]any); ok {
|
||||
address := ""
|
||||
mask := 0
|
||||
|
||||
// Extract address
|
||||
if addrVal, ok := itemObj["address"]; ok {
|
||||
if addrStr, ok := addrVal.(string); ok {
|
||||
address = addrStr
|
||||
} else {
|
||||
address = fmt.Sprintf("%v", addrVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mask
|
||||
if maskVal, ok := itemObj["mask"]; ok {
|
||||
if maskInt, ok := maskVal.(int); ok {
|
||||
mask = maskInt
|
||||
} else if maskFloat, ok := maskVal.(float64); ok {
|
||||
mask = int(maskFloat)
|
||||
} else if maskStr, ok := maskVal.(string); ok {
|
||||
if maskInt, err := strconv.Atoi(maskStr); err == nil {
|
||||
mask = maskInt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to CIDR format (e.g., "10.1.2.3/32")
|
||||
if address != "" && mask > 0 {
|
||||
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||
allowedAddresses = append(allowedAddresses, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if allowedIPsStr, ok := allowedIPsValue.(string); ok {
|
||||
// Fallback: try parsing as comma-separated string
|
||||
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to string parsing if array parsing didn't work
|
||||
if len(allowedAddresses) == 0 {
|
||||
allowedIPsStr := peer.GetString("allowedips")
|
||||
if allowedIPsStr == "" {
|
||||
allowedIPsStr = peer.GetString("allowed-ips")
|
||||
}
|
||||
if allowedIPsStr != "" {
|
||||
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
|
||||
}
|
||||
}
|
||||
|
||||
endpoint := peer.GetString("endpoint")
|
||||
port := peer.GetString("port")
|
||||
|
||||
// Combine endpoint and port if both are available
|
||||
if endpoint != "" && port != "" {
|
||||
// Check if endpoint already contains a port
|
||||
if !strings.Contains(endpoint, ":") {
|
||||
endpoint = fmt.Sprintf("%s:%s", endpoint, port)
|
||||
}
|
||||
} else if endpoint == "" && port != "" {
|
||||
// If only port is available, we can't construct a full endpoint
|
||||
// This might be used with the interface's listenport
|
||||
}
|
||||
|
||||
keepAliveSeconds := 0
|
||||
keepAliveStr := peer.GetString("persistentkeepalive")
|
||||
if keepAliveStr == "" {
|
||||
keepAliveStr = peer.GetString("persistent-keepalive")
|
||||
}
|
||||
if keepAliveStr != "" {
|
||||
duration, err := time.ParseDuration(keepAliveStr)
|
||||
if err == nil {
|
||||
keepAliveSeconds = int(duration.Seconds())
|
||||
} else {
|
||||
// Try parsing as integer (seconds)
|
||||
if secs, err := strconv.Atoi(keepAliveStr); err == nil {
|
||||
keepAliveSeconds = secs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Peer statistics (last handshake, rx/tx bytes) are not currently supported
|
||||
// by the pfSense REST API. This functionality is reserved for future implementation
|
||||
// when the API adds support for these fields.
|
||||
// See: https://github.com/jaredhendrickson13/pfsense-api/issues (issue opened by user)
|
||||
//
|
||||
// When supported, extract fields like:
|
||||
// - lastHandshake: peer.GetString("lasthandshake") or peer.GetString("last-handshake")
|
||||
// - rxBytes: peer.GetInt("rxbytes") or peer.GetInt("rx-bytes")
|
||||
// - txBytes: peer.GetInt("txbytes") or peer.GetInt("tx-bytes")
|
||||
lastHandshakeTime := time.Time{}
|
||||
rxBytes := uint64(0)
|
||||
txBytes := uint64(0)
|
||||
|
||||
peerModel := domain.PhysicalPeer{
|
||||
Identifier: domain.PeerIdentifier(publicKey),
|
||||
Endpoint: endpoint,
|
||||
AllowedIPs: allowedAddresses,
|
||||
KeyPair: domain.KeyPair{
|
||||
PublicKey: publicKey,
|
||||
PrivateKey: privateKey,
|
||||
},
|
||||
PresharedKey: domain.PreSharedKey(presharedKey),
|
||||
PersistentKeepalive: keepAliveSeconds,
|
||||
LastHandshake: lastHandshakeTime,
|
||||
ProtocolVersion: 0, // pfSense may not expose protocol version
|
||||
BytesUpload: txBytes,
|
||||
BytesDownload: rxBytes,
|
||||
ImportSource: domain.ControllerTypePfsense,
|
||||
}
|
||||
|
||||
// Extract description/name - pfSense API uses "descr" field
|
||||
description := peer.GetString("descr")
|
||||
if description == "" {
|
||||
description = peer.GetString("description")
|
||||
}
|
||||
if description == "" {
|
||||
description = peer.GetString("comment")
|
||||
}
|
||||
|
||||
// Extract name - pfSense API may use "name" or "descr"
|
||||
name := peer.GetString("name")
|
||||
if name == "" {
|
||||
name = peer.GetString("descr")
|
||||
}
|
||||
if name == "" {
|
||||
name = description // fallback to description if name is not available
|
||||
}
|
||||
|
||||
peerModel.SetExtras(domain.PfsensePeerExtras{
|
||||
Id: peer.GetString("id"),
|
||||
Name: name,
|
||||
Comment: description,
|
||||
Disabled: peer.GetBool("disabled"),
|
||||
ClientEndpoint: "", // pfSense may handle this differently
|
||||
ClientAddress: "", // pfSense may handle this differently
|
||||
ClientDns: "", // pfSense may handle this differently
|
||||
ClientKeepalive: 0, // pfSense may handle this differently
|
||||
})
|
||||
|
||||
return peerModel, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) SaveInterface(
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
mutex := c.getInterfaceMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
physicalInterface, err := c.getOrCreateInterface(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deviceId := ""
|
||||
if physicalInterface.GetExtras() != nil {
|
||||
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
|
||||
deviceId = extras.Id
|
||||
}
|
||||
}
|
||||
|
||||
if updateFunc != nil {
|
||||
physicalInterface, err = updateFunc(physicalInterface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deviceId != "" {
|
||||
// Ensure the ID is preserved
|
||||
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
|
||||
extras.Id = deviceId
|
||||
physicalInterface.SetExtras(extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.updateInterface(ctx, physicalInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) getOrCreateInterface(
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
) (*domain.PhysicalInterface, error) {
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
|
||||
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||
}
|
||||
|
||||
// create a new tunnel if it does not exist
|
||||
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular)
|
||||
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", lowlevel.GenericJsonObject{
|
||||
"name": string(id),
|
||||
})
|
||||
if createReply.Status == lowlevel.PfsenseApiStatusOk {
|
||||
return c.loadInterfaceData(ctx, createReply.Data)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error)
|
||||
}
|
||||
|
||||
func (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
|
||||
extras := pi.GetExtras().(domain.PfsenseInterfaceExtras)
|
||||
interfaceId := extras.Id
|
||||
|
||||
payload := lowlevel.GenericJsonObject{
|
||||
"name": string(pi.Identifier),
|
||||
"description": extras.Comment,
|
||||
"mtu": strconv.Itoa(pi.Mtu),
|
||||
"listenport": strconv.Itoa(pi.ListenPort),
|
||||
"privatekey": pi.KeyPair.PrivateKey,
|
||||
"disabled": strconv.FormatBool(!pi.DeviceUp),
|
||||
}
|
||||
|
||||
// Add addresses if present
|
||||
if len(pi.Addresses) > 0 {
|
||||
addresses := make([]string, 0, len(pi.Addresses))
|
||||
for _, addr := range pi.Addresses {
|
||||
addresses = append(addresses, addr.String())
|
||||
}
|
||||
payload["addresses"] = strings.Join(addresses, ",")
|
||||
}
|
||||
|
||||
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload)
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
mutex := c.getInterfaceMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Find the tunnel ID
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard tunnel %s: %v", id, wgReply.Error)
|
||||
}
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil // tunnel does not exist, nothing to delete
|
||||
}
|
||||
|
||||
interfaceId := wgReply.Data[0].GetString("id")
|
||||
// Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId)
|
||||
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) SavePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error {
|
||||
// Lock the peer to prevent concurrent modifications
|
||||
mutex := c.getPeerMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
peerId := ""
|
||||
if physicalPeer.GetExtras() != nil {
|
||||
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
|
||||
peerId = extras.Id
|
||||
}
|
||||
}
|
||||
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if peerId != "" {
|
||||
// Ensure the ID is preserved
|
||||
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
|
||||
extras.Id = peerId
|
||||
physicalPeer.SetExtras(extras)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) getOrCreatePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) (*domain.PhysicalPeer, error) {
|
||||
// Query for peer by publickey and interface (tun field)
|
||||
// The API uses query parameters like ?publickey=...&tun=...
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"publickey": string(id),
|
||||
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
|
||||
},
|
||||
})
|
||||
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
|
||||
slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId)
|
||||
existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &existingPeer, nil
|
||||
}
|
||||
|
||||
// create a new peer if it does not exist
|
||||
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular)
|
||||
slog.Debug("creating new pfSense peer", "peer", id, "interface", deviceId)
|
||||
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", lowlevel.GenericJsonObject{
|
||||
"name": fmt.Sprintf("wg-%s", id[0:8]),
|
||||
"interface": string(deviceId),
|
||||
"publickey": string(id),
|
||||
"allowedips": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer
|
||||
})
|
||||
if createReply.Status == lowlevel.PfsenseApiStatusOk {
|
||||
newPeer, err := c.convertWireGuardPeer(createReply.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("successfully created pfSense peer", "peer", id, "interface", deviceId)
|
||||
return &newPeer, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error)
|
||||
}
|
||||
|
||||
func (c *PfsenseController) updatePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
pp *domain.PhysicalPeer,
|
||||
) error {
|
||||
extras := pp.GetExtras().(domain.PfsensePeerExtras)
|
||||
peerId := extras.Id
|
||||
|
||||
allowedIPsStr := domain.CidrsToString(pp.AllowedIPs)
|
||||
|
||||
slog.Debug("updating pfSense peer",
|
||||
"peer", pp.Identifier,
|
||||
"interface", deviceId,
|
||||
"allowed-ips", allowedIPsStr,
|
||||
"allowed-ips-count", len(pp.AllowedIPs),
|
||||
"disabled", extras.Disabled)
|
||||
|
||||
payload := lowlevel.GenericJsonObject{
|
||||
"name": extras.Name,
|
||||
"description": extras.Comment,
|
||||
"presharedkey": string(pp.PresharedKey),
|
||||
"publickey": pp.KeyPair.PublicKey,
|
||||
"privatekey": pp.KeyPair.PrivateKey,
|
||||
"persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive),
|
||||
"disabled": strconv.FormatBool(extras.Disabled),
|
||||
"allowedips": allowedIPsStr,
|
||||
}
|
||||
|
||||
if pp.Endpoint != "" {
|
||||
payload["endpoint"] = pp.Endpoint
|
||||
}
|
||||
|
||||
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id}
|
||||
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload)
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
|
||||
}
|
||||
|
||||
if extras.Disabled {
|
||||
slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId)
|
||||
} else {
|
||||
slog.Debug("successfully updated pfSense peer", "peer", pp.Identifier, "interface", deviceId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) DeletePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) error {
|
||||
// Lock the peer to prevent concurrent modifications
|
||||
mutex := c.getPeerMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Query for peer by publickey and interface (tun field)
|
||||
// The API uses query parameters like ?publickey=...&tun=...
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"publickey": string(id),
|
||||
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard peer %s for interface %s: %v", id, deviceId, wgReply.Error)
|
||||
}
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil // peer does not exist, nothing to delete
|
||||
}
|
||||
|
||||
peerId := wgReply.Data[0].GetString("id")
|
||||
// Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}
|
||||
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId)
|
||||
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion wireguard-related
|
||||
|
||||
// region wg-quick-related
|
||||
|
||||
func (c *PfsenseController) ExecuteInterfaceHook(
|
||||
_ context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
_ string,
|
||||
) error {
|
||||
// TODO implement me
|
||||
slog.Error("interface hooks are not yet supported for pfSense backends, please open an issue on GitHub")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) SetDNS(
|
||||
ctx context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
dnsStr, _ string,
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
c.coreMutex.Lock()
|
||||
defer c.coreMutex.Unlock()
|
||||
|
||||
// pfSense DNS configuration is typically managed at the system level
|
||||
// This may need to be implemented based on pfSense API capabilities
|
||||
slog.Warn("DNS setting is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) UnsetDNS(
|
||||
ctx context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
dnsStr, _ string,
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
c.coreMutex.Lock()
|
||||
defer c.coreMutex.Unlock()
|
||||
|
||||
// pfSense DNS configuration is typically managed at the system level
|
||||
slog.Warn("DNS unsetting is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion wg-quick-related
|
||||
|
||||
// region routing-related
|
||||
|
||||
func (c *PfsenseController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
// pfSense routing is typically managed through the firewall rules and routing tables
|
||||
// This may need to be implemented based on pfSense API capabilities
|
||||
slog.Warn("route setting is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
// pfSense routing is typically managed through the firewall rules and routing tables
|
||||
slog.Warn("route removal is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion routing-related
|
||||
|
||||
// region statistics-related
|
||||
|
||||
func (c *PfsenseController) PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error) {
|
||||
// Use pfSense API to ping if available, otherwise return error
|
||||
// This may need to be implemented based on pfSense API capabilities
|
||||
return nil, fmt.Errorf("ping functionality is not yet implemented for pfSense backends")
|
||||
}
|
||||
|
||||
// endregion statistics-related
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
@@ -155,6 +157,37 @@ func (s *Server) setupFrontendRoutes() {
|
||||
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
|
||||
})
|
||||
|
||||
// If a custom frontend path is configured, serve files from there when it contains content.
|
||||
// If the directory is empty or missing, populate it with the embedded frontend-dist content first.
|
||||
if s.cfg.Web.FrontendFilePath != "" {
|
||||
if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil {
|
||||
slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||
} else {
|
||||
ok := true
|
||||
hasFiles, err := dirHasFiles(s.cfg.Web.FrontendFilePath)
|
||||
if err != nil {
|
||||
slog.Error("failed to check frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||
ok = false
|
||||
}
|
||||
if !hasFiles && ok {
|
||||
embeddedFS := fsMust(fs.Sub(frontendStatics, "frontend-dist"))
|
||||
if err := copyEmbedDirToDisk(embeddedFS, s.cfg.Web.FrontendFilePath); err != nil {
|
||||
slog.Error("failed to populate frontend base directory from embedded assets",
|
||||
"path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
// serve files from FS
|
||||
slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath)
|
||||
s.server.HandleFiles("/app", http.Dir(s.cfg.Web.FrontendFilePath))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: serve embedded frontend files
|
||||
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
|
||||
}
|
||||
|
||||
@@ -182,3 +215,67 @@ func fsMust(f fs.FS, err error) fs.FS {
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// dirHasFiles returns true if the directory contains at least one file (non-directory).
|
||||
func dirHasFiles(dir string) (bool, error) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Read a few entries; if any entry exists, consider it having files/dirs.
|
||||
// We want to know if there is at least one file; if only subdirs exist, still treat as content.
|
||||
entries, err := d.Readdir(-1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
// check recursively
|
||||
has, err := dirHasFiles(filepath.Join(dir, e.Name()))
|
||||
if err == nil && has {
|
||||
return true, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
// regular file
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk.
|
||||
func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error {
|
||||
return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dstDir, path)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0755)
|
||||
}
|
||||
// ensure parent dir exists
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
// open source file
|
||||
f, err := srcFS.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func NewMailManager(
|
||||
users UserDatabaseRepo,
|
||||
wg WireguardDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
|
||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle, cfg.Mail.TemplatesPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"fmt"
|
||||
htmlTemplate "html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
@@ -22,15 +26,50 @@ type TemplateHandler struct {
|
||||
textTemplates *template.Template
|
||||
}
|
||||
|
||||
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
|
||||
func newTemplateHandler(portalUrl, portalName string, basePath string) (*TemplateHandler, error) {
|
||||
// Always parse embedded defaults first
|
||||
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html template files: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse embedded html template files: %w", err)
|
||||
}
|
||||
|
||||
txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse text template files: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse embedded text template files: %w", err)
|
||||
}
|
||||
|
||||
// If a basePath is provided, ensure existence, populate if empty, then parse to override
|
||||
if basePath != "" {
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create templates base directory %s: %w", basePath, err)
|
||||
}
|
||||
|
||||
hasTemplates, err := dirHasTemplates(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to inspect templates directory: %w", err)
|
||||
}
|
||||
|
||||
// If no templates present, copy embedded defaults to directory
|
||||
if !hasTemplates {
|
||||
if err := copyEmbeddedTemplates(basePath); err != nil {
|
||||
return nil, fmt.Errorf("failed to populate templates directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse files from basePath to override embedded ones.
|
||||
// Only parse when matches exist to allow partial overrides without errors.
|
||||
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gohtml")); len(matches) > 0 {
|
||||
slog.Debug("parsing html email templates from base path", "base-path", basePath, "files", matches)
|
||||
if htmlTemplateCache, err = htmlTemplateCache.ParseFiles(matches...); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html templates from base path: %w", err)
|
||||
}
|
||||
}
|
||||
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gotpl")); len(matches) > 0 {
|
||||
slog.Debug("parsing text email templates from base path", "base-path", basePath, "files", matches)
|
||||
if txtTemplateCache, err = txtTemplateCache.ParseFiles(matches...); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse text templates from base path: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler := &TemplateHandler{
|
||||
@@ -43,6 +82,51 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
// dirHasTemplates checks whether directory contains any .gohtml or .gotpl files.
|
||||
func dirHasTemplates(basePath string) (bool, error) {
|
||||
entries, err := os.ReadDir(basePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := filepath.Ext(e.Name())
|
||||
if ext == ".gohtml" || ext == ".gotpl" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// copyEmbeddedTemplates writes embedded templates into basePath.
|
||||
func copyEmbeddedTemplates(basePath string) error {
|
||||
list, err := fs.ReadDir(TemplateFiles, "tpl_files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range list {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
// Only copy known template extensions
|
||||
if ext := filepath.Ext(name); ext != ".gohtml" && ext != ".gotpl" {
|
||||
continue
|
||||
}
|
||||
data, err := TemplateFiles.ReadFile(filepath.Join("tpl_files", name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := filepath.Join(basePath, name)
|
||||
if err := os.WriteFile(out, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfigMail returns the text and html template for the mail with a link.
|
||||
func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
|
||||
var tplBuff bytes.Buffer
|
||||
@@ -52,6 +136,7 @@ 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)
|
||||
@@ -61,6 +146,7 @@ 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)
|
||||
|
||||
@@ -44,6 +44,10 @@ func (c *ControllerManager) init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.registerPfsenseControllers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.logRegisteredControllers()
|
||||
|
||||
return nil
|
||||
@@ -86,6 +90,26 @@ func (c *ControllerManager) registerMikrotikControllers() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ControllerManager) registerPfsenseControllers() error {
|
||||
for _, backendConfig := range c.cfg.Backend.Pfsense {
|
||||
if backendConfig.Id == config.LocalBackendName {
|
||||
slog.Warn("skipping registration of pfSense controller with reserved ID", "id", config.LocalBackendName)
|
||||
continue
|
||||
}
|
||||
|
||||
controller, err := wgcontroller.NewPfsenseController(c.cfg, &backendConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pfSense controller for backend %s: %w", backendConfig.Id, err)
|
||||
}
|
||||
|
||||
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
|
||||
Config: backendConfig.BackendBase,
|
||||
Implementation: controller,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ControllerManager) logRegisteredControllers() {
|
||||
for backend, controller := range c.controllers {
|
||||
slog.Debug("backend controller registered",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
@@ -867,6 +868,17 @@ func (m Manager) importInterface(
|
||||
iface.Backend = backend.GetId()
|
||||
iface.PeerDefAllowedIPsStr = iface.AddressStr()
|
||||
|
||||
// For pfSense backends, extract endpoint and DNS from peers
|
||||
if backend.GetId() == domain.ControllerTypePfsense {
|
||||
endpoint, dns := extractPfsenseDefaultsFromPeers(peers, iface.ListenPort)
|
||||
if endpoint != "" {
|
||||
iface.PeerDefEndpoint = endpoint
|
||||
}
|
||||
if dns != "" {
|
||||
iface.PeerDefDnsStr = dns
|
||||
}
|
||||
}
|
||||
|
||||
// try to predict the interface type based on the number of peers
|
||||
switch len(peers) {
|
||||
case 0:
|
||||
@@ -904,6 +916,61 @@ func (m Manager) importInterface(
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers
|
||||
// For server interfaces, peers typically have endpoints pointing to the server, so we use the most common one
|
||||
func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) {
|
||||
if len(peers) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Count endpoint occurrences to find the most common one
|
||||
endpointCounts := make(map[string]int)
|
||||
dnsValues := make(map[string]int)
|
||||
|
||||
for _, peer := range peers {
|
||||
// Extract endpoint from peer
|
||||
if peer.Endpoint != "" {
|
||||
endpointCounts[peer.Endpoint]++
|
||||
}
|
||||
|
||||
// Extract DNS from peer extras if available
|
||||
if extras := peer.GetExtras(); extras != nil {
|
||||
if pfsenseExtras, ok := extras.(domain.PfsensePeerExtras); ok {
|
||||
if pfsenseExtras.ClientDns != "" {
|
||||
dnsValues[pfsenseExtras.ClientDns]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the most common endpoint
|
||||
maxCount := 0
|
||||
for ep, count := range endpointCounts {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
endpoint = ep
|
||||
}
|
||||
}
|
||||
|
||||
// If endpoint doesn't have a port and we have a listenPort, add it
|
||||
if endpoint != "" && listenPort > 0 {
|
||||
if !strings.Contains(endpoint, ":") {
|
||||
endpoint = fmt.Sprintf("%s:%d", endpoint, listenPort)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the most common DNS
|
||||
maxDnsCount := 0
|
||||
for dnsVal, count := range dnsValues {
|
||||
if count > maxDnsCount {
|
||||
maxDnsCount = count
|
||||
dns = dnsVal
|
||||
}
|
||||
}
|
||||
|
||||
return endpoint, dns
|
||||
}
|
||||
|
||||
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
|
||||
now := time.Now()
|
||||
peer := domain.ConvertPhysicalPeer(p)
|
||||
|
||||
@@ -18,6 +18,7 @@ type Backend struct {
|
||||
// External Backend-specific configuration
|
||||
|
||||
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
|
||||
Pfsense []BackendPfsense `yaml:"pfsense"`
|
||||
}
|
||||
|
||||
// Validate checks the backend configuration for errors.
|
||||
@@ -36,6 +37,15 @@ func (b *Backend) Validate() error {
|
||||
}
|
||||
uniqueMap[backend.Id] = struct{}{}
|
||||
}
|
||||
for _, backend := range b.Pfsense {
|
||||
if backend.Id == LocalBackendName {
|
||||
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
|
||||
}
|
||||
if _, exists := uniqueMap[backend.Id]; exists {
|
||||
return fmt.Errorf("backend ID %q is not unique", backend.Id)
|
||||
}
|
||||
uniqueMap[backend.Id] = struct{}{}
|
||||
}
|
||||
|
||||
if b.Default != LocalBackendName {
|
||||
if _, ok := uniqueMap[b.Default]; !ok {
|
||||
@@ -101,3 +111,42 @@ func (b *BackendMikrotik) GetApiTimeout() time.Duration {
|
||||
}
|
||||
return b.ApiTimeout
|
||||
}
|
||||
|
||||
type BackendPfsense struct {
|
||||
BackendBase `yaml:",inline"` // Embed the base fields
|
||||
|
||||
ApiUrl string `yaml:"api_url"` // The base URL of the pfSense REST API (e.g., "https://pfsense.example.com/api/v2")
|
||||
ApiKey string `yaml:"api_key"` // API key for authentication (generated in pfSense under 'System' -> 'REST API' -> 'Keys')
|
||||
ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the pfSense API
|
||||
ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds)
|
||||
|
||||
// Concurrency controls the maximum number of concurrent API requests that this backend will issue
|
||||
// when enumerating interfaces and their details. If 0 or negative, a default of 5 is used.
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
|
||||
Debug bool `yaml:"debug"` // Enable debug logging for the pfSense backend
|
||||
}
|
||||
|
||||
// GetConcurrency returns the configured concurrency for this backend or a sane default (5)
|
||||
// when the configured value is zero or negative.
|
||||
func (b *BackendPfsense) GetConcurrency() int {
|
||||
if b == nil {
|
||||
return 5
|
||||
}
|
||||
if b.Concurrency <= 0 {
|
||||
return 5
|
||||
}
|
||||
return b.Concurrency
|
||||
}
|
||||
|
||||
// GetApiTimeout returns the configured API timeout or a sane default (30 seconds)
|
||||
// when the configured value is zero or negative.
|
||||
func (b *BackendPfsense) GetApiTimeout() time.Duration {
|
||||
if b == nil {
|
||||
return 30 * time.Second
|
||||
}
|
||||
if b.ApiTimeout <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return b.ApiTimeout
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ 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")
|
||||
@@ -195,6 +196,7 @@ 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
|
||||
|
||||
@@ -43,4 +43,8 @@ type MailConfig struct {
|
||||
LinkOnly bool `yaml:"link_only"`
|
||||
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
|
||||
AllowPeerEmail bool `yaml:"allow_peer_email"`
|
||||
// TemplatesPath is an optional base path on the filesystem that contains email templates (.gotpl and .gohtml).
|
||||
// If the directory exists but is empty, the embedded default templates will be written there on startup.
|
||||
// If templates are present in the directory, they override the embedded defaults.
|
||||
TemplatesPath string `yaml:"templates_path"`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ type WebConfig struct {
|
||||
CertFile string `yaml:"cert_file"`
|
||||
// KeyFile is the path to the TLS certificate key file.
|
||||
KeyFile string `yaml:"key_file"`
|
||||
// FrontendFilePath is an optional path to a folder that contains the frontend files.
|
||||
// If set and the folder contains at least one file, it overrides the embedded frontend.
|
||||
// If set and the folder is empty or does not exist, the embedded frontend will be written into it on startup.
|
||||
FrontendFilePath string `yaml:"frontend_filepath"`
|
||||
}
|
||||
|
||||
func (c *WebConfig) Sanitize() {
|
||||
|
||||
@@ -5,6 +5,7 @@ package domain
|
||||
const (
|
||||
ControllerTypeMikrotik = "mikrotik"
|
||||
ControllerTypeLocal = "wgctrl"
|
||||
ControllerTypePfsense = "pfsense"
|
||||
)
|
||||
|
||||
// Controller extras can be used to store additional information available for specific controllers only.
|
||||
@@ -30,3 +31,20 @@ type MikrotikPeerExtras struct {
|
||||
type LocalPeerExtras struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type PfsenseInterfaceExtras struct {
|
||||
Id string // internal pfSense ID
|
||||
Comment string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type PfsensePeerExtras struct {
|
||||
Id string // internal pfSense ID
|
||||
Name string
|
||||
Comment string
|
||||
Disabled bool
|
||||
ClientEndpoint string
|
||||
ClientAddress string
|
||||
ClientDns string
|
||||
ClientKeepalive int
|
||||
}
|
||||
|
||||
@@ -240,7 +240,8 @@ func (p *PhysicalInterface) GetExtras() any {
|
||||
func (p *PhysicalInterface) SetExtras(extras any) {
|
||||
switch extras.(type) {
|
||||
case MikrotikInterfaceExtras: // OK
|
||||
default: // we only support MikrotikInterfaceExtras for now
|
||||
case PfsenseInterfaceExtras: // OK
|
||||
default: // we only support MikrotikInterfaceExtras and PfsenseInterfaceExtras for now
|
||||
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
|
||||
}
|
||||
|
||||
@@ -303,6 +304,14 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
|
||||
} else {
|
||||
iface.Disabled = nil
|
||||
}
|
||||
case ControllerTypePfsense:
|
||||
extras := pi.GetExtras().(PfsenseInterfaceExtras)
|
||||
iface.DisplayName = extras.Comment
|
||||
if extras.Disabled {
|
||||
iface.Disabled = &now
|
||||
} else {
|
||||
iface.Disabled = nil
|
||||
}
|
||||
}
|
||||
|
||||
return iface
|
||||
@@ -325,6 +334,12 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
|
||||
Disabled: i.IsDisabled(),
|
||||
}
|
||||
pi.SetExtras(extras)
|
||||
case ControllerTypePfsense:
|
||||
extras := PfsenseInterfaceExtras{
|
||||
Comment: i.DisplayName,
|
||||
Disabled: i.IsDisabled(),
|
||||
}
|
||||
pi.SetExtras(extras)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,8 @@ func (p *PhysicalPeer) SetExtras(extras any) {
|
||||
switch extras.(type) {
|
||||
case MikrotikPeerExtras: // OK
|
||||
case LocalPeerExtras: // OK
|
||||
default: // we only support MikrotikPeerExtras and LocalPeerExtras for now
|
||||
case PfsensePeerExtras: // OK
|
||||
default: // we only support MikrotikPeerExtras, LocalPeerExtras, and PfsensePeerExtras for now
|
||||
panic(fmt.Sprintf("unsupported peer backend extras type %T", extras))
|
||||
}
|
||||
|
||||
@@ -301,6 +302,26 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
|
||||
peer.Disabled = nil
|
||||
peer.DisabledReason = ""
|
||||
}
|
||||
case ControllerTypePfsense:
|
||||
extras := pp.GetExtras().(PfsensePeerExtras)
|
||||
peer.Notes = extras.Comment
|
||||
peer.DisplayName = extras.Name
|
||||
if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
|
||||
peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true)
|
||||
peer.Interface.Type = InterfaceTypeClient
|
||||
peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress)
|
||||
peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true)
|
||||
peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true)
|
||||
} else {
|
||||
peer.Interface.Type = InterfaceTypeServer
|
||||
}
|
||||
if extras.Disabled {
|
||||
peer.Disabled = &now
|
||||
peer.DisabledReason = "Disabled by pfSense controller"
|
||||
} else {
|
||||
peer.Disabled = nil
|
||||
peer.DisabledReason = ""
|
||||
}
|
||||
}
|
||||
|
||||
return peer
|
||||
@@ -355,6 +376,18 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
|
||||
Disabled: p.IsDisabled(),
|
||||
}
|
||||
pp.SetExtras(extras)
|
||||
case ControllerTypePfsense:
|
||||
extras := PfsensePeerExtras{
|
||||
Id: "",
|
||||
Name: p.DisplayName,
|
||||
Comment: p.Notes,
|
||||
Disabled: p.IsDisabled(),
|
||||
ClientEndpoint: p.Endpoint.GetValue(),
|
||||
ClientAddress: CidrsToString(p.Interface.Addresses),
|
||||
ClientDns: p.Interface.DnsStr.GetValue(),
|
||||
ClientKeepalive: p.PersistentKeepalive.GetValue(),
|
||||
}
|
||||
pp.SetExtras(extras)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
428
internal/lowlevel/pfsense.go
Normal file
428
internal/lowlevel/pfsense.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package lowlevel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
// PfsenseApiClient provides HTTP client functionality for interacting with the pfSense REST API.
|
||||
// Documentation: https://pfrest.org/
|
||||
// Swagger UI: https://pfrest.org/api-docs/
|
||||
|
||||
// region models
|
||||
|
||||
const (
|
||||
PfsenseApiStatusOk = "ok" // pfSense REST API uses "ok" in response
|
||||
PfsenseApiStatusError = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
PfsenseApiErrorCodeUnknown = iota + 700
|
||||
PfsenseApiErrorCodeRequestPreparationFailed
|
||||
PfsenseApiErrorCodeRequestFailed
|
||||
PfsenseApiErrorCodeResponseDecodeFailed
|
||||
)
|
||||
|
||||
type PfsenseApiResponse[T any] struct {
|
||||
Status string
|
||||
Code int
|
||||
Data T `json:"data,omitempty"`
|
||||
Error *PfsenseApiError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PfsenseApiError struct {
|
||||
Code int `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func (e *PfsenseApiError) String() string {
|
||||
if e == nil {
|
||||
return "no error"
|
||||
}
|
||||
return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details)
|
||||
}
|
||||
|
||||
type PfsenseRequestOptions struct {
|
||||
Filters map[string]string `json:"filters,omitempty"`
|
||||
PropList []string `json:"proplist,omitempty"`
|
||||
}
|
||||
|
||||
func (o *PfsenseRequestOptions) GetPath(base string) string {
|
||||
if o == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
path, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
|
||||
query := path.Query()
|
||||
// pfSense REST API uses standard query parameters for filtering
|
||||
for k, v := range o.Filters {
|
||||
query.Set(k, v)
|
||||
}
|
||||
// Note: PropList may not be supported by pfSense REST API in the same way as Mikrotik
|
||||
// pfSense typically returns all fields by default, but we keep this for potential future use
|
||||
// Verify the correct parameter name in Swagger docs if field selection is needed
|
||||
if len(o.PropList) > 0 {
|
||||
// pfSense might use different parameter name - verify in Swagger docs
|
||||
// For now, we'll skip it as pfSense may return all fields by default
|
||||
// query.Set("fields", strings.Join(o.PropList, ","))
|
||||
}
|
||||
path.RawQuery = query.Encode()
|
||||
return path.String()
|
||||
}
|
||||
|
||||
// endregion models
|
||||
|
||||
// region API-client
|
||||
|
||||
type PfsenseApiClient struct {
|
||||
coreCfg *config.Config
|
||||
cfg *config.BackendPfsense
|
||||
|
||||
client *http.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewPfsenseApiClient(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseApiClient, error) {
|
||||
c := &PfsenseApiClient{
|
||||
coreCfg: coreCfg,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
err := c.setup()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.debugLog("pfSense api client created", "api_url", cfg.ApiUrl)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) setup() error {
|
||||
p.client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: !p.cfg.ApiVerifyTls,
|
||||
},
|
||||
},
|
||||
Timeout: p.cfg.GetApiTimeout(),
|
||||
}
|
||||
|
||||
if p.cfg.Debug {
|
||||
p.log = slog.New(internal.GetLoggingHandler("debug",
|
||||
p.coreCfg.Advanced.LogPretty,
|
||||
p.coreCfg.Advanced.LogJson).
|
||||
WithAttrs([]slog.Attr{
|
||||
{
|
||||
Key: "pfsense-bid", Value: slog.StringValue(p.cfg.Id),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) debugLog(msg string, args ...any) {
|
||||
if p.log != nil {
|
||||
p.log.Debug("[PFS-API] "+msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) getFullPath(command string) string {
|
||||
path, err := url.JoinPath(p.cfg.ApiUrl, command)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) prepareGetRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if p.cfg.ApiKey != "" {
|
||||
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
|
||||
// Uses X-API-Key header for API key authentication
|
||||
req.Header.Set("X-API-Key", p.cfg.ApiKey)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) prepareDeleteRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if p.cfg.ApiKey != "" {
|
||||
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
|
||||
// Uses X-API-Key header for API key authentication
|
||||
req.Header.Set("X-API-Key", p.cfg.ApiKey)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) preparePayloadRequest(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
fullUrl string,
|
||||
payload GenericJsonObject,
|
||||
) (*http.Request, error) {
|
||||
// marshal the payload to JSON
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.cfg.ApiKey != "" {
|
||||
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
|
||||
// Uses X-API-Key header for API key authentication
|
||||
req.Header.Set("X-API-Key", p.cfg.ApiKey)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func errToPfsenseApiResponse[T any](code int, message string, err error) PfsenseApiResponse[T] {
|
||||
return PfsenseApiResponse[T]{
|
||||
Status: PfsenseApiStatusError,
|
||||
Code: code,
|
||||
Error: &PfsenseApiError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parsePfsenseHttpResponse[T any](resp *http.Response, err error) PfsenseApiResponse[T] {
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeRequestFailed, "failed to execute request", err)
|
||||
}
|
||||
|
||||
// pfSense REST API wraps responses in {code, status, data} or {code, status, error} structure
|
||||
var wrapper struct {
|
||||
Code int `json:"code"`
|
||||
Status string `json:"status"`
|
||||
Data T `json:"data,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Read the entire body first
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed, "failed to read response body", err)
|
||||
}
|
||||
|
||||
// Close the body after reading
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if len(bodyBytes) == 0 {
|
||||
// Empty response for DELETE operations
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return PfsenseApiResponse[T]{Status: PfsenseApiStatusOk, Code: resp.StatusCode}
|
||||
}
|
||||
return errToPfsenseApiResponse[T](resp.StatusCode, "empty error response", fmt.Errorf("HTTP %d", resp.StatusCode))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
|
||||
// Log the actual response for debugging when JSON parsing fails
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
bodyPreview := string(bodyBytes)
|
||||
if len(bodyPreview) > 500 {
|
||||
bodyPreview = bodyPreview[:500] + "..."
|
||||
}
|
||||
slog.Error("failed to decode pfSense API response",
|
||||
"status_code", resp.StatusCode,
|
||||
"content_type", contentType,
|
||||
"url", resp.Request.URL.String(),
|
||||
"method", resp.Request.Method,
|
||||
"body_preview", bodyPreview,
|
||||
"error", err)
|
||||
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed,
|
||||
fmt.Sprintf("failed to decode response (status %d, content-type: %s): %v", resp.StatusCode, contentType, err), err)
|
||||
}
|
||||
|
||||
// Check if response indicates success
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
// Map pfSense status to our status
|
||||
status := PfsenseApiStatusOk
|
||||
if wrapper.Status != "ok" && wrapper.Status != "success" {
|
||||
status = PfsenseApiStatusError
|
||||
}
|
||||
|
||||
// Handle EmptyResponse type
|
||||
if _, ok := any(wrapper.Data).(EmptyResponse); ok {
|
||||
return PfsenseApiResponse[T]{Status: status, Code: wrapper.Code}
|
||||
}
|
||||
|
||||
return PfsenseApiResponse[T]{Status: status, Code: wrapper.Code, Data: wrapper.Data}
|
||||
}
|
||||
|
||||
// Handle error response
|
||||
if wrapper.Error != nil {
|
||||
return PfsenseApiResponse[T]{
|
||||
Status: PfsenseApiStatusError,
|
||||
Code: wrapper.Code,
|
||||
Error: &PfsenseApiError{
|
||||
Code: wrapper.Error.Code,
|
||||
Message: wrapper.Error.Message,
|
||||
Details: wrapper.Error.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback error response
|
||||
return errToPfsenseApiResponse[T](wrapper.Code, "unknown error", fmt.Errorf("HTTP %d: %s", wrapper.Code, wrapper.Status))
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) Query(
|
||||
ctx context.Context,
|
||||
command string,
|
||||
opts *PfsenseRequestOptions,
|
||||
) PfsenseApiResponse[[]GenericJsonObject] {
|
||||
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
|
||||
defer cancel()
|
||||
|
||||
fullUrl := opts.GetPath(p.getFullPath(command))
|
||||
|
||||
req, err := p.prepareGetRequest(apiCtx, fullUrl)
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[[]GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
|
||||
"failed to create request", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
p.debugLog("executing API query", "url", fullUrl)
|
||||
response := parsePfsenseHttpResponse[[]GenericJsonObject](p.client.Do(req))
|
||||
p.debugLog("retrieved API query result", "url", fullUrl, "duration", time.Since(start).String())
|
||||
return response
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) Get(
|
||||
ctx context.Context,
|
||||
command string,
|
||||
opts *PfsenseRequestOptions,
|
||||
) PfsenseApiResponse[GenericJsonObject] {
|
||||
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
|
||||
defer cancel()
|
||||
|
||||
fullUrl := opts.GetPath(p.getFullPath(command))
|
||||
|
||||
req, err := p.prepareGetRequest(apiCtx, fullUrl)
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
|
||||
"failed to create request", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
p.debugLog("executing API get", "url", fullUrl)
|
||||
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
|
||||
p.debugLog("retrieved API get result", "url", fullUrl, "duration", time.Since(start).String())
|
||||
return response
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) Create(
|
||||
ctx context.Context,
|
||||
command string,
|
||||
payload GenericJsonObject,
|
||||
) PfsenseApiResponse[GenericJsonObject] {
|
||||
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
|
||||
defer cancel()
|
||||
|
||||
fullUrl := p.getFullPath(command)
|
||||
|
||||
req, err := p.preparePayloadRequest(apiCtx, http.MethodPost, fullUrl, payload)
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
|
||||
"failed to create request", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
p.debugLog("executing API post", "url", fullUrl)
|
||||
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
|
||||
p.debugLog("retrieved API post result", "url", fullUrl, "duration", time.Since(start).String())
|
||||
return response
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) Update(
|
||||
ctx context.Context,
|
||||
command string,
|
||||
payload GenericJsonObject,
|
||||
) PfsenseApiResponse[GenericJsonObject] {
|
||||
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
|
||||
defer cancel()
|
||||
|
||||
fullUrl := p.getFullPath(command)
|
||||
|
||||
req, err := p.preparePayloadRequest(apiCtx, http.MethodPatch, fullUrl, payload)
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
|
||||
"failed to create request", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
p.debugLog("executing API patch", "url", fullUrl)
|
||||
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
|
||||
p.debugLog("retrieved API patch result", "url", fullUrl, "duration", time.Since(start).String())
|
||||
return response
|
||||
}
|
||||
|
||||
func (p *PfsenseApiClient) Delete(
|
||||
ctx context.Context,
|
||||
command string,
|
||||
) PfsenseApiResponse[EmptyResponse] {
|
||||
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
|
||||
defer cancel()
|
||||
|
||||
fullUrl := p.getFullPath(command)
|
||||
|
||||
req, err := p.prepareDeleteRequest(apiCtx, fullUrl)
|
||||
if err != nil {
|
||||
return errToPfsenseApiResponse[EmptyResponse](PfsenseApiErrorCodeRequestPreparationFailed,
|
||||
"failed to create request", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
p.debugLog("executing API delete", "url", fullUrl)
|
||||
response := parsePfsenseHttpResponse[EmptyResponse](p.client.Do(req))
|
||||
p.debugLog("retrieved API delete result", "url", fullUrl, "duration", time.Since(start).String())
|
||||
return response
|
||||
}
|
||||
|
||||
// endregion API-client
|
||||
|
||||
@@ -81,6 +81,7 @@ nav:
|
||||
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
|
||||
- Configuration:
|
||||
- Overview: documentation/configuration/overview.md
|
||||
- Mail templates: documentation/configuration/mail-templates.md
|
||||
- Examples: documentation/configuration/examples.md
|
||||
- Usage:
|
||||
- General: documentation/usage/general.md
|
||||
@@ -88,6 +89,7 @@ nav:
|
||||
- LDAP: documentation/usage/ldap.md
|
||||
- Security: documentation/usage/security.md
|
||||
- Webhooks: documentation/usage/webhooks.md
|
||||
- Mail Templates: documentation/usage/mail-templates.md
|
||||
- REST API: documentation/rest-api/api-doc.md
|
||||
- Upgrade: documentation/upgrade/v1.md
|
||||
- Monitoring: documentation/monitoring/prometheus.md
|
||||
|
||||
Reference in New Issue
Block a user