Compare commits

...

22 Commits

Author SHA1 Message Date
dependabot[bot]
53eb56e83d chore(deps): bump the actions group across 1 directory with 2 updates (#627)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Bumps the actions group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/login-action` from 3.6.0 to 3.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](5e57cd1181...c94ce9fb46)

Updates `docker/build-push-action` from 6.18.0 to 6.19.2
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](263435318d...10e90e3645)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 6.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:10:03 +01:00
Christoph Haas
ce695648c0 fix: improve peer email handling logic (#620) 2026-02-20 19:09:37 +01:00
Christoph Haas
85d407ff11 chore: update dependencies 2026-02-20 18:59:58 +01:00
h44z
e62db0d62e Merge commit from fork
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* fix: prevent open redirect in OAuth return URL validation

* reformat check

---------

Co-authored-by: Arne Cools <arne.cools@intigriti.com>
2026-01-29 22:37:16 +01:00
dependabot[bot]
129cd0d408 chore(deps): bump the actions group with 2 updates (#618)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python).


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

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 22:24:34 +01:00
h44z
70cc44cc4d feat: add live traffic stats (#530) (#616) 2026-01-26 22:24:10 +01:00
h44z
e53b8c8087 fix: improve import of existing allowed-IPs (#615)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2026-01-25 00:33:33 +01:00
ShiroTohu
df9fdd14fb fix: typo in local.go (#613)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2026-01-21 22:22:40 +01:00
h44z
e0f6c1d04b feat: allow multiple auth sources per user (#500,#477) (#612)
* feat: allow multiple auth sources per user (#500,#477)

* only override isAdmin flag if it is provided by the authentication source
2026-01-21 22:22:22 +01:00
dependabot[bot]
d2fe267be7 chore(deps): bump golang.org/x/crypto from 0.46.0 to 0.47.0 (#606)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.46.0 to 0.47.0.
- [Commits](https://github.com/golang/crypto/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 23:15:08 +01:00
dependabot[bot]
bb516e9115 chore(deps): bump golang.org/x/sys from 0.39.0 to 0.40.0 (#607)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/sys/compare/v0.39.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 23:07:11 +01:00
Arnaud Rocher
5d58df8a19 fix: parity of Base64/URL encoding between frontend and backend (#611)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Signed-off-by: Arnaud Rocher <arnaud.roche3@gmail.com>
2026-01-17 19:38:48 +01:00
h44z
2200509bc0 feat: introduce "Create Default Peer" flag for interfaces (#513) (#605)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2026-01-13 23:11:22 +01:00
h44z
1b56acac87 Doc Update (#603)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* docs: enhance binary usage guide and systemd setup (#577)

* docs: remove invalid mail templates section from configuration overview
2026-01-05 23:25:37 +01:00
h44z
015220dc7b bulk actions for peers and users (#492) (#602) 2026-01-05 23:25:17 +01:00
dependabot[bot]
4b49a55ea2 chore(deps): bump the actions group across 1 directory with 3 updates (#599)
Bumps the actions group with 3 updates in the / directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `docker/setup-buildx-action` from 3.11.1 to 3.12.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](e468171a9d...8d2750c68a)

Updates `actions/upload-artifact` from 5.0.0 to 6.0.0
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](330a01c490...b7c566a772)

Updates `actions/download-artifact` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](018cc2cf5b...37930b1c2a)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 23:24:27 +01:00
dependabot[bot]
93db40c995 chore(deps): bump github.com/go-playground/validator/v10 (#601)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.28.0 to 10.30.1.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.28.0...v10.30.1)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-04 23:54:33 +01:00
h44z
0a88fe745f allow setting a base-path for the web UI and API (#583) (#595)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-12-20 15:30:55 +01:00
h44z
8cc937b031 Custom templates (#594)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* allow custom mail templates (#533)

* allow to override embedded frontend (#533)
2025-12-10 23:10:43 +01:00
rwjack
54ca1d8aed Add Pfsense backend (ALPHA) (#585)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
* Add pfSense backend domain types and configuration

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

* Add low-level pfSense REST API client

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

* Implement pfSense WireGuard controller

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

* Register pfSense controllers and update configuration

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

* Fix peer filtering and allowedips parsing for pfSense backend

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

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

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

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

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

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

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

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

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

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

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

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

* Fix unused variable compilation error

Removed unused deviceId variable that was causing build failure.

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

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

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

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

* Fix URL encoding issue in tunnel endpoint queries

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

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

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

* update backend docs for pfsense

---------

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


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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-09 22:11:04 +01:00
112 changed files with 5492 additions and 1116 deletions

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }} if: ${{ github.event_name == 'pull_request' }}
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -35,7 +35,7 @@ jobs:
# ct lint requires Python 3.x to run following packages: # ct lint requires Python 3.x to run following packages:
# - yamale (https://github.com/23andMe/Yamale) # - yamale (https://github.com/23andMe/Yamale)
# - yamllint (https://github.com/adrienverge/yamllint) # - yamllint (https://github.com/adrienverge/yamllint)
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: '3.x' python-version: '3.x'
@@ -60,9 +60,9 @@ jobs:
permissions: permissions:
packages: write packages: write
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

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

View File

@@ -15,11 +15,11 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: 3.x python-version: 3.x

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ func main() {
rawDb, err := adapters.NewDatabase(cfg.Database) rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err) internal.AssertNoError(err)
database, err := adapters.NewSqlRepository(rawDb) database, err := adapters.NewSqlRepository(rawDb, cfg)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuard, err := wireguard.NewControllerManager(cfg) wireGuard, err := wireguard.NewControllerManager(cfg)
@@ -84,7 +84,7 @@ func main() {
internal.AssertNoError(err) internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx) userManager.StartBackgroundJobs(ctx)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager) authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)
authenticator.StartBackgroundJobs(ctx) authenticator.StartBackgroundJobs(ctx)
@@ -135,6 +135,7 @@ func main() {
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus)
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,
@@ -144,6 +145,7 @@ func main() {
apiV0EndpointPeers, apiV0EndpointPeers,
apiV0EndpointConfig, apiV0EndpointConfig,
apiV0EndpointTest, apiV0EndpointTest,
apiV0EndpointWebsocket,
) )
// endregion API v0 (SPA frontend) // endregion API v0 (SPA frontend)

View File

@@ -11,7 +11,12 @@ core:
web: web:
external_url: http://localhost:8888 external_url: http://localhost:8888
base_path: ""
request_logging: true request_logging: true
frontend_filepath: ""
mail:
templates_path: ""
webhook: webhook:
url: "" url: ""
@@ -94,3 +99,15 @@ auth:
admin_group_regex: ^admin-group-name$ admin_group_regex: ^admin-group-name$
registration_enabled: true registration_enabled: true
log_user_info: true log_user_info: true
backend:
default: local
pfsense:
- id: pfsense1
display_name: "Main pfSense Firewall"
api_url: "https://pfsense.example.com" # Base URL without /api/v2 (endpoints already include it)
api_key: "your-api-key" # Generate in pfSense under 'System' -> 'REST API' -> 'Keys'
api_verify_tls: true
api_timeout: 30s
concurrency: 5
debug: false

View File

@@ -74,6 +74,7 @@ mail:
from: Wireguard Portal <noreply@wireguard.local> from: Wireguard Portal <noreply@wireguard.local>
link_only: false link_only: false
allow_peer_email: false allow_peer_email: false
templates_path: ""
auth: auth:
oidc: [] oidc: []
@@ -87,6 +88,7 @@ auth:
web: web:
listening_address: :8888 listening_address: :8888
external_url: http://localhost:8888 external_url: http://localhost:8888
base_path: ""
site_company_name: WireGuard Portal site_company_name: WireGuard Portal
site_title: WireGuard Portal site_title: WireGuard Portal
session_identifier: wgPortalSession session_identifier: wgPortalSession
@@ -96,6 +98,7 @@ web:
expose_host_info: false expose_host_info: false
cert_file: "" cert_file: ""
key_File: "" key_File: ""
frontend_filepath: ""
webhook: webhook:
url: "" url: ""
@@ -154,12 +157,14 @@ More advanced options are found in the subsequent `Advanced` section.
### `create_default_peer` ### `create_default_peer`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER` - **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces. - **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_creation` ### `create_default_peer_on_creation`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION` - **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces. - **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option requires [create_default_peer](#create_default_peer) to be enabled.
### `re_enable_peer_after_user_enable` ### `re_enable_peer_after_user_enable`
- **Default:** `true` - **Default:** `true`
@@ -485,6 +490,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 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. 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 ## Auth
@@ -794,8 +804,15 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
- **Default:** `http://localhost:8888` - **Default:** `http://localhost:8888`
- **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL` - **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL`
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects. - **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
The external URL must not contain a path component or trailing slash. If you want to serve WireGuard Portal on a subpath, use the `base_path` setting.
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server. **Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
### `base_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_BASE_PATH`
- **Description:** The base path for the web server (e.g., `/wgportal`).
By default (meaning an empty value), the portal will be served from the root path `/`.
### `site_company_name` ### `site_company_name`
- **Default:** `WireGuard Portal` - **Default:** `WireGuard Portal`
- **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME` - **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME`
@@ -841,6 +858,14 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
- **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE` - **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE`
- **Description:** (Optional) Path to the TLS certificate 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 ## Webhook

View File

@@ -9,6 +9,11 @@ Make sure that you download the correct binary for your architecture. The availa
- `wg-portal_linux_arm64` - Linux ARM 64-bit - `wg-portal_linux_arm64` - Linux ARM 64-bit
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit - `wg-portal_linux_arm_v7` - Linux ARM 32-bit
### Released versions
To download a specific version, replace `${WG_PORTAL_VERSION}` with the desired version (or set an environment variable).
All official release versions can be found on the [GitHub Releases Page](https://github.com/h44z/wg-portal/releases).
With `curl`: With `curl`:
```shell ```shell
@@ -27,16 +32,74 @@ with `gh cli`:
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64' gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
``` ```
The downloaded file will be named `wg-portal` and can be moved to a directory of your choice, see [Install](#install) for more information.
### Unreleased versions (master branch builds)
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
## Install ## Install
The following command can be used to install the downloaded binary (`wg-portal`) to `/opt/wg-portal/wg-portal`. It ensures that the binary is executable.
```shell ```shell
sudo mkdir -p /opt/wg-portal sudo mkdir -p /opt/wg-portal
sudo install wg-portal /opt/wg-portal/ sudo install wg-portal /opt/wg-portal/
``` ```
## Unreleased versions (master branch builds) To handle tasks such as restarting the service or configuring automatic startup, it is recommended to use a process manager like [systemd](https://systemd.io/).
Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster). ## Systemd Service Setup
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand.
>
> The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`.
> The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
To run WireGuard Portal as a systemd service, you can create a service unit file. The easiest way to do this is by using `systemctl edit`:
```shell
sudo systemctl edit --force --full wg-portal.service
```
Paste the following content into the editor and adjust the variables to your needs:
```ini
[Unit]
Description=WireGuard Portal
ConditionPathExists=/opt/wg-portal/wg-portal
After=network.target
[Service]
Type=simple
User=root
Group=root
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
Restart=on-failure
RestartSec=10
WorkingDirectory=/opt/wg-portal
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
ExecStart=/opt/wg-portal/wg-portal
[Install]
WantedBy=multi-user.target
```
Alternatively, you can create or modify the file manually in `/etc/systemd/system/wg-portal.service`.
For systemd to pick up the changes, you need to reload the daemon:
```shell
sudo systemctl daemon-reload
```
After creating the service file, you can enable and start the service:
```shell
sudo systemctl enable --now wg-portal.service
```
To check status and log output, use: `sudo systemctl status wg-portal.service` or `sudo journalctl -u wg-portal.service`.

View File

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

View File

@@ -4,7 +4,7 @@ To build the application from source files, use the Makefile provided in the rep
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [Make](https://www.gnu.org/software/make/) - [Make](https://www.gnu.org/software/make/)
- [Go](https://go.dev/dl/): `>=1.24.0` - [Go](https://go.dev/dl/): `>=1.25.0`
- [Node.js with npm](https://nodejs.org/en/download): `node>=18, npm>=9` - [Node.js with npm](https://nodejs.org/en/download): `node>=18, npm>=9`
## Build ## Build

View File

@@ -443,6 +443,18 @@ definitions:
maxLength: 64 maxLength: 64
minLength: 32 minLength: 32
type: string type: string
AuthSources:
description: The source of the user. This field is optional.
example:
- db
items:
enum:
- db
- ldap
- oauth
type: string
readOnly: true
type: array
Department: Department:
description: The department of the user. This field is optional. description: The department of the user. This field is optional.
example: Software Development example: Software Development
@@ -503,19 +515,6 @@ definitions:
description: The phone number of the user. This field is optional. description: The phone number of the user. This field is optional.
example: "+1234546789" example: "+1234546789"
type: string type: string
ProviderName:
description: The name of the authentication provider. This field is read-only.
example: ""
readOnly: true
type: string
Source:
description: The source of the user. This field is optional.
enum:
- db
- ldap
- oauth
example: db
type: string
required: required:
- Identifier - Identifier
type: object type: object

View File

@@ -0,0 +1,152 @@
WireGuard Portal supports multiple authentication mechanisms to manage user access. This includes
- Local user accounts
- LDAP authentication
- OAuth2 and OIDC authentication
- Passkey authentication (WebAuthn)
Users can have two roles which limit their permissions in WireGuard Portal:
- **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
In general, each user is identified by a _unique identifier_. If the same user identifier exists across multiple authentication sources, WireGuard Portal automatically merges those accounts into a single user record.
When a user is associated with multiple authentication sources, their information in WireGuard Portal is updated based on the most recently logged-in source. For more details, see [User Synchronization](./user-sync.md) documentation.
## Password Authentication
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
## Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
## OAuth2 and OIDC Authentication
WireGuard Portal supports OAuth2 and OIDC authentication. You can use any OAuth2 or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth2 or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth2 provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
#### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers.
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_domains:
- "outlook.com"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their attributes in the OAuth2 or OIDC provider. To do this, set the `admin_mapping` property for the provider.
Administrative access can either be mapped by a specific attribute or by group membership.
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
The user is granted admin access if the regex matches the attribute value.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
is_admin: "wg_admin_prop"
admin_mapping:
admin_value_regex: "^true$"
```
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
The user is granted admin access if the regex matches any of the group names.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
user_groups: "groups"
admin_mapping:
admin_group_regex: "^the-admin-group$"
```
The example above will grant admin access to users who are members of the `the-admin-group` group.
## LDAP Authentication
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
### Limiting Login to Specific Users
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
```
The `login_filter` should always be designed to return at most one user.
### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
### Admin Mapping
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
## User Synchronization

View File

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

View File

@@ -14,7 +14,7 @@ WireGuard Interfaces can be categorized into three types:
## Accessing the Web UI ## Accessing the Web UI
The web UI should be accessed via the URL specified in the `external_url` property of the configuration file. The web UI should be accessed via the URL specified in the `external_url` property of the configuration file.
By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI. By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) or [Authentication](authentication.md) sections for more information on securing the web UI.
So the default URL to access the web UI is: So the default URL to access the web UI is:

View File

@@ -1,37 +0,0 @@
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered,
so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation.
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
The synchronization process can be fine-tuned by multiple parameters, which are described below.
## LDAP Synchronization
WireGuard Portal can automatically synchronize users from LDAP to the database.
To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
### Limiting Synchronization to Specific Users
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
For example, to import only users with a `mail` attribute:
```yaml
auth:
ldap:
- id: ldap
# ... other settings
sync_filter: (mail=*)
```
### Disable Missing Users
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
All peers associated with that user will also be disabled.
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
This will only re-enable the user if they where disabled by the synchronization process. Manually disabled users will not be re-enabled.

View File

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

View File

@@ -1,153 +1,12 @@
This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data. This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data.
## Authentication ## Database Encryption
WireGuard Portal supports multiple authentication methods, including: WireGuard Portal supports multiple database backends. To reduce the risk of data exposure, sensitive information stored in the database can be encrypted.
To enable encryption, set the [`encryption_passphrase`](../configuration/overview.md#database) in the database configuration section.
- Local user accounts
- LDAP authentication
- OAuth and OIDC authentication
- Passkey authentication (WebAuthn)
Users can have two roles which limit their permissions in WireGuard Portal:
- **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
### Password Security
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
### Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
### OAuth and OIDC Authentication
WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
#### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers.
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_domains:
- "outlook.com"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider.
Administrative access can either be mapped by a specific attribute or by group membership.
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
The user is granted admin access if the regex matches the attribute value.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
is_admin: "wg_admin_prop"
admin_mapping:
admin_value_regex: "^true$"
```
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
The user is granted admin access if the regex matches any of the group names.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
user_groups: "groups"
admin_mapping:
admin_group_regex: "^the-admin-group$"
```
The example above will grant admin access to users who are members of the `the-admin-group` group.
### LDAP Authentication
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
#### Limiting Login to Specific Users
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
```
The `login_filter` should always be designed to return at most one user.
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed!
> Only new or updated records will be encrypted; existing data remains in plaintext until its next modified.
## UI and API Access ## UI and API Access
@@ -158,3 +17,8 @@ It is recommended to use HTTPS for all communication with the portal to prevent
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features. Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features.
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section. A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.
### Secure Authentication
To prevent unauthorized access, WireGuard Portal supports integrating with secure authentication providers such as LDAP, OAuth2, or Passkeys, see [Authentication](./authentication.md) for more details.
When possible, use centralized authentication and enforce multi-factor authentication (MFA) at the provider level for enhanced account security.
For local accounts, administrators should enforce strong password requirements.

View File

@@ -0,0 +1,46 @@
For all external authentication providers (LDAP, OIDC, OAuth2), WireGuard Portal can automatically create a local user record upon the user's first successful login.
This behavior is controlled by the `registration_enabled` setting in each authentication provider's configuration.
User information from external authentication sources is merged into the corresponding local WireGuard Portal user record whenever the user logs in.
Additionally, WireGuard Portal supports periodic synchronization of user data from an LDAP directory.
To prevent overwriting local changes, WireGuard Portal allows you to set a per-user flag that disables synchronization of external attributes.
When this flag is set, the user in WireGuard Portal will not be updated automatically during log-ins or LDAP synchronization.
### LDAP Synchronization
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
You can even register multiple LDAP servers side-by-side. Details on the log-in process can be found in the [LDAP Authentication](./authentication.md#ldap-authentication) section.
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
The synchronization process can be fine-tuned by multiple parameters, which are described below.
#### Synchronization Parameters
To enable the LDAP sycnhronization this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
##### Limiting Synchronization to Specific Users
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
For example, to import only users with a `mail` attribute:
```yaml
auth:
ldap:
- id: ldap
# ... other settings
sync_filter: (mail=*)
```
##### Disable Missing Users
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
All peers associated with that user will also be disabled.
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.

View File

@@ -68,26 +68,32 @@ All payload models are encoded as JSON objects. Fields with empty values might b
#### User Payload (entity: `user`) #### User Payload (entity: `user`)
| JSON Field | Type | Description | | JSON Field | Type | Description |
|----------------|-------------|-----------------------------------| |----------------|---------------|-----------------------------------|
| CreatedBy | string | Creator identifier | | CreatedBy | string | Creator identifier |
| UpdatedBy | string | Last updater identifier | | UpdatedBy | string | Last updater identifier |
| CreatedAt | time.Time | Time of creation | | CreatedAt | time.Time | Time of creation |
| UpdatedAt | time.Time | Time of last update | | UpdatedAt | time.Time | Time of last update |
| Identifier | string | Unique user identifier | | Identifier | string | Unique user identifier |
| Email | string | User email | | Email | string | User email |
| Source | string | Authentication source | | AuthSources | []AuthSource | Authentication sources |
| ProviderName | string | Name of auth provider | | IsAdmin | bool | Whether user has admin privileges |
| IsAdmin | bool | Whether user has admin privileges | | Firstname | string | User's first name (optional) |
| Firstname | string | User's first name (optional) | | Lastname | string | User's last name (optional) |
| Lastname | string | User's last name (optional) | | Phone | string | Contact phone number (optional) |
| Phone | string | Contact phone number (optional) | | Department | string | User's department (optional) |
| Department | string | User's department (optional) | | Notes | string | Additional notes (optional) |
| Notes | string | Additional notes (optional) | | Disabled | *time.Time | When user was disabled |
| Disabled | *time.Time | When user was disabled | | DisabledReason | string | Reason for deactivation |
| DisabledReason | string | Reason for deactivation | | Locked | *time.Time | When user account was locked |
| Locked | *time.Time | When user account was locked | | LockedReason | string | Reason for being locked |
| LockedReason | string | Reason for being locked |
`AuthSource`:
| JSON Field | Type | Description |
|--------------|---------------|-----------------------------------------------------|
| Source | string | The authentication source (e.g. LDAP, OAuth, or DB) |
| ProviderName | string | The identifier of the authentication provider |
#### Peer Payload (entity: `peer`) #### Peer Payload (entity: `peer`)

View File

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

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = interfaces.Prepared.Identifier formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode formData.value.Mode = interfaces.Prepared.Mode
formData.value.CreateDefaultPeer = interfaces.Prepared.CreateDefaultPeer
formData.value.Backend = interfaces.Prepared.Backend formData.value.Backend = interfaces.Prepared.Backend
formData.value.PublicKey = interfaces.Prepared.PublicKey formData.value.PublicKey = interfaces.Prepared.PublicKey
@@ -122,6 +123,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = selectedInterface.value.Identifier formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode formData.value.Mode = selectedInterface.value.Mode
formData.value.CreateDefaultPeer = selectedInterface.value.CreateDefaultPeer
formData.value.Backend = selectedInterface.value.Backend formData.value.Backend = selectedInterface.value.Backend
formData.value.PublicKey = selectedInterface.value.PublicKey formData.value.PublicKey = selectedInterface.value.PublicKey
@@ -487,6 +489,10 @@ async function del() {
<input v-model="formData.Disabled" class="form-check-input" type="checkbox"> <input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div> </div>
<div class="form-check form-switch" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')">
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.Backend==='local'"> <div class="form-check form-switch" v-if="formData.Backend==='local'">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox"> <input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>

View File

@@ -42,7 +42,7 @@ const passwordWeak = computed(() => {
}) })
const formValid = computed(() => { const formValid = computed(() => {
if (formData.value.Source !== 'db') { if (!formData.value.AuthSources.some(s => s === 'db')) {
return true // nothing to validate return true // nothing to validate
} }
if (props.userId !== '#NEW#' && passwordWeak.value) { if (props.userId !== '#NEW#' && passwordWeak.value) {
@@ -70,7 +70,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
} else { // fill existing userdata } else { // fill existing userdata
formData.value.Identifier = selectedUser.value.Identifier formData.value.Identifier = selectedUser.value.Identifier
formData.value.Email = selectedUser.value.Email formData.value.Email = selectedUser.value.Email
formData.value.Source = selectedUser.value.Source formData.value.AuthSources = selectedUser.value.AuthSources
formData.value.IsAdmin = selectedUser.value.IsAdmin formData.value.IsAdmin = selectedUser.value.IsAdmin
formData.value.Firstname = selectedUser.value.Firstname formData.value.Firstname = selectedUser.value.Firstname
formData.value.Lastname = selectedUser.value.Lastname formData.value.Lastname = selectedUser.value.Lastname
@@ -80,6 +80,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Password = "" formData.value.Password = ""
formData.value.Disabled = selectedUser.value.Disabled formData.value.Disabled = selectedUser.value.Disabled
formData.value.Locked = selectedUser.value.Locked formData.value.Locked = selectedUser.value.Locked
formData.value.PersistLocalChanges = selectedUser.value.PersistLocalChanges
} }
} }
} }
@@ -133,7 +134,7 @@ async function del() {
<template> <template>
<Modal :title="title" :visible="visible" @close="close"> <Modal :title="title" :visible="visible" @close="close">
<template #default> <template #default>
<fieldset v-if="formData.Source==='db'"> <fieldset>
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend> <legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
<div v-if="props.userId==='#NEW#'" class="form-group"> <div v-if="props.userId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label> <label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
@@ -141,16 +142,22 @@ async function del() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label> <label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
<input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text"> <input v-model="formData.AuthSources" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
</div> </div>
<div v-if="formData.Source==='db'" class="form-group"> <div class="form-group" v-if="formData.AuthSources.some(s => s ==='db')">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label> <label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password"> <input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div> <div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small> <small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
</div> </div>
</fieldset> </fieldset>
<fieldset v-if="formData.Source==='db'"> <fieldset v-if="formData.AuthSources.some(s => s !=='db') && !formData.PersistLocalChanges">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="alert alert-warning mt-3">
{{ $t('modals.user-edit.sync-warning') }}
</div>
</fieldset>
<fieldset v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend> <legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label> <label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
@@ -194,10 +201,14 @@ async function del() {
<input v-model="formData.Locked" class="form-check-input" type="checkbox"> <input v-model="formData.Locked" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label> <label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
</div> </div>
<div class="form-check form-switch" v-if="formData.Source==='db'"> <div class="form-check form-switch" v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox"> <input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label> <label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
</div> </div>
<div class="form-check form-switch" v-if="formData.AuthSources.some(s => s !=='db')">
<input v-model="formData.PersistLocalChanges" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.persist-local-changes.label') }}</label>
</div>
</fieldset> </fieldset>
</template> </template>

View File

@@ -1,7 +1,7 @@
export function base64_url_encode(input) { export function base64_url_encode(input) {
let output = btoa(input) let output = btoa(input)
output = output.replace('+', '.') output = output.replaceAll('+', '.')
output = output.replace('/', '_') output = output.replaceAll('/', '_')
output = output.replace('=', '-') output = output.replaceAll('=', '-')
return output return output
} }

View File

@@ -4,6 +4,7 @@ export function freshInterface() {
Disabled: false, Disabled: false,
DisplayName: "", DisplayName: "",
Identifier: "", Identifier: "",
CreateDefaultPeer: false,
Mode: "server", Mode: "server",
Backend: "local", Backend: "local",
@@ -136,7 +137,7 @@ export function freshUser() {
Identifier: "", Identifier: "",
Email: "", Email: "",
Source: "db", AuthSources: ["db"],
IsAdmin: false, IsAdmin: false,
Firstname: "", Firstname: "",
@@ -154,6 +155,8 @@ export function freshUser() {
ApiEnabled: false, ApiEnabled: false,
PersistLocalChanges: false,
PeerCount: 0, PeerCount: 0,
// Internal values // Internal values

View File

@@ -0,0 +1,86 @@
import { peerStore } from '@/stores/peers';
import { interfaceStore } from '@/stores/interfaces';
import { authStore } from '@/stores/auth';
let socket = null;
let reconnectTimer = null;
let failureCount = 0;
export const websocketWrapper = {
connect() {
if (socket) {
console.log('WebSocket already connected, re-using existing connection.');
return;
}
const protocol = WGPORTAL_BACKEND_BASE_URL.startsWith('https://') ? 'wss://' : 'ws://';
const baseUrl = WGPORTAL_BACKEND_BASE_URL.replace(/^https?:\/\//, '');
const url = `${protocol}${baseUrl}/ws`;
socket = new WebSocket(url);
socket.onopen = () => {
console.log('WebSocket connected');
failureCount = 0;
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
}
};
socket.onclose = () => {
console.log('WebSocket disconnected');
failureCount++;
socket = null;
this.scheduleReconnect();
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
failureCount++;
socket.close();
socket = null;
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'peer_stats':
peerStore().updatePeerTrafficStats(message.data);
break;
case 'interface_stats':
interfaceStore().updateInterfaceTrafficStats(message.data);
break;
}
};
},
disconnect() {
if (socket) {
socket.close();
socket = null;
}
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
failureCount = 0;
}
},
scheduleReconnect() {
if (reconnectTimer) return;
if (!authStore().IsAuthenticated) return; // Don't reconnect if not logged in
reconnectTimer = setInterval(() => {
if (failureCount > 2) {
console.log('WebSocket connection unavailable, giving up.');
clearInterval(reconnectTimer);
reconnectTimer = null;
return;
}
console.log('Attempting to reconnect WebSocket...');
this.connect();
}, 5000);
}
};

View File

@@ -129,6 +129,11 @@
"button-add-peers": "Mehrere Peers hinzufügen", "button-add-peers": "Mehrere Peers hinzufügen",
"button-show-peer": "Peer anzeigen", "button-show-peer": "Peer anzeigen",
"button-edit-peer": "Peer bearbeiten", "button-edit-peer": "Peer bearbeiten",
"button-bulk-delete": "Ausgewählte Peers löschen",
"button-bulk-enable": "Ausgewählte Peers aktivieren",
"button-bulk-disable": "Ausgewählte Peers deaktivieren",
"confirm-bulk-delete": "Sind Sie sicher, dass Sie {count} Peers löschen möchten?",
"confirm-bulk-disable": "Sind Sie sicher, dass Sie {count} Peers deaktivieren möchten?",
"peer-disabled": "Peer ist deaktiviert, Grund:", "peer-disabled": "Peer ist deaktiviert, Grund:",
"peer-expiring": "Peer läuft ab am", "peer-expiring": "Peer läuft ab am",
"peer-connected": "Verbunden", "peer-connected": "Verbunden",
@@ -142,7 +147,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Vorname", "firstname": "Vorname",
"lastname": "Nachname", "lastname": "Nachname",
"source": "Quelle", "sources": "Quellen",
"peers": "Peers", "peers": "Peers",
"admin": "Admin" "admin": "Admin"
}, },
@@ -153,6 +158,14 @@
"button-add-user": "Benutzer hinzufügen", "button-add-user": "Benutzer hinzufügen",
"button-show-user": "Benutzer anzeigen", "button-show-user": "Benutzer anzeigen",
"button-edit-user": "Benutzer bearbeiten", "button-edit-user": "Benutzer bearbeiten",
"button-bulk-delete": "Ausgewählte Benutzer löschen",
"button-bulk-enable": "Ausgewählte Benutzer aktivieren",
"button-bulk-disable": "Ausgewählte Benutzer deaktivieren",
"button-bulk-lock": "Ausgewählte Benutzer sperren",
"button-bulk-unlock": "Ausgewählte Benutzer entsperren",
"confirm-bulk-delete": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?",
"confirm-bulk-disable": "Sind Sie sicher, dass Sie {count} Benutzer deaktivieren möchten?",
"confirm-bulk-lock": "Sind Sie sicher, dass Sie {count} Benutzer sperren möchten?",
"user-disabled": "Benutzer ist deaktiviert, Grund:", "user-disabled": "Benutzer ist deaktiviert, Grund:",
"user-locked": "Konto ist gesperrt, Grund:", "user-locked": "Konto ist gesperrt, Grund:",
"admin": "Benutzer hat Administratorrechte", "admin": "Benutzer hat Administratorrechte",
@@ -365,7 +378,11 @@
}, },
"admin": { "admin": {
"label": "Ist Administrator" "label": "Ist Administrator"
} },
"persist-local-changes": {
"label": "Lokale Änderungen speichern"
},
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben."
}, },
"interface-view": { "interface-view": {
"headline": "Konfiguration für Schnittstelle:" "headline": "Konfiguration für Schnittstelle:"
@@ -456,6 +473,9 @@
"disabled": { "disabled": {
"label": "Schnittstelle deaktiviert" "label": "Schnittstelle deaktiviert"
}, },
"create-default-peer": {
"label": "Peer für neue Benutzer automatisch erstellen"
},
"save-config": { "save-config": {
"label": "wg-quick Konfiguration automatisch speichern" "label": "wg-quick Konfiguration automatisch speichern"
}, },

View File

@@ -129,6 +129,11 @@
"button-add-peers": "Add Multiple Peers", "button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer", "button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer", "button-edit-peer": "Edit Peer",
"button-bulk-delete": "Delete selected peers",
"button-bulk-enable": "Enable selected peers",
"button-bulk-disable": "Disable selected peers",
"confirm-bulk-delete": "Are you sure you want to delete {count} peers?",
"confirm-bulk-disable": "Are you sure you want to disable {count} peers?",
"peer-disabled": "Peer is disabled, reason:", "peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at", "peer-expiring": "Peer is expiring at",
"peer-connected": "Connected", "peer-connected": "Connected",
@@ -142,7 +147,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Firstname", "firstname": "Firstname",
"lastname": "Lastname", "lastname": "Lastname",
"source": "Source", "sources": "Sources",
"peers": "Peers", "peers": "Peers",
"admin": "Admin" "admin": "Admin"
}, },
@@ -153,6 +158,14 @@
"button-add-user": "Add User", "button-add-user": "Add User",
"button-show-user": "Show User", "button-show-user": "Show User",
"button-edit-user": "Edit User", "button-edit-user": "Edit User",
"button-bulk-delete": "Delete selected users",
"button-bulk-enable": "Enable selected users",
"button-bulk-disable": "Disable selected users",
"button-bulk-lock": "Lock selected users",
"button-bulk-unlock": "Unlock selected users",
"confirm-bulk-delete": "Are you sure you want to delete {count} users?",
"confirm-bulk-disable": "Are you sure you want to disable {count} users?",
"confirm-bulk-lock": "Are you sure you want to lock {count} users?",
"user-disabled": "User is disabled, reason:", "user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:", "user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges", "admin": "User has administrator privileges",
@@ -365,7 +378,11 @@
}, },
"admin": { "admin": {
"label": "Is Admin" "label": "Is Admin"
} },
"persist-local-changes": {
"label": "Persist local changes"
},
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization."
}, },
"interface-view": { "interface-view": {
"headline": "Config for Interface:" "headline": "Config for Interface:"
@@ -456,6 +473,9 @@
"disabled": { "disabled": {
"label": "Interface Disabled" "label": "Interface Disabled"
}, },
"create-default-peer": {
"label": "Create default peer for new users"
},
"save-config": { "save-config": {
"label": "Automatically save wg-quick config" "label": "Automatically save wg-quick config"
}, },

View File

@@ -162,7 +162,7 @@
"email": "Correo electrónico", "email": "Correo electrónico",
"firstname": "Nombre", "firstname": "Nombre",
"lastname": "Apellido", "lastname": "Apellido",
"source": "Origen", "sources": "Origen",
"peers": "Peers", "peers": "Peers",
"admin": "Administrador" "admin": "Administrador"
}, },

View File

@@ -137,7 +137,7 @@
"email": "E-mail", "email": "E-mail",
"firstname": "Prénom", "firstname": "Prénom",
"lastname": "Nom", "lastname": "Nom",
"source": "Source", "sources": "Sources",
"peers": "Pairs", "peers": "Pairs",
"admin": "Admin" "admin": "Admin"
}, },

View File

@@ -136,7 +136,7 @@
"email": "이메일", "email": "이메일",
"firstname": "이름", "firstname": "이름",
"lastname": "성", "lastname": "성",
"source": "소스", "sources": "소스",
"peers": "피어", "peers": "피어",
"admin": "관리자" "admin": "관리자"
}, },

View File

@@ -137,7 +137,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Primeiro Nome", "firstname": "Primeiro Nome",
"lastname": "Último Nome", "lastname": "Último Nome",
"source": "Fonte", "sources": "Fonte",
"peers": "Peers", "peers": "Peers",
"admin": "Administrador" "admin": "Administrador"
}, },

View File

@@ -143,7 +143,7 @@
"email": "Электронная почта", "email": "Электронная почта",
"firstname": "Имя", "firstname": "Имя",
"lastname": "Фамилия", "lastname": "Фамилия",
"source": "Источник", "sources": "Источник",
"peers": "Пиры", "peers": "Пиры",
"admin": "Админ" "admin": "Админ"
}, },

View File

@@ -135,7 +135,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Ім'я", "firstname": "Ім'я",
"lastname": "Прізвище", "lastname": "Прізвище",
"source": "Джерело", "sources": "Джерело",
"peers": "Піри", "peers": "Піри",
"admin": "Адміністратор" "admin": "Адміністратор"
}, },

View File

@@ -134,7 +134,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Tên", "firstname": "Tên",
"lastname": "Họ", "lastname": "Họ",
"source": "Nguồn", "sources": "Nguồn",
"peers": "Peers", "peers": "Peers",
"admin": "Quản trị viên" "admin": "Quản trị viên"
}, },

View File

@@ -134,7 +134,7 @@
"email": "电子邮件", "email": "电子邮件",
"firstname": "名", "firstname": "名",
"lastname": "姓", "lastname": "姓",
"source": "来源", "sources": "来源",
"peers": "节点", "peers": "节点",
"admin": "管理员" "admin": "管理员"
}, },

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification"; import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper' import { apiWrapper } from '@/helpers/fetch-wrapper'
import { websocketWrapper } from '@/helpers/websocket-wrapper'
import router from '../router' import router from '../router'
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
import {base64_url_encode} from "@/helpers/encoding"; import {base64_url_encode} from "@/helpers/encoding";
@@ -295,9 +296,11 @@ export const authStore = defineStore('auth',{
} }
} }
localStorage.setItem('user', JSON.stringify(this.user)) localStorage.setItem('user', JSON.stringify(this.user))
websocketWrapper.connect()
} else { } else {
this.user = null this.user = null
localStorage.removeItem('user') localStorage.removeItem('user')
websocketWrapper.disconnect()
} }
}, },
setWebAuthnCredentials(credentials) { setWebAuthnCredentials(credentials) {

View File

@@ -14,6 +14,7 @@ export const interfaceStore = defineStore('interfaces', {
configuration: "", configuration: "",
selected: "", selected: "",
fetching: false, fetching: false,
trafficStats: {},
}), }),
getters: { getters: {
Count: (state) => state.interfaces.length, Count: (state) => state.interfaces.length,
@@ -24,6 +25,9 @@ export const interfaceStore = defineStore('interfaces', {
}, },
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0], GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
isFetching: (state) => state.fetching, isFetching: (state) => state.fetching,
TrafficStats: (state) => {
return (state.selected in state.trafficStats) ? state.trafficStats[state.selected] : { Received: 0, Transmitted: 0 }
},
}, },
actions: { actions: {
setInterfaces(interfaces) { setInterfaces(interfaces) {
@@ -34,6 +38,14 @@ export const interfaceStore = defineStore('interfaces', {
this.selected = "" this.selected = ""
} }
this.fetching = false this.fetching = false
this.trafficStats = {}
},
updateInterfaceTrafficStats(interfaceStats) {
const id = interfaceStats.EntityId;
this.trafficStats[id] = {
Received: interfaceStats.BytesReceived,
Transmitted: interfaceStats.BytesTransmitted,
};
}, },
async LoadInterfaces() { async LoadInterfaces() {
this.fetching = true this.fetching = true

View File

@@ -23,6 +23,7 @@ export const peerStore = defineStore('peers', {
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
trafficStats: {},
}), }),
getters: { getters: {
Find: (state) => { Find: (state) => {
@@ -76,6 +77,9 @@ export const peerStore = defineStore('peers', {
Statistics: (state) => { Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats() return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
}, },
TrafficStats: (state) => {
return (id) => (id in state.trafficStats) ? state.trafficStats[id] : { Received: 0, Transmitted: 0 }
},
hasStatistics: (state) => state.statsEnabled, hasStatistics: (state) => state.statsEnabled,
}, },
@@ -111,6 +115,7 @@ export const peerStore = defineStore('peers', {
this.peers = peers this.peers = peers
this.calculatePages() this.calculatePages()
this.fetching = false this.fetching = false
this.trafficStats = {}
}, },
setPeer(peer) { setPeer(peer) {
this.peer = peer this.peer = peer
@@ -126,11 +131,19 @@ export const peerStore = defineStore('peers', {
if (!statsResponse) { if (!statsResponse) {
this.stats = {} this.stats = {}
this.statsEnabled = false this.statsEnabled = false
this.trafficStats = {}
} else { } else {
this.stats = statsResponse.Stats this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled this.statsEnabled = statsResponse.Enabled
} }
}, },
updatePeerTrafficStats(peerStats) {
const id = peerStats.EntityId;
this.trafficStats[id] = {
Received: peerStats.BytesReceived,
Transmitted: peerStats.BytesTransmitted,
};
},
async Reset() { async Reset() {
this.setPeers([]) this.setPeers([])
this.setStats(undefined) this.setStats(undefined)
@@ -222,6 +235,73 @@ export const peerStore = defineStore('peers', {
throw new Error(error) throw new Error(error)
}) })
}, },
async BulkDelete(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
.then(() => {
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
this.fetching = false
notify({
title: "Peers deleted",
text: "Selected peers have been deleted!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to delete peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to delete selected peers!",
type: 'error',
})
throw new Error(error)
})
},
async BulkEnable(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
.then(async () => {
await this.LoadPeers()
notify({
title: "Peers enabled",
text: "Selected peers have been enabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to enable peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to enable selected peers!",
type: 'error',
})
throw new Error(error)
})
},
async BulkDisable(ids, reason) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
.then(async () => {
await this.LoadPeers()
notify({
title: "Peers disabled",
text: "Selected peers have been disabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to disable peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to disable selected peers!",
type: 'error',
})
throw new Error(error)
})
},
async UpdatePeer(id, formData) { async UpdatePeer(id, formData) {
this.fetching = true this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData) return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper"; import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification"; import {notify} from "@kyvg/vue3-notification";
import {authStore} from "@/stores/auth"; import {authStore} from "@/stores/auth";
import {peerStore} from "@/stores/peers";
import { base64_url_encode } from '@/helpers/encoding'; import { base64_url_encode } from '@/helpers/encoding';
import {freshStats} from "@/helpers/models"; import {freshStats} from "@/helpers/models";
import { ipToBigInt } from '@/helpers/utils'; import { ipToBigInt } from '@/helpers/utils';
@@ -218,5 +219,18 @@ export const profileStore = defineStore('profile', {
}) })
}) })
}, },
async BulkDelete(ids) {
this.fetching = true
const peers = peerStore()
return peers.BulkDelete(ids)
.then(() => {
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
this.fetching = false
})
.catch(error => {
this.fetching = false
throw new Error(error)
})
},
} }
}) })

View File

@@ -142,5 +142,140 @@ export const userStore = defineStore('users', {
}) })
}) })
}, },
async BulkDelete(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
.then(() => {
this.users = this.users.filter(u => !ids.includes(u.Identifier))
this.fetching = false
notify({
title: "Users deleted",
text: "Selected users have been deleted!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to delete users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to delete selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkEnable(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Disabled = false
u.DisabledReason = ""
}
})
this.fetching = false
notify({
title: "Users enabled",
text: "Selected users have been enabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to enable users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to enable selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkDisable(ids, reason) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Disabled = true
u.DisabledReason = reason
}
})
this.fetching = false
notify({
title: "Users disabled",
text: "Selected users have been disabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to disable users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to disable selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkLock(ids, reason) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-lock`, { Identifiers: ids, Reason: reason })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Locked = true
u.LockedReason = reason
}
})
this.fetching = false
notify({
title: "Users locked",
text: "Selected users have been locked!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to lock users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to lock selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkUnlock(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-unlock`, { Identifiers: ids })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Locked = false
u.LockedReason = ""
}
})
this.fetching = false
notify({
title: "Users unlocked",
text: "Selected users have been unlocked!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to unlock users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to unlock selected users!",
type: 'error',
})
throw new Error(error)
})
},
} }
}) })

View File

@@ -29,6 +29,10 @@ const sortKey = ref("")
const sortOrder = ref(1) const sortOrder = ref(1)
const selectAll = ref(false) const selectAll = ref(false)
const selectedPeers = computed(() => {
return peers.All.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
})
function sortBy(key) { function sortBy(key) {
if (sortKey.value === key) { if (sortKey.value === key) {
sortOrder.value = sortOrder.value * -1; // Toggle sort order sortOrder.value = sortOrder.value * -1; // Toggle sort order
@@ -111,6 +115,39 @@ async function saveConfig() {
} }
} }
async function bulkDelete() {
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
try {
await peers.BulkDelete(selectedPeers.value)
selectAll.value = false // reset selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkEnable() {
try {
await peers.BulkEnable(selectedPeers.value)
selectAll.value = false
peers.All.forEach(p => p.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
async function bulkDisable() {
if (confirm(t('interfaces.confirm-bulk-disable', {count: selectedPeers.value.length}))) {
try {
await peers.BulkDisable(selectedPeers.value)
selectAll.value = false
peers.All.forEach(p => p.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
}
function toggleSelectAll() { function toggleSelectAll() {
peers.FilteredAndPaged.forEach(peer => { peers.FilteredAndPaged.forEach(peer => {
peer.IsSelected = selectAll.value; peer.IsSelected = selectAll.value;
@@ -173,6 +210,12 @@ onMounted(async () => {
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>) {{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span> <span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
<div v-if="interfaces.GetSelected && (interfaces.TrafficStats.Received > 0 || interfaces.TrafficStats.Transmitted > 0)" class="mt-2">
<small class="text-muted">
Traffic: <i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Transmitted) }}/s
</small>
</div>
</div> </div>
<div class="col-12 col-lg-4 text-lg-end"> <div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a> <a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
@@ -353,6 +396,13 @@ onMounted(async () => {
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a> <a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div> </div>
</div> </div>
<div class="row" v-if="selectedPeers.length > 0">
<div class="col-12 text-lg-end">
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-enable')" @click.prevent="bulkEnable"><i class="fa-regular fa-circle-check"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-disable')" @click.prevent="bulkDisable"><i class="fa fa-ban"></i></a>
<a class="btn btn-outline-danger btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-delete')" @click.prevent="bulkDelete"><i class="fa fa-trash-can"></i></a>
</div>
</div>
<div v-if="interfaces.Count!==0" class="mt-2 table-responsive"> <div v-if="interfaces.Count!==0" class="mt-2 table-responsive">
<div v-if="peers.Count===0"> <div v-if="peers.Count===0">
<h4>{{ $t('interfaces.no-peer.headline') }}</h4> <h4>{{ $t('interfaces.no-peer.headline') }}</h4>
@@ -407,14 +457,19 @@ onMounted(async () => {
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td> <td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td v-if="peers.hasStatistics"> <td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected"> <div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span> <span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small>
</div> </div>
<div v-else> <div v-else>
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span> <span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
</div> </div>
</td> </td>
<td v-if="peers.hasStatistics" > <td v-if="peers.hasStatistics" >
<span class="text-center" >{{ humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted) }}</span> <div class="d-flex flex-column">
<span :title="humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) + ' / ' + humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted)">
<i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Transmitted) }}/s
</span>
</div>
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a> <a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>

View File

@@ -1,14 +1,19 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref } from "vue"; import { onMounted, ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { peerStore } from "@/stores/peers";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue"; import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { humanFileSize } from "@/helpers/utils";
const settings = settingsStore() const settings = settingsStore()
const profile = profileStore() const profile = profileStore()
const peers = peerStore()
const { t } = useI18n()
const viewedPeerId = ref("") const viewedPeerId = ref("")
const editPeerId = ref("") const editPeerId = ref("")
@@ -17,6 +22,10 @@ const sortKey = ref("")
const sortOrder = ref(1) const sortOrder = ref(1)
const selectAll = ref(false) const selectAll = ref(false)
const selectedPeers = computed(() => {
return profile.Peers.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
})
function sortBy(key) { function sortBy(key) {
if (sortKey.value === key) { if (sortKey.value === key) {
sortOrder.value = sortOrder.value * -1; // Toggle sort order sortOrder.value = sortOrder.value * -1; // Toggle sort order
@@ -35,6 +44,17 @@ function friendlyInterfaceName(id, name) {
return id return id
} }
async function bulkDelete() {
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
try {
await profile.BulkDelete(selectedPeers.value)
selectAll.value = false // reset selection
} catch (e) {
// notification is handled in store
}
}
}
function toggleSelectAll() { function toggleSelectAll() {
profile.FilteredAndPagedPeers.forEach(peer => { profile.FilteredAndPagedPeers.forEach(peer => {
peer.IsSelected = selectAll.value; peer.IsSelected = selectAll.value;
@@ -84,6 +104,13 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<div class="row" v-if="selectedPeers.length > 0">
<div class="col-12 text-lg-end">
<button class="btn btn-outline-danger btn-sm" :title="$t('interfaces.button-bulk-delete')" @click.prevent="bulkDelete">
<i class="fa fa-trash-can"></i>
</button>
</div>
</div>
<div class="mt-2 table-responsive"> <div class="mt-2 table-responsive">
<div v-if="profile.CountPeers === 0"> <div v-if="profile.CountPeers === 0">
<h4>{{ $t('profile.no-peer.headline') }}</h4> <h4>{{ $t('profile.no-peer.headline') }}</h4>

View File

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

View File

@@ -1,16 +1,77 @@
<script setup> <script setup>
import {userStore} from "@/stores/users"; import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue"; import {ref, onMounted, computed} from "vue";
import UserEditModal from "../components/UserEditModal.vue"; import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue"; import UserViewModal from "../components/UserViewModal.vue";
import {useI18n} from "vue-i18n";
const users = userStore() const users = userStore()
const { t } = useI18n()
const editUserId = ref("") const editUserId = ref("")
const viewedUserId = ref("") const viewedUserId = ref("")
const selectAll = ref(false) const selectAll = ref(false)
const selectedUsers = computed(() => {
return users.All.filter(user => user.IsSelected).map(user => user.Identifier);
})
async function bulkDelete() {
if (confirm(t('users.confirm-bulk-delete', {count: selectedUsers.value.length}))) {
try {
await users.BulkDelete(selectedUsers.value)
selectAll.value = false // reset selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkEnable() {
try {
await users.BulkEnable(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
async function bulkDisable() {
if (confirm(t('users.confirm-bulk-disable', {count: selectedUsers.value.length}))) {
try {
await users.BulkDisable(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkLock() {
if (confirm(t('users.confirm-bulk-lock', {count: selectedUsers.value.length}))) {
try {
await users.BulkLock(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkUnlock() {
try {
await users.BulkUnlock(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
function toggleSelectAll() { function toggleSelectAll() {
users.FilteredAndPaged.forEach(user => { users.FilteredAndPaged.forEach(user => {
user.IsSelected = selectAll.value; user.IsSelected = selectAll.value;
@@ -45,6 +106,15 @@ onMounted(() => {
</a> </a>
</div> </div>
</div> </div>
<div class="row" v-if="selectedUsers.length > 0">
<div class="col-12 text-lg-end">
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-enable')" @click.prevent="bulkEnable"><i class="fa-regular fa-circle-check"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-disable')" @click.prevent="bulkDisable"><i class="fa fa-ban"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-unlock')" @click.prevent="bulkUnlock"><i class="fa-solid fa-lock-open"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-lock')" @click.prevent="bulkLock"><i class="fa-solid fa-lock"></i></a>
<a class="btn btn-outline-danger btn-sm ms-2" href="#" :title="$t('users.button-bulk-delete')" @click.prevent="bulkDelete"><i class="fa fa-trash-can"></i></a>
</div>
</div>
<div class="mt-2 table-responsive"> <div class="mt-2 table-responsive">
<div v-if="users.Count===0"> <div v-if="users.Count===0">
<h4>{{ $t('users.no-user.headline') }}</h4> <h4>{{ $t('users.no-user.headline') }}</h4>
@@ -61,7 +131,7 @@ onMounted(() => {
<th scope="col">{{ $t('users.table-heading.email') }}</th> <th scope="col">{{ $t('users.table-heading.email') }}</th>
<th scope="col">{{ $t('users.table-heading.firstname') }}</th> <th scope="col">{{ $t('users.table-heading.firstname') }}</th>
<th scope="col">{{ $t('users.table-heading.lastname') }}</th> <th scope="col">{{ $t('users.table-heading.lastname') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th> <th class="text-center" scope="col">{{ $t('users.table-heading.sources') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th> <th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th> <th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
<th scope="col"></th><!-- Actions --> <th scope="col"></th><!-- Actions -->
@@ -80,7 +150,7 @@ onMounted(() => {
<td>{{user.Email}}</td> <td>{{user.Email}}</td>
<td>{{user.Firstname}}</td> <td>{{user.Firstname}}</td>
<td>{{user.Lastname}}</td> <td>{{user.Lastname}}</td>
<td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td> <td><span class="badge bg-light me-1" v-for="src in user.AuthSources" :key="src">{{src}}</span></td>
<td class="text-center">{{user.PeerCount}}</td> <td class="text-center">{{user.PeerCount}}</td>
<td class="text-center"> <td class="text-center">
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span> <span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>

78
go.mod
View File

@@ -1,18 +1,19 @@
module github.com/h44z/wg-portal module github.com/h44z/wg-portal
go 1.24.0 go 1.25.0
require ( require (
github.com/a8m/envsubst v1.4.3 github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.9.0 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/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pkgz/routegroup v1.6.0 github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0 github.com/go-webauthn/webauthn v0.15.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0 github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
@@ -21,9 +22,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.33.0 golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.41.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
@@ -33,42 +34,42 @@ require (
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.2.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/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // 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.13 // indirect
github.com/glebarez/go-sqlite v1.22.0 // 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-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-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.0 // indirect github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect github.com/go-webauthn/x v0.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.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.8 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -77,31 +78,32 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // 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.6 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.17.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect modernc.org/sqlite v1.46.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

190
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
@@ -20,8 +22,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.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 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/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.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= 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.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.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
@@ -38,8 +40,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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= 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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -50,8 +52,10 @@ 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/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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= 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/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -62,29 +66,35 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-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 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= 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.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= 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.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
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.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= 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 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -93,23 +103,29 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY= github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A= github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -117,8 +133,10 @@ 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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -126,15 +144,21 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 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 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -175,16 +199,18 @@ 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 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= 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.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.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= 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/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/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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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-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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@@ -193,19 +219,25 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 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.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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.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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -262,18 +294,24 @@ 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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.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.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -291,10 +329,14 @@ 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.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -302,8 +344,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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -324,8 +366,10 @@ 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.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.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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -354,16 +398,20 @@ 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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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 h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
@@ -371,6 +419,8 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdI
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -390,18 +440,24 @@ 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.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -410,8 +466,10 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -23,9 +23,6 @@ import (
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
var SchemaVersion uint64 = 1
// SysStat stores the current database schema version and the timestamp when it was applied. // SysStat stores the current database schema version and the timestamp when it was applied.
type SysStat struct { type SysStat struct {
MigratedAt time.Time `gorm:"column:migrated_at"` MigratedAt time.Time `gorm:"column:migrated_at"`
@@ -179,13 +176,15 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
// SqlRepo is a SQL database repository implementation. // SqlRepo is a SQL database repository implementation.
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems. // Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
type SqlRepo struct { type SqlRepo struct {
db *gorm.DB db *gorm.DB
cfg *config.Config
} }
// NewSqlRepository creates a new SqlRepo instance. // NewSqlRepository creates a new SqlRepo instance.
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) { func NewSqlRepository(db *gorm.DB, cfg *config.Config) (*SqlRepo, error) {
repo := &SqlRepo{ repo := &SqlRepo{
db: db, db: db,
cfg: cfg,
} }
if err := repo.preCheck(); err != nil { if err := repo.preCheck(); err != nil {
@@ -223,6 +222,8 @@ func (r *SqlRepo) preCheck() error {
func (r *SqlRepo) migrate() error { func (r *SqlRepo) migrate() error {
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{})) slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{})) slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
slog.Debug("running migration: user authentications", "result",
r.db.AutoMigrate(&domain.UserAuthentication{}))
slog.Debug("running migration: user webauthn credentials", "result", slog.Debug("running migration: user webauthn credentials", "result",
r.db.AutoMigrate(&domain.UserWebauthnCredential{})) r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{})) slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
@@ -232,16 +233,88 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{})) slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{} existingSysStat := SysStat{}
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat) r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 { if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
sysStat := SysStat{ sysStat := SysStat{
MigratedAt: time.Now(), MigratedAt: time.Now(),
SchemaVersion: SchemaVersion, SchemaVersion: schemaVersion,
} }
if err := r.db.Create(&sysStat).Error; err != nil { if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err) return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
} }
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion) slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 1 --> 2
if existingSysStat.SchemaVersion == 1 {
const schemaVersion = 2
// Preserve existing behavior for installations that had default-peer-creation enabled.
if r.cfg.Core.CreateDefaultPeer {
err := r.db.Model(&domain.Interface{}).
Where("type = ?", domain.InterfaceTypeServer).
Update("create_default_peer", true).Error
if err != nil {
return fmt.Errorf("failed to migrate interface flags for schema version %d: %w", schemaVersion, err)
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 2 --> 3
if existingSysStat.SchemaVersion == 2 {
const schemaVersion = 3
// Migration to multi-auth
err := r.db.Transaction(func(tx *gorm.DB) error {
var users []domain.User
if err := tx.Find(&users).Error; err != nil {
return err
}
now := time.Now()
for _, user := range users {
auth := domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemDBMigrator,
UpdatedBy: domain.CtxSystemDBMigrator,
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: user.Identifier,
Source: user.Source,
ProviderName: user.ProviderName,
}
if err := tx.Create(&auth).Error; err != nil {
return err
}
}
slog.Debug("migrated users to multi-auth model", "schema_version", schemaVersion)
return nil
})
if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
} }
return nil return nil
@@ -751,7 +824,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) { func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User var user domain.User
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").First(&user, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound return nil, domain.ErrNotFound
@@ -769,7 +842,8 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var users []domain.User var users []domain.User
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error err := r.db.WithContext(ctx).Where("email = ?",
email).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound return nil, domain.ErrNotFound
} }
@@ -809,7 +883,7 @@ func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdB
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) { func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User var users []domain.User
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -829,6 +903,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
Or("lastname LIKE ?", searchValue). Or("lastname LIKE ?", searchValue).
Or("email LIKE ?", searchValue). Or("email LIKE ?", searchValue).
Preload("WebAuthnCredentialList"). Preload("WebAuthnCredentialList").
Preload("Authentications").
Find(&users).Error Find(&users).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -888,7 +963,17 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
) { ) {
var user domain.User var user domain.User
// userDefaults will be applied to newly created user records result := tx.Model(&user).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&user, id)
if result.Error != nil {
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, result.Error
}
}
if result.Error == nil && result.RowsAffected > 0 {
return &user, nil
}
// create a new user record if no user record exists yet
userDefaults := domain.User{ userDefaults := domain.User{
BaseModel: domain.BaseModel{ BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(), CreatedBy: ui.UserId(),
@@ -897,16 +982,15 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
}, },
Identifier: id, Identifier: id,
Source: domain.UserSourceDatabase,
IsAdmin: false, IsAdmin: false,
} }
err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error err := tx.Create(&userDefaults).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &user, nil return &userDefaults, nil
} }
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error { func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
@@ -923,6 +1007,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
return fmt.Errorf("failed to update users webauthn credentials: %w", err) return fmt.Errorf("failed to update users webauthn credentials: %w", err)
} }
err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("Authentications").Unscoped().Replace(user.Authentications)
if err != nil {
return fmt.Errorf("failed to update users authentications: %w", err)
}
return nil return nil
} }

View File

@@ -626,7 +626,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
if err != nil { if err != nil {
slog.Warn("failed to executed shell command", slog.Warn("failed to executed shell command",
"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err) "command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err)
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err) return fmt.Errorf("failed to execute shell command %s: %w", commandWithInterfaceName, err)
} }
slog.Debug("executed shell command", slog.Debug("executed shell command",
"command", commandWithInterfaceName, "command", commandWithInterfaceName,

View File

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

View File

@@ -800,6 +800,126 @@
} }
} }
}, },
"/peer/bulk-delete": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Bulk delete selected peers.",
"operationId": "peers_handleBulkDelete",
"parameters": [
{
"description": "A list of peer identifiers to delete",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if deletion was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/bulk-disable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Bulk disable selected peers.",
"operationId": "peers_handleBulkDisable",
"parameters": [
{
"description": "A list of peer identifiers to disable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/bulk-enable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Bulk enable selected peers.",
"operationId": "peers_handleBulkEnable",
"parameters": [
{
"description": "A list of peer identifiers to enable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/config-mail": { "/peer/config-mail": {
"post": { "post": {
"produces": [ "produces": [
@@ -1324,6 +1444,206 @@
} }
} }
}, },
"/user/bulk-delete": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk delete selected users.",
"operationId": "users_handleBulkDelete",
"parameters": [
{
"description": "A list of user identifiers to delete",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if deletion was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-disable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk disable selected users.",
"operationId": "users_handleBulkDisable",
"parameters": [
{
"description": "A list of user identifiers to disable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-enable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk enable selected users.",
"operationId": "users_handleBulkEnable",
"parameters": [
{
"description": "A list of user identifiers to enable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-lock": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk lock selected users.",
"operationId": "users_handleBulkLock",
"parameters": [
{
"description": "A list of user identifiers to lock",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-unlock": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk unlock selected users.",
"operationId": "users_handleBulkUnlock",
"parameters": [
{
"description": "A list of user identifiers to unlock",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/new": { "/user/new": {
"post": { "post": {
"produces": [ "produces": [
@@ -1737,6 +2057,23 @@
} }
} }
}, },
"model.BulkPeerRequest": {
"type": "object",
"required": [
"Identifiers"
],
"properties": {
"Identifiers": {
"type": "array",
"items": {
"type": "string"
}
},
"Reason": {
"type": "string"
}
}
},
"model.ConfigOption-array_string": { "model.ConfigOption-array_string": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2339,6 +2676,12 @@
"ApiTokenCreated": { "ApiTokenCreated": {
"type": "string" "type": "string"
}, },
"AuthSources": {
"type": "array",
"items": {
"type": "string"
}
},
"Department": { "Department": {
"type": "string" "type": "string"
}, },
@@ -2382,14 +2725,11 @@
"PeerCount": { "PeerCount": {
"type": "integer" "type": "integer"
}, },
"PersistLocalChanges": {
"type": "boolean"
},
"Phone": { "Phone": {
"type": "string" "type": "string"
},
"ProviderName": {
"type": "string"
},
"Source": {
"type": "string"
} }
} }
}, },

View File

@@ -16,6 +16,17 @@ definitions:
Timestamp: Timestamp:
type: string type: string
type: object type: object
model.BulkPeerRequest:
properties:
Identifiers:
items:
type: string
type: array
Reason:
type: string
required:
- Identifiers
type: object
model.ConfigOption-array_string: model.ConfigOption-array_string:
properties: properties:
Overridable: Overridable:
@@ -420,6 +431,10 @@ definitions:
type: string type: string
ApiTokenCreated: ApiTokenCreated:
type: string type: string
AuthSources:
items:
type: string
type: array
Department: Department:
type: string type: string
Disabled: Disabled:
@@ -450,12 +465,10 @@ definitions:
type: string type: string
PeerCount: PeerCount:
type: integer type: integer
PersistLocalChanges:
type: boolean
Phone: Phone:
type: string type: string
ProviderName:
type: string
Source:
type: string
type: object type: object
model.WebAuthnCredentialRequest: model.WebAuthnCredentialRequest:
properties: properties:
@@ -1080,6 +1093,84 @@ paths:
summary: Update the given peer record. summary: Update the given peer record.
tags: tags:
- Peer - Peer
/peer/bulk-delete:
post:
operationId: peers_handleBulkDelete
parameters:
- description: A list of peer identifiers to delete
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if deletion was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk delete selected peers.
tags:
- Peer
/peer/bulk-disable:
post:
operationId: peers_handleBulkDisable
parameters:
- description: A list of peer identifiers to disable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk disable selected peers.
tags:
- Peer
/peer/bulk-enable:
post:
operationId: peers_handleBulkEnable
parameters:
- description: A list of peer identifiers to enable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk enable selected peers.
tags:
- Peer
/peer/config-mail: /peer/config-mail:
post: post:
operationId: peers_handleEmailPost operationId: peers_handleEmailPost
@@ -1571,6 +1662,136 @@ paths:
summary: Get all user records. summary: Get all user records.
tags: tags:
- Users - Users
/user/bulk-delete:
post:
operationId: users_handleBulkDelete
parameters:
- description: A list of user identifiers to delete
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if deletion was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk delete selected users.
tags:
- Users
/user/bulk-disable:
post:
operationId: users_handleBulkDisable
parameters:
- description: A list of user identifiers to disable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk disable selected users.
tags:
- Users
/user/bulk-enable:
post:
operationId: users_handleBulkEnable
parameters:
- description: A list of user identifiers to enable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk enable selected users.
tags:
- Users
/user/bulk-lock:
post:
operationId: users_handleBulkLock
parameters:
- description: A list of user identifiers to lock
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk lock selected users.
tags:
- Users
/user/bulk-unlock:
post:
operationId: users_handleBulkUnlock
parameters:
- description: A list of user identifiers to unlock
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk unlock selected users.
tags:
- Users
/user/new: /user/new:
post: post:
operationId: users_handleCreatePost operationId: users_handleCreatePost

View File

@@ -2132,6 +2132,22 @@
"minLength": 32, "minLength": 32,
"example": "" "example": ""
}, },
"AuthSources": {
"description": "The source of the user. This field is optional.",
"type": "array",
"items": {
"type": "string",
"enum": [
"db",
"ldap",
"oauth"
]
},
"readOnly": true,
"example": [
"db"
]
},
"Department": { "Department": {
"description": "The department of the user. This field is optional.", "description": "The department of the user. This field is optional.",
"type": "string", "type": "string",
@@ -2205,22 +2221,6 @@
"description": "The phone number of the user. This field is optional.", "description": "The phone number of the user. This field is optional.",
"type": "string", "type": "string",
"example": "+1234546789" "example": "+1234546789"
},
"ProviderName": {
"description": "The name of the authentication provider. This field is read-only.",
"type": "string",
"readOnly": true,
"example": ""
},
"Source": {
"description": "The source of the user. This field is optional.",
"type": "string",
"enum": [
"db",
"ldap",
"oauth"
],
"example": "db"
} }
} }
}, },

View File

@@ -490,6 +490,18 @@ definitions:
maxLength: 64 maxLength: 64
minLength: 32 minLength: 32
type: string type: string
AuthSources:
description: The source of the user. This field is optional.
example:
- db
items:
enum:
- db
- ldap
- oauth
type: string
readOnly: true
type: array
Department: Department:
description: The department of the user. This field is optional. description: The department of the user. This field is optional.
example: Software Development example: Software Development
@@ -552,19 +564,6 @@ definitions:
description: The phone number of the user. This field is optional. description: The phone number of the user. This field is optional.
example: "+1234546789" example: "+1234546789"
type: string type: string
ProviderName:
description: The name of the authentication provider. This field is read-only.
example: ""
readOnly: true
type: string
Source:
description: The source of the user. This field is optional.
enum:
- db
- ldap
- oauth
example: db
type: string
required: required:
- Identifier - Identifier
type: object type: object

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 709 B

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
package logging package logging
import ( import (
"bufio"
"net"
"net/http" "net/http"
) )
@@ -38,6 +40,12 @@ func (w *writerWrapper) Write(data []byte) (int, error) {
return n, err return n, err
} }
// Hijack wraps the Hijack method of the ResponseWriter and returns the hijacked connection.
// This is required for websockets to work.
func (w *writerWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return http.NewResponseController(w.ResponseWriter).Hijack()
}
// newWriterWrapper returns a new writerWrapper that wraps the given http.ResponseWriter. // newWriterWrapper returns a new writerWrapper that wraps the given http.ResponseWriter.
// It initializes the StatusCode to http.StatusOK. // It initializes the StatusCode to http.StatusOK.
func newWriterWrapper(w http.ResponseWriter) *writerWrapper { func newWriterWrapper(w http.ResponseWriter) *writerWrapper {

View File

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

View File

@@ -2,6 +2,7 @@ package backend
import ( import (
"context" "context"
"fmt"
"io" "io"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
@@ -118,3 +119,30 @@ func (p PeerService) SendPeerEmail(
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) { func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
return p.peers.GetPeerStats(ctx, id) return p.peers.GetPeerStats(ctx, id)
} }
func (p PeerService) BulkDelete(ctx context.Context, ids []domain.PeerIdentifier) error {
for _, id := range ids {
if err := p.peers.DeletePeer(ctx, id); err != nil {
return fmt.Errorf("failed to delete peer %s: %w", id, err)
}
}
return nil
}
func (p PeerService) BulkUpdate(ctx context.Context, ids []domain.PeerIdentifier, updateFn func(*domain.Peer)) error {
for _, id := range ids {
peer, err := p.peers.GetPeer(ctx, id)
if err != nil {
return fmt.Errorf("failed to get peer %s: %w", id, err)
}
updateFn(peer)
if _, err := p.peers.UpdatePeer(ctx, peer); err != nil {
return fmt.Errorf("failed to update peer %s: %w", id, err)
}
}
return nil
}

View File

@@ -3,6 +3,7 @@ package backend
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strings" "strings"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
@@ -72,7 +73,11 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
return u.users.DeactivateApi(ctx, id) return u.users.DeactivateApi(ctx, id)
} }
func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) { func (u UserService) ChangePassword(
ctx context.Context,
id domain.UserIdentifier,
oldPassword, newPassword string,
) (*domain.User, error) {
oldPassword = strings.TrimSpace(oldPassword) oldPassword = strings.TrimSpace(oldPassword)
newPassword = strings.TrimSpace(newPassword) newPassword = strings.TrimSpace(newPassword)
@@ -91,8 +96,10 @@ func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifie
} }
// ensure that the user uses the database backend; otherwise we can't change the password // ensure that the user uses the database backend; otherwise we can't change the password
if user.Source != domain.UserSourceDatabase { if !slices.ContainsFunc(user.Authentications, func(authentication domain.UserAuthentication) bool {
return nil, fmt.Errorf("user source %s does not support password changes", user.Source) return authentication.Source == domain.UserSourceDatabase
}) {
return nil, fmt.Errorf("user has no linked authentication source that does support password changes")
} }
// validate old password // validate old password
@@ -121,3 +128,30 @@ func (u UserService) GetUserPeerStats(ctx context.Context, id domain.UserIdentif
func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) { func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) {
return u.wg.GetUserInterfaces(ctx, id) return u.wg.GetUserInterfaces(ctx, id)
} }
func (u UserService) BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error {
for _, id := range ids {
if err := u.users.DeleteUser(ctx, id); err != nil {
return fmt.Errorf("failed to delete user %s: %w", id, err)
}
}
return nil
}
func (u UserService) BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error {
for _, id := range ids {
user, err := u.users.GetUser(ctx, id)
if err != nil {
return fmt.Errorf("failed to get user %s: %w", id, err)
}
updateFn(user)
if _, err := u.users.UpdateUser(ctx, user); err != nil {
return fmt.Errorf("failed to update user %s: %w", id, err)
}
}
return nil
}

View File

@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -449,7 +448,17 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
// isValidReturnUrl checks if the given return URL matches the configured external URL of the application. // isValidReturnUrl checks if the given return URL matches the configured external URL of the application.
func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool { func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
if !strings.HasPrefix(returnUrl, e.cfg.Web.ExternalUrl) { expectedUrl, err := url.Parse(e.cfg.Web.ExternalUrl)
if err != nil {
return false
}
returnUrlParsed, err := url.Parse(returnUrl)
if err != nil {
return false
}
if returnUrlParsed.Scheme != expectedUrl.Scheme || returnUrlParsed.Host != expectedUrl.Host {
return false return false
} }

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"io" "io"
"net/http" "net/http"
"time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -41,6 +42,10 @@ type PeerService interface {
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
// GetPeerStats returns the peer stats for the given interface. // GetPeerStats returns the peer stats for the given interface.
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
// BulkDelete deletes multiple peers.
BulkDelete(context.Context, []domain.PeerIdentifier) error
// BulkUpdate modifies multiple peers.
BulkUpdate(context.Context, []domain.PeerIdentifier, func(*domain.Peer)) error
} }
type PeerEndpoint struct { type PeerEndpoint struct {
@@ -84,6 +89,9 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /{id}", e.handleSingleGet()) apiGroup.HandleFunc("GET /{id}", e.handleSingleGet())
apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut()) apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut())
apiGroup.HandleFunc("DELETE /{id}", e.handleDelete()) apiGroup.HandleFunc("DELETE /{id}", e.handleDelete())
apiGroup.HandleFunc("POST /bulk-delete", e.handleBulkDelete())
apiGroup.HandleFunc("POST /bulk-enable", e.handleBulkEnable())
apiGroup.HandleFunc("POST /bulk-disable", e.handleBulkDisable())
} }
// handleAllGet returns a gorm Handler function. // handleAllGet returns a gorm Handler function.
@@ -521,3 +529,114 @@ func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
} }
return configStyle return configStyle
} }
// handleBulkDelete returns a gorm Handler function.
//
// @ID peers_handleBulkDelete
// @Tags Peer
// @Summary Bulk delete selected peers.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to delete"
// @Success 204 "No content if deletion was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/bulk-delete [post]
func (e PeerEndpoint) handleBulkDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkPeerRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.PeerIdentifier(id)
}
err := e.peerService.BulkDelete(r.Context(), ids)
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkEnable returns a gorm Handler function.
//
// @ID peers_handleBulkEnable
// @Tags Peer
// @Summary Bulk enable selected peers.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to enable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/bulk-enable [post]
func (e PeerEndpoint) handleBulkEnable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkPeerRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.PeerIdentifier(id)
}
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
p.Disabled = nil
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkDisable returns a gorm Handler function.
//
// @ID peers_handleBulkDisable
// @Tags Peer
// @Summary Bulk disable selected peers.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to disable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/bulk-disable [post]
func (e PeerEndpoint) handleBulkDisable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkPeerRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.PeerIdentifier(id)
}
now := time.Now()
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
p.Disabled = &now
p.DisabledReason = domain.DisabledReasonAdmin
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"net/http" "net/http"
"time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -36,6 +37,10 @@ type UserService interface {
GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error)
// GetUserInterfaces returns all interfaces for the given user. // GetUserInterfaces returns all interfaces for the given user.
GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error)
// BulkDelete deletes multiple users.
BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error
// BulkUpdate modifies multiple users.
BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error
} }
type UserEndpoint struct { type UserEndpoint struct {
@@ -77,7 +82,13 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password",
e.handleChangePasswordPost())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-delete", e.handleBulkDelete())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-enable", e.handleBulkEnable())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-disable", e.handleBulkDisable())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-lock", e.handleBulkLock())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-unlock", e.handleBulkUnlock())
} }
// handleAllGet returns a gorm Handler function. // handleAllGet returns a gorm Handler function.
@@ -459,3 +470,190 @@ func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }
} }
// handleBulkDelete returns a gorm Handler function.
//
// @ID users_handleBulkDelete
// @Tags Users
// @Summary Bulk delete selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to delete"
// @Success 204 "No content if deletion was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-delete [post]
func (e UserEndpoint) handleBulkDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
err := e.userService.BulkDelete(r.Context(), ids)
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkEnable returns a gorm Handler function.
//
// @ID users_handleBulkEnable
// @Tags Users
// @Summary Bulk enable selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to enable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-enable [post]
func (e UserEndpoint) handleBulkEnable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Disabled = nil
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkDisable returns a gorm Handler function.
//
// @ID users_handleBulkDisable
// @Tags Users
// @Summary Bulk disable selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to disable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-disable [post]
func (e UserEndpoint) handleBulkDisable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
now := time.Now()
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Disabled = &now
user.DisabledReason = domain.DisabledReasonAdmin
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkLock returns a gorm Handler function.
//
// @ID users_handleBulkLock
// @Tags Users
// @Summary Bulk lock selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to lock"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-lock [post]
func (e UserEndpoint) handleBulkLock() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
now := time.Now()
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Locked = &now
user.LockedReason = domain.LockedReasonAdmin
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkUnlock returns a gorm Handler function.
//
// @ID users_handleBulkUnlock
// @Tags Users
// @Summary Bulk unlock selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to unlock"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-unlock [post]
func (e UserEndpoint) handleBulkUnlock() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Locked = nil
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}

View File

@@ -0,0 +1,100 @@
package handlers
import (
"context"
"net/http"
"strings"
"sync"
"github.com/go-pkgz/routegroup"
"github.com/gorilla/websocket"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type WebsocketEventBus interface {
Subscribe(topic string, fn any) error
Unsubscribe(topic string, fn any) error
}
type WebsocketEndpoint struct {
authenticator Authenticator
bus WebsocketEventBus
upgrader websocket.Upgrader
}
func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus) *WebsocketEndpoint {
return &WebsocketEndpoint{
authenticator: auth,
bus: bus,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return strings.HasPrefix(origin, cfg.Web.ExternalUrl)
},
},
}
}
func (e WebsocketEndpoint) GetName() string {
return "WebsocketEndpoint"
}
func (e WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) {
g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket())
}
// wsMessage represents a message sent over websocket to the frontend
type wsMessage struct {
Type string `json:"type"` // either "peer_stats" or "interface_stats"
Data any `json:"data"` // domain.TrafficDelta
}
func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := e.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
writeMutex := sync.Mutex{}
writeJSON := func(msg wsMessage) error {
writeMutex.Lock()
defer writeMutex.Unlock()
return conn.WriteJSON(msg)
}
peerStatsHandler := func(status domain.TrafficDelta) {
_ = writeJSON(wsMessage{Type: "peer_stats", Data: status})
}
interfaceStatsHandler := func(status domain.TrafficDelta) {
_ = writeJSON(wsMessage{Type: "interface_stats", Data: status})
}
_ = e.bus.Subscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
defer e.bus.Unsubscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
_ = e.bus.Subscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
defer e.bus.Unsubscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
// Keep connection open until client disconnects or context is cancelled
go func() {
for {
if _, _, err := conn.ReadMessage(); err != nil {
cancel()
return
}
}
}()
<-ctx.Done()
}
}

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ type Settings struct {
MinPasswordLength int `json:"MinPasswordLength"` MinPasswordLength int `json:"MinPasswordLength"`
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"` AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
LoginFormVisible bool `json:"LoginFormVisible"` LoginFormVisible bool `json:"LoginFormVisible"`
CreateDefaultPeer bool `json:"CreateDefaultPeer"`
} }
type SettingsBackendNames struct { type SettingsBackendNames struct {

View File

@@ -0,0 +1,10 @@
package model
type BulkPeerRequest struct {
Identifiers []string `json:"Identifiers" binding:"required"`
Reason string `json:"Reason"`
}
type BulkUserRequest struct {
Identifiers []string `json:"Identifiers" binding:"required"`
}

View File

@@ -9,15 +9,16 @@ import (
) )
type Interface struct { type Interface struct {
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0 Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any' Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ... Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down) Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
CreateDefaultPeer bool `json:"CreateDefaultPeer"` // if true, default peers will be created for this interface
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820 ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
Addresses []string `json:"Addresses"` // the interface ip addresses Addresses []string `json:"Addresses"` // the interface ip addresses
@@ -65,6 +66,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
Disabled: src.IsDisabled(), Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason, DisabledReason: src.DisabledReason,
SaveConfig: src.SaveConfig, SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer,
ListenPort: src.ListenPort, ListenPort: src.ListenPort,
Addresses: domain.CidrsToStringSlice(src.Addresses), Addresses: domain.CidrsToStringSlice(src.Addresses),
Dns: internal.SliceString(src.DnsStr), Dns: internal.SliceString(src.DnsStr),
@@ -151,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
PreDown: src.PreDown, PreDown: src.PreDown,
PostDown: src.PostDown, PostDown: src.PostDown,
SaveConfig: src.SaveConfig, SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer,
DisplayName: src.DisplayName, DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode), Type: domain.InterfaceType(src.Mode),
Backend: domain.InterfaceBackend(src.Backend), Backend: domain.InterfaceBackend(src.Backend),

View File

@@ -3,15 +3,15 @@ package model
import ( import (
"time" "time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
type User struct { type User struct {
Identifier string `json:"Identifier"` Identifier string `json:"Identifier"`
Email string `json:"Email"` Email string `json:"Email"`
Source string `json:"Source"` AuthSources []string `json:"AuthSources"`
ProviderName string `json:"ProviderName"` IsAdmin bool `json:"IsAdmin"`
IsAdmin bool `json:"IsAdmin"`
Firstname string `json:"Firstname"` Firstname string `json:"Firstname"`
Lastname string `json:"Lastname"` Lastname string `json:"Lastname"`
@@ -29,6 +29,8 @@ type User struct {
ApiTokenCreated *time.Time `json:"ApiTokenCreated,omitempty"` ApiTokenCreated *time.Time `json:"ApiTokenCreated,omitempty"`
ApiEnabled bool `json:"ApiEnabled"` ApiEnabled bool `json:"ApiEnabled"`
PersistLocalChanges bool `json:"PersistLocalChanges"`
// Calculated // Calculated
PeerCount int `json:"PeerCount"` PeerCount int `json:"PeerCount"`
@@ -36,24 +38,26 @@ type User struct {
func NewUser(src *domain.User, exposeCreds bool) *User { func NewUser(src *domain.User, exposeCreds bool) *User {
u := &User{ u := &User{
Identifier: string(src.Identifier), Identifier: string(src.Identifier),
Email: src.Email, Email: src.Email,
Source: string(src.Source), AuthSources: internal.Map(src.Authentications, func(authentication domain.UserAuthentication) string {
ProviderName: src.ProviderName, return string(authentication.Source)
IsAdmin: src.IsAdmin, }),
Firstname: src.Firstname, IsAdmin: src.IsAdmin,
Lastname: src.Lastname, Firstname: src.Firstname,
Phone: src.Phone, Lastname: src.Lastname,
Department: src.Department, Phone: src.Phone,
Notes: src.Notes, Department: src.Department,
Password: "", // never fill password Notes: src.Notes,
Disabled: src.IsDisabled(), Password: "", // never fill password
DisabledReason: src.DisabledReason, Disabled: src.IsDisabled(),
Locked: src.IsLocked(), DisabledReason: src.DisabledReason,
LockedReason: src.LockedReason, Locked: src.IsLocked(),
ApiToken: "", // by default, do not expose API token LockedReason: src.LockedReason,
ApiTokenCreated: src.ApiTokenCreated, ApiToken: "", // by default, do not expose API token
ApiEnabled: src.IsApiEnabled(), ApiTokenCreated: src.ApiTokenCreated,
ApiEnabled: src.IsApiEnabled(),
PersistLocalChanges: src.PersistLocalChanges,
PeerCount: src.LinkedPeerCount, PeerCount: src.LinkedPeerCount,
} }
@@ -77,22 +81,21 @@ func NewUsers(src []domain.User) []User {
func NewDomainUser(src *User) *domain.User { func NewDomainUser(src *User) *domain.User {
now := time.Now() now := time.Now()
res := &domain.User{ res := &domain.User{
Identifier: domain.UserIdentifier(src.Identifier), Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email, Email: src.Email,
Source: domain.UserSource(src.Source), IsAdmin: src.IsAdmin,
ProviderName: src.ProviderName, Firstname: src.Firstname,
IsAdmin: src.IsAdmin, Lastname: src.Lastname,
Firstname: src.Firstname, Phone: src.Phone,
Lastname: src.Lastname, Department: src.Department,
Phone: src.Phone, Notes: src.Notes,
Department: src.Department, Password: domain.PrivateString(src.Password),
Notes: src.Notes, Disabled: nil, // set below
Password: domain.PrivateString(src.Password), DisabledReason: src.DisabledReason,
Disabled: nil, // set below Locked: nil, // set below
DisabledReason: src.DisabledReason, LockedReason: src.LockedReason,
Locked: nil, // set below LinkedPeerCount: src.PeerCount,
LockedReason: src.LockedReason, PersistLocalChanges: src.PersistLocalChanges,
LinkedPeerCount: src.PeerCount,
} }
if src.Disabled { if src.Disabled {

View File

@@ -3,6 +3,7 @@ package models
import ( import (
"time" "time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@@ -13,9 +14,7 @@ type User struct {
// The email address of the user. This field is optional. // The email address of the user. This field is optional.
Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"` Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"`
// The source of the user. This field is optional. // The source of the user. This field is optional.
Source string `json:"Source" binding:"oneof=db ldap oauth" example:"db"` AuthSources []string `json:"AuthSources" readonly:"true" binding:"oneof=db ldap oauth" example:"db"`
// The name of the authentication provider. This field is read-only.
ProviderName string `json:"ProviderName,omitempty" readonly:"true" example:""`
// If this field is set, the user is an admin. // If this field is set, the user is an admin.
IsAdmin bool `json:"IsAdmin" example:"false"` IsAdmin bool `json:"IsAdmin" example:"false"`
@@ -52,10 +51,11 @@ type User struct {
func NewUser(src *domain.User, exposeCredentials bool) *User { func NewUser(src *domain.User, exposeCredentials bool) *User {
u := &User{ u := &User{
Identifier: string(src.Identifier), Identifier: string(src.Identifier),
Email: src.Email, Email: src.Email,
Source: string(src.Source), AuthSources: internal.Map(src.Authentications, func(authentication domain.UserAuthentication) string {
ProviderName: src.ProviderName, return string(authentication.Source)
}),
IsAdmin: src.IsAdmin, IsAdmin: src.IsAdmin,
Firstname: src.Firstname, Firstname: src.Firstname,
Lastname: src.Lastname, Lastname: src.Lastname,
@@ -93,8 +93,6 @@ func NewDomainUser(src *User) *domain.User {
res := &domain.User{ res := &domain.User{
Identifier: domain.UserIdentifier(src.Identifier), Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email, Email: src.Email,
Source: domain.UserSource(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin, IsAdmin: src.IsAdmin,
Firstname: src.Firstname, Firstname: src.Firstname,
Lastname: src.Lastname, Lastname: src.Lastname,

View File

@@ -129,8 +129,6 @@ func (a *App) createDefaultUser(ctx context.Context) error {
}, },
Identifier: adminUserId, Identifier: adminUserId,
Email: "admin@wgportal.local", Email: "admin@wgportal.local",
Source: domain.UserSourceDatabase,
ProviderName: "",
IsAdmin: true, IsAdmin: true,
Firstname: "WireGuard Portal", Firstname: "WireGuard Portal",
Lastname: "Admin", Lastname: "Admin",

View File

@@ -29,8 +29,8 @@ type UserManager interface {
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error) GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
// RegisterUser creates a new user in the database. // RegisterUser creates a new user in the database.
RegisterUser(ctx context.Context, user *domain.User) error RegisterUser(ctx context.Context, user *domain.User) error
// UpdateUser updates an existing user in the database. // UpdateUserInternal updates an existing user in the database.
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error)
} }
type EventBus interface { type EventBus interface {
@@ -99,7 +99,7 @@ type Authenticator struct {
} }
// NewAuthenticator creates a new Authenticator instance. // NewAuthenticator creates a new Authenticator instance.
func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserManager) ( func NewAuthenticator(cfg *config.Auth, extUrl, basePath string, bus EventBus, users UserManager) (
*Authenticator, *Authenticator,
error, error,
) { ) {
@@ -107,7 +107,7 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
cfg: cfg, cfg: cfg,
bus: bus, bus: bus,
users: users, users: users,
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl), callbackUrlPrefix: fmt.Sprintf("%s%s/api/v0", extUrl, basePath),
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)), oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)), ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
} }
@@ -232,7 +232,7 @@ func (a *Authenticator) setupExternalAuthProviders(
} }
for i := range ldap { // LDAP for i := range ldap { // LDAP
providerCfg := &ldap[i] providerCfg := &ldap[i]
providerId := strings.ToLower(providerCfg.URL) providerId := strings.ToLower(providerCfg.ProviderName)
if _, exists := a.ldapAuthenticators[providerId]; exists { if _, exists := a.ldapAuthenticators[providerId]; exists {
// this is an unrecoverable error, we cannot register the same provider twice // this is an unrecoverable error, we cannot register the same provider twice
@@ -354,21 +354,45 @@ func (a *Authenticator) passwordAuthentication(
var ldapProvider AuthenticatorLdap var ldapProvider AuthenticatorLdap
var userInDatabase = false var userInDatabase = false
var userSource domain.UserSource
existingUser, err := a.users.GetUser(ctx, identifier) existingUser, err := a.users.GetUser(ctx, identifier)
if err == nil { if err == nil {
userInDatabase = true userInDatabase = true
userSource = existingUser.Source
} }
if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) { if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) {
return nil, errors.New("user is locked") return nil, errors.New("user is locked")
} }
if !userInDatabase || userSource == domain.UserSourceLdap { authOK := false
// search user in ldap if registration is enabled if userInDatabase {
// User is already in db, search for authentication sources which support password authentication and
// validate the password.
for _, authentication := range existingUser.Authentications {
if authentication.Source == domain.UserSourceDatabase {
err := existingUser.CheckPassword(password)
if err == nil {
authOK = true
break
}
}
if authentication.Source == domain.UserSourceLdap {
ldapProvider, ok := a.ldapAuthenticators[strings.ToLower(authentication.ProviderName)]
if !ok {
continue // ldap provider not found, skip further checks
}
err := ldapProvider.PlaintextAuthentication(identifier, password)
if err == nil {
authOK = true
break
}
}
}
} else {
// User is not yet in the db, check ldap providers which have registration enabled.
// If the user is found, check the password - on success, sync it to the db.
for _, ldapAuth := range a.ldapAuthenticators { for _, ldapAuth := range a.ldapAuthenticators {
if !userInDatabase && !ldapAuth.RegistrationEnabled() { if !ldapAuth.RegistrationEnabled() {
continue continue // ldap provider does not support registration, skip further checks
} }
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier) rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
@@ -379,55 +403,39 @@ func (a *Authenticator) passwordAuthentication(
} }
continue // user not found / other ldap error continue // user not found / other ldap error
} }
// user found, check if the password is correct
err = ldapAuth.PlaintextAuthentication(identifier, password)
if err != nil {
continue // password is incorrect, skip further checks
}
// create a new user in the db
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo) ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
if err != nil { if err != nil {
slog.Error("failed to parse ldap user info", slog.Error("failed to parse ldap user info",
"source", ldapAuth.GetName(), "identifier", identifier, "error", err) "source", ldapAuth.GetName(), "identifier", identifier, "error", err)
continue continue
} }
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(), true)
if err != nil {
return nil, fmt.Errorf("unable to process user information: %w", err)
}
// ldap user found existingUser = user
userSource = domain.UserSourceLdap slog.Debug("created new LDAP user in db",
ldapProvider = ldapAuth "identifier", user.Identifier, "provider", ldapProvider.GetName())
authOK = true
break break
} }
} }
if userSource == "" { if !authOK {
slog.Warn("no user source found for user", return nil, errors.New("failed to authenticate user")
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
return nil, errors.New("user not found")
} }
if userSource == domain.UserSourceLdap && ldapProvider == nil { return existingUser, nil
slog.Warn("no ldap provider found for user",
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
return nil, errors.New("ldap provider not found")
}
switch userSource {
case domain.UserSourceDatabase:
err = existingUser.CheckPassword(password)
case domain.UserSourceLdap:
err = ldapProvider.PlaintextAuthentication(identifier, password)
default:
err = errors.New("no authentication backend available")
}
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
if !userInDatabase {
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(),
ldapProvider.RegistrationEnabled())
if err != nil {
return nil, fmt.Errorf("unable to process user information: %w", err)
}
return user, nil
} else {
return existingUser, nil
}
} }
// endregion password authentication // endregion password authentication
@@ -590,17 +598,34 @@ func (a *Authenticator) registerNewUser(
source domain.UserSource, source domain.UserSource,
provider string, provider string,
) (*domain.User, error) { ) (*domain.User, error) {
ctxUserInfo := domain.GetUserInfo(ctx)
now := time.Now()
// convert user info to domain.User // convert user info to domain.User
user := &domain.User{ user := &domain.User{
Identifier: userInfo.Identifier, Identifier: userInfo.Identifier,
Email: userInfo.Email, Email: userInfo.Email,
Source: source, IsAdmin: false,
ProviderName: provider, Firstname: userInfo.Firstname,
IsAdmin: userInfo.IsAdmin, Lastname: userInfo.Lastname,
Firstname: userInfo.Firstname, Phone: userInfo.Phone,
Lastname: userInfo.Lastname, Department: userInfo.Department,
Phone: userInfo.Phone, Authentications: []domain.UserAuthentication{
Department: userInfo.Department, {
BaseModel: domain.BaseModel{
CreatedBy: ctxUserInfo.UserId(),
UpdatedBy: ctxUserInfo.UserId(),
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: userInfo.Identifier,
Source: source,
ProviderName: provider,
},
},
}
if userInfo.AdminInfoAvailable && userInfo.IsAdmin {
user.IsAdmin = true
} }
err := a.users.RegisterUser(ctx, user) err := a.users.RegisterUser(ctx, user)
@@ -610,6 +635,7 @@ func (a *Authenticator) registerNewUser(
slog.Debug("registered user from external authentication provider", slog.Debug("registered user from external authentication provider",
"user", user.Identifier, "user", user.Identifier,
"adminInfoAvailable", userInfo.AdminInfoAvailable,
"isAdmin", user.IsAdmin, "isAdmin", user.IsAdmin,
"provider", source) "provider", source)
@@ -643,6 +669,39 @@ func (a *Authenticator) updateExternalUser(
return nil // user is locked or disabled, do not update return nil // user is locked or disabled, do not update
} }
// Update authentication sources
foundAuthSource := false
for _, auth := range existingUser.Authentications {
if auth.Source == source && auth.ProviderName == provider {
foundAuthSource = true
break
}
}
if !foundAuthSource {
ctxUserInfo := domain.GetUserInfo(ctx)
now := time.Now()
existingUser.Authentications = append(existingUser.Authentications, domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: ctxUserInfo.UserId(),
UpdatedBy: ctxUserInfo.UserId(),
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: existingUser.Identifier,
Source: source,
ProviderName: provider,
})
}
if existingUser.PersistLocalChanges {
if !foundAuthSource {
// Even if local changes are persisted, we need to save the new authentication source
_, err := a.users.UpdateUserInternal(ctx, existingUser)
return err
}
return nil
}
isChanged := false isChanged := false
if existingUser.Email != userInfo.Email { if existingUser.Email != userInfo.Email {
existingUser.Email = userInfo.Email existingUser.Email = userInfo.Email
@@ -664,33 +723,24 @@ func (a *Authenticator) updateExternalUser(
existingUser.Department = userInfo.Department existingUser.Department = userInfo.Department
isChanged = true isChanged = true
} }
if existingUser.IsAdmin != userInfo.IsAdmin { if userInfo.AdminInfoAvailable && existingUser.IsAdmin != userInfo.IsAdmin {
existingUser.IsAdmin = userInfo.IsAdmin existingUser.IsAdmin = userInfo.IsAdmin
isChanged = true isChanged = true
} }
if existingUser.Source != source {
existingUser.Source = source
isChanged = true
}
if existingUser.ProviderName != provider {
existingUser.ProviderName = provider
isChanged = true
}
if !isChanged { if isChanged || !foundAuthSource {
return nil // nothing to update _, err := a.users.UpdateUserInternal(ctx, existingUser)
} if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
_, err := a.users.UpdateUser(ctx, existingUser) slog.Debug("updated user with data from external authentication provider",
if err != nil { "user", existingUser.Identifier,
return fmt.Errorf("failed to update user: %w", err) "adminInfoAvailable", userInfo.AdminInfoAvailable,
"isAdmin", existingUser.IsAdmin,
"provider", source)
} }
slog.Debug("updated user with data from external authentication provider",
"user", existingUser.Identifier,
"isAdmin", existingUser.IsAdmin,
"provider", source)
return nil return nil
} }

View File

@@ -127,18 +127,26 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
// ParseUserInfo parses the user information from the LDAP server into a domain.AuthenticatorUserInfo struct. // ParseUserInfo parses the user information from the LDAP server into a domain.AuthenticatorUserInfo struct.
func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) { func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
isAdmin, err := internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN) isAdmin := false
if err != nil { adminInfoAvailable := false
return nil, fmt.Errorf("failed to check admin group: %w", err) if l.cfg.FieldMap.GroupMembership != "" {
adminInfoAvailable = true
var err error
isAdmin, err = internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
if err != nil {
return nil, fmt.Errorf("failed to check admin group: %w", err)
}
} }
userInfo := &domain.AuthenticatorUserInfo{ userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")), Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""), Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""), Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""), Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""), Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""), Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
IsAdmin: isAdmin, IsAdmin: isAdmin,
AdminInfoAvailable: adminInfoAvailable,
} }
return userInfo, nil return userInfo, nil

View File

@@ -15,9 +15,11 @@ func parseOauthUserInfo(
raw map[string]any, raw map[string]any,
) (*domain.AuthenticatorUserInfo, error) { ) (*domain.AuthenticatorUserInfo, error) {
var isAdmin bool var isAdmin bool
var adminInfoAvailable bool
// first try to match the is_admin field against the given regex // first try to match the is_admin field against the given regex
if mapping.IsAdmin != "" { if mapping.IsAdmin != "" {
adminInfoAvailable = true
re := adminMapping.GetAdminValueRegex() re := adminMapping.GetAdminValueRegex()
if re.MatchString(strings.TrimSpace(internal.MapDefaultString(raw, mapping.IsAdmin, ""))) { if re.MatchString(strings.TrimSpace(internal.MapDefaultString(raw, mapping.IsAdmin, ""))) {
isAdmin = true isAdmin = true
@@ -26,6 +28,7 @@ func parseOauthUserInfo(
// next try to parse the user's groups // next try to parse the user's groups
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" { if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil) userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
re := adminMapping.GetAdminGroupRegex() re := adminMapping.GetAdminGroupRegex()
for _, group := range userGroups { for _, group := range userGroups {
@@ -37,13 +40,14 @@ func parseOauthUserInfo(
} }
userInfo := &domain.AuthenticatorUserInfo{ userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")), Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, mapping.Email, ""), Email: internal.MapDefaultString(raw, mapping.Email, ""),
Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""), Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""),
Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""), Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""),
Phone: internal.MapDefaultString(raw, mapping.Phone, ""), Phone: internal.MapDefaultString(raw, mapping.Phone, ""),
Department: internal.MapDefaultString(raw, mapping.Department, ""), Department: internal.MapDefaultString(raw, mapping.Department, ""),
IsAdmin: isAdmin, IsAdmin: isAdmin,
AdminInfoAvailable: adminInfoAvailable,
} }
return userInfo, nil return userInfo, nil

View File

@@ -23,8 +23,8 @@ type WebAuthnUserManager interface {
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error) GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID. // GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
// UpdateUser updates an existing user in the database. // UpdateUserInternal updates an existing user in the database.
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error)
} }
type WebAuthnAuthenticator struct { type WebAuthnAuthenticator struct {
@@ -89,7 +89,7 @@ func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, u
if user.WebAuthnId == "" { if user.WebAuthnId == "" {
user.GenerateWebAuthnId() user.GenerateWebAuthnId()
user, err = a.users.UpdateUser(ctx, user) user, err = a.users.UpdateUserInternal(ctx, user)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err) return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
} }
@@ -150,7 +150,7 @@ func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
return nil, err return nil, err
} }
user, err = a.users.UpdateUser(ctx, user) user, err = a.users.UpdateUserInternal(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -181,7 +181,7 @@ func (a *WebAuthnAuthenticator) RemoveCredential(
} }
user.RemoveCredential(credentialIdBase64) user.RemoveCredential(credentialIdBase64)
user, err = a.users.UpdateUser(ctx, user) user, err = a.users.UpdateUserInternal(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -205,7 +205,7 @@ func (a *WebAuthnAuthenticator) UpdateCredential(
return nil, err return nil, err
} }
user, err = a.users.UpdateUser(ctx, user) user, err = a.users.UpdateUserInternal(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -26,6 +26,7 @@ const TopicUserEnabled = "user:enabled"
const TopicInterfaceCreated = "interface:created" const TopicInterfaceCreated = "interface:created"
const TopicInterfaceUpdated = "interface:updated" const TopicInterfaceUpdated = "interface:updated"
const TopicInterfaceDeleted = "interface:deleted" const TopicInterfaceDeleted = "interface:deleted"
const TopicInterfaceStatsUpdated = "interface:stats:updated"
// endregion interface-events // endregion interface-events
@@ -37,6 +38,7 @@ const TopicPeerUpdated = "peer:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated" const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated" const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicPeerStateChanged = "peer:state:changed" const TopicPeerStateChanged = "peer:state:changed"
const TopicPeerStatsUpdated = "peer:stats:updated"
// endregion peer-events // endregion peer-events

View File

@@ -72,7 +72,7 @@ func NewMailManager(
users UserDatabaseRepo, users UserDatabaseRepo,
wg WireguardDatabaseRepo, wg WireguardDatabaseRepo,
) (*Manager, error) { ) (*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 { if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err) return nil, fmt.Errorf("failed to initialize template handler: %w", err)
} }
@@ -190,19 +190,21 @@ func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, d
if err == nil { if err == nil {
slog.Debug("peer email: using user-identifier as email", slog.Debug("peer email: using user-identifier as email",
"peer", peer.Identifier, "email", peer.UserIdentifier) "peer", peer.Identifier, "email", peer.UserIdentifier)
return string(peer.UserIdentifier), domain.User{} return string(peer.UserIdentifier), domain.User{
} else { Email: string(peer.UserIdentifier),
slog.Debug("peer email: skipping peer email", }
"peer", peer.Identifier,
"reason", "peer has no user linked and user-identifier is not a valid email address")
return "", domain.User{}
} }
} else {
slog.Debug("peer email: skipping peer email", slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier, "peer", peer.Identifier,
"reason", "user has no user linked") "reason", "peer has no user linked and user-identifier is not a valid email address")
return "", domain.User{} return "", domain.User{}
} }
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no user linked")
return "", domain.User{}
} }
if user.Email == "" { if user.Email == "" {

View File

@@ -6,6 +6,10 @@ import (
"fmt" "fmt"
htmlTemplate "html/template" htmlTemplate "html/template"
"io" "io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"text/template" "text/template"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@@ -22,15 +26,50 @@ type TemplateHandler struct {
textTemplates *template.Template 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") htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
if err != nil { 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") txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
if err != nil { 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{ handler := &TemplateHandler{
@@ -43,24 +82,71 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
return handler, nil 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. // 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) { func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
var tplBuff bytes.Buffer var tplBuff bytes.Buffer
var htmlTplBuff bytes.Buffer var htmlTplBuff bytes.Buffer
err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{ err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{
"User": user, "User": user,
"Link": link, "Link": link,
"PortalUrl": c.portalUrl, "PortalUrl": c.portalUrl,
"PortalName": c.portalName,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err) return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
} }
err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{ err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{
"User": user, "User": user,
"Link": link, "Link": link,
"PortalUrl": c.portalUrl, "PortalUrl": c.portalUrl,
"PortalName": c.portalName,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err) return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)

View File

@@ -144,8 +144,6 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
}, },
Identifier: domain.UserIdentifier(oldUser.Email), Identifier: domain.UserIdentifier(oldUser.Email),
Email: oldUser.Email, Email: oldUser.Email,
Source: domain.UserSource(oldUser.Source),
ProviderName: "",
IsAdmin: oldUser.IsAdmin, IsAdmin: oldUser.IsAdmin,
Firstname: oldUser.Firstname, Firstname: oldUser.Firstname,
Lastname: oldUser.Lastname, Lastname: oldUser.Lastname,
@@ -159,11 +157,25 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
LockedReason: "", LockedReason: "",
LinkedPeerCount: 0, LinkedPeerCount: 0,
} }
if err := newDb.Create(&newUser).Error; err != nil { if err := newDb.Create(&newUser).Error; err != nil {
return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err) return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
} }
authentication := domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemV1Migrator,
UpdatedBy: domain.CtxSystemV1Migrator,
CreatedAt: oldUser.CreatedAt,
UpdatedAt: oldUser.UpdatedAt,
},
UserIdentifier: domain.UserIdentifier(oldUser.Email),
Source: domain.UserSource(oldUser.Source),
ProviderName: "", // unknown
}
if err := newDb.Create(&authentication).Error; err != nil {
return fmt.Errorf("failed to migrate user-authentication %s: %w", oldUser.Email, err)
}
slog.Debug("user migrated successfully", "identifier", newUser.Identifier) slog.Debug("user migrated successfully", "identifier", newUser.Identifier)
} }
@@ -346,8 +358,6 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
}, },
Identifier: domain.UserIdentifier(oldPeer.Email), Identifier: domain.UserIdentifier(oldPeer.Email),
Email: oldPeer.Email, Email: oldPeer.Email,
Source: domain.UserSourceDatabase,
ProviderName: "",
IsAdmin: false, IsAdmin: false,
Locked: &now, Locked: &now,
LockedReason: domain.DisabledReasonMigrationDummy, LockedReason: domain.DisabledReasonMigrationDummy,
@@ -358,6 +368,21 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err) return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err)
} }
authentication := domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemV1Migrator,
UpdatedBy: domain.CtxSystemV1Migrator,
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: domain.UserIdentifier(oldPeer.Email),
Source: domain.UserSourceDatabase,
ProviderName: "", // unknown
}
if err := newDb.Create(&authentication).Error; err != nil {
return fmt.Errorf("failed to migrate dummy user-authentication %s: %w", oldPeer.Email, err)
}
slog.Debug("dummy user migrated successfully", "identifier", user.Identifier) slog.Debug("dummy user migrated successfully", "identifier", user.Identifier)
} }
newPeer := domain.Peer{ newPeer := domain.Peer{

View File

@@ -2,6 +2,7 @@ package users
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -25,6 +26,8 @@ func convertRawLdapUser(
return nil, fmt.Errorf("failed to check admin group: %w", err) return nil, fmt.Errorf("failed to check admin group: %w", err)
} }
uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
return &domain.User{ return &domain.User{
BaseModel: domain.BaseModel{ BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemLdapSyncer, CreatedBy: domain.CtxSystemLdapSyncer,
@@ -32,18 +35,23 @@ func convertRawLdapUser(
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
}, },
Identifier: domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")), Identifier: uid,
Email: strings.ToLower(internal.MapDefaultString(rawUser, fields.Email, "")), Email: strings.ToLower(internal.MapDefaultString(rawUser, fields.Email, "")),
Source: domain.UserSourceLdap, IsAdmin: isAdmin,
ProviderName: providerName, Authentications: []domain.UserAuthentication{
IsAdmin: isAdmin, {
Firstname: internal.MapDefaultString(rawUser, fields.Firstname, ""), UserIdentifier: uid,
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""), Source: domain.UserSourceLdap,
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""), ProviderName: providerName,
Department: internal.MapDefaultString(rawUser, fields.Department, ""), },
Notes: "", },
Password: "", Firstname: internal.MapDefaultString(rawUser, fields.Firstname, ""),
Disabled: nil, Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
Department: internal.MapDefaultString(rawUser, fields.Department, ""),
Notes: "",
Password: "",
Disabled: nil,
}, nil }, nil
} }
@@ -72,7 +80,9 @@ func userChangedInLdap(dbUser, ldapUser *domain.User) bool {
return true return true
} }
if dbUser.ProviderName != ldapUser.ProviderName { if !slices.ContainsFunc(dbUser.Authentications, func(authentication domain.UserAuthentication) bool {
return authentication.Source == ldapUser.Authentications[0].Source
}) {
return true return true
} }

View File

@@ -0,0 +1,239 @@
package users
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
go func(cfg config.LdapProvider) {
syncInterval := cfg.SyncInterval
if syncInterval == 0 {
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
return
}
// perform initial sync
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
} else {
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
}
// start periodic sync
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(syncInterval):
// select blocks until one of the cases evaluate to true
}
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
}
}
}(ldapCfg)
}
}
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
dn, err := ldap.ParseDN(provider.AdminGroupDN)
if err != nil {
return fmt.Errorf("failed to parse admin group DN: %w", err)
}
provider.ParsedAdminGroupDN = dn
conn, err := internal.LdapConnect(provider)
if err != nil {
return fmt.Errorf("failed to setup LDAP connection: %w", err)
}
defer internal.LdapDisconnect(conn)
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
if err != nil {
return err
}
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
// Update existing LDAP users
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
if err != nil {
return err
}
// Disable missing LDAP users
if provider.DisableMissing {
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
if err != nil {
return err
}
}
return nil
}
func (m Manager) updateLdapUsers(
ctx context.Context,
provider *config.LdapProvider,
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
adminGroupDN *ldap.DN,
) error {
for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
}
if provider.SyncLogUserInfo {
slog.Debug("ldap user data",
"raw-user", rawUser, "user", user.Identifier,
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
}
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
}
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
if existingUser == nil {
// create new user
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
_, err := m.create(tctx, user)
if err != nil {
cancel()
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
}
} else {
// update existing user
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
user.Disabled = nil
user.DisabledReason = ""
} else {
user.Disabled = existingUser.Disabled
user.DisabledReason = existingUser.DisabledReason
}
if existingUser.PersistLocalChanges {
cancel()
continue // skip synchronization for this user
}
if userChangedInLdap(existingUser, user) {
syncedUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
cancel()
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
}
syncedUser.UpdatedAt = time.Now()
syncedUser.UpdatedBy = domain.CtxSystemLdapSyncer
syncedUser.MergeAuthSources(user.Authentications...)
syncedUser.Email = user.Email
syncedUser.Firstname = user.Firstname
syncedUser.Lastname = user.Lastname
syncedUser.Phone = user.Phone
syncedUser.Department = user.Department
syncedUser.IsAdmin = user.IsAdmin
syncedUser.Disabled = user.Disabled
syncedUser.DisabledReason = user.DisabledReason
_, err = m.update(tctx, existingUser, syncedUser, false)
if err != nil {
cancel()
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
}
}
}
cancel()
}
return nil
}
func (m Manager) disableMissingLdapUsers(
ctx context.Context,
providerName string,
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
) error {
allUsers, err := m.users.GetAllUsers(ctx)
if err != nil {
return err
}
for _, user := range allUsers {
userHasAuthSource := false
for _, auth := range user.Authentications {
if auth.Source == domain.UserSourceLdap && auth.ProviderName == providerName {
userHasAuthSource = true
break
}
}
if !userHasAuthSource {
continue // ignore non ldap users
}
if user.IsDisabled() {
continue // ignore deactivated
}
if user.PersistLocalChanges {
continue // skip sync for this user
}
existsInLDAP := false
for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
if user.Identifier == userId {
existsInLDAP = true
break
}
}
if existsInLDAP {
continue
}
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
now := time.Now()
user.Disabled = &now
user.DisabledReason = domain.DisabledReasonLdapMissing
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
u.Disabled = user.Disabled
u.DisabledReason = user.DisabledReason
return u, nil
})
if err != nil {
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
}
m.bus.Publish(app.TopicUserDisabled, user)
}
return nil
}

View File

@@ -4,15 +4,12 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"math" "math"
"sync" "sync"
"time" "time"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@@ -79,7 +76,7 @@ func (m Manager) RegisterUser(ctx context.Context, user *domain.User) error {
return err return err
} }
createdUser, err := m.CreateUser(ctx, user) createdUser, err := m.create(ctx, user)
if err != nil { if err != nil {
return err return err
} }
@@ -101,20 +98,11 @@ func (m Manager) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain
return nil, err return nil, err
} }
user, err := m.users.GetUser(ctx, id) return m.getUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to load user %s: %w", id, err)
}
peers, _ := m.peers.GetUserPeers(ctx, id) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user, nil
} }
// GetUserByEmail returns the user with the given email address. // GetUserByEmail returns the user with the given email address.
func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
user, err := m.users.GetUserByEmail(ctx, email) user, err := m.users.GetUserByEmail(ctx, email)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to load user for email %s: %w", email, err) return nil, fmt.Errorf("unable to load user for email %s: %w", email, err)
@@ -124,16 +112,11 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
return nil, err return nil, err
} }
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case return m.enrichUser(ctx, user), nil
user.LinkedPeerCount = len(peers)
return user, nil
} }
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential. // GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) { func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64) user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err) return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
@@ -143,11 +126,7 @@ func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBa
return nil, err return nil, err
} }
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case return m.enrichUser(ctx, user), nil
user.LinkedPeerCount = len(peers)
return user, nil
} }
// GetAllUsers returns all users. // GetAllUsers returns all users.
@@ -169,8 +148,7 @@ func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
go func() { go func() {
defer wg.Done() defer wg.Done()
for user := range ch { for user := range ch {
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case m.enrichUser(ctx, user)
user.LinkedPeerCount = len(peers)
} }
}() }()
} }
@@ -194,77 +172,29 @@ func (m Manager) UpdateUser(ctx context.Context, user *domain.User) (*domain.Use
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err) return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
} }
if err := m.validateModifications(ctx, existingUser, user); err != nil { user.CopyCalculatedAttributes(existingUser, true) // ensure that crucial attributes stay the same
return nil, fmt.Errorf("update not allowed: %w", err)
}
user.CopyCalculatedAttributes(existingUser) return m.update(ctx, existingUser, user, true)
err = user.HashPassword() }
// UpdateUserInternal updates the user with the given identifier. This function must never be called from external.
// This function allows to override authentications and webauthn credentials.
func (m Manager) UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error) {
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
}
if user.Password == "" { // keep old password
user.Password = existingUser.Password
} }
err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) { return m.update(ctx, existingUser, user, false)
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserUpdated, *user)
switch {
case !existingUser.IsDisabled() && user.IsDisabled():
m.bus.Publish(app.TopicUserDisabled, *user)
case existingUser.IsDisabled() && !user.IsDisabled():
m.bus.Publish(app.TopicUserEnabled, *user)
}
return user, nil
} }
// CreateUser creates a new user. // CreateUser creates a new user.
func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) { func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
if user.Identifier == "" {
return nil, errors.New("missing user identifier")
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil { if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err return nil, err
} }
existingUser, err := m.users.GetUser(ctx, user.Identifier) return m.create(ctx, user)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
}
if existingUser != nil {
return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
}
if err := m.validateCreation(ctx, user); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = user.HashPassword()
if err != nil {
return nil, err
}
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
m.bus.Publish(app.TopicUserCreated, *user)
return user, nil
} }
// DeleteUser deletes the user with the given identifier. // DeleteUser deletes the user with the given identifier.
@@ -307,15 +237,10 @@ func (m Manager) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*do
user.ApiToken = uuid.New().String() user.ApiToken = uuid.New().String()
user.ApiTokenCreated = &now user.ApiTokenCreated = &now
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) { user, err = m.update(ctx, user, user, true) // self-update
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil { if err != nil {
return nil, fmt.Errorf("update failure: %w", err) return nil, err
} }
m.bus.Publish(app.TopicUserUpdated, *user)
m.bus.Publish(app.TopicUserApiEnabled, *user) m.bus.Publish(app.TopicUserApiEnabled, *user)
return user, nil return user, nil
@@ -335,15 +260,10 @@ func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*
user.ApiToken = "" user.ApiToken = ""
user.ApiTokenCreated = nil user.ApiTokenCreated = nil
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) { user, err = m.update(ctx, user, user, true) // self-update
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil { if err != nil {
return nil, fmt.Errorf("update failure: %w", err) return nil, err
} }
m.bus.Publish(app.TopicUserUpdated, *user)
m.bus.Publish(app.TopicUserApiDisabled, *user) m.bus.Publish(app.TopicUserApiDisabled, *user)
return user, nil return user, nil
@@ -380,10 +300,6 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
return fmt.Errorf("cannot lock own user: %w", domain.ErrInvalidData) return fmt.Errorf("cannot lock own user: %w", domain.ErrInvalidData)
} }
if old.Source != new.Source {
return fmt.Errorf("cannot change user source: %w", domain.ErrInvalidData)
}
return nil return nil
} }
@@ -414,14 +330,19 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData) return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
} }
if len(new.Authentications) != 1 {
return fmt.Errorf("invalid number of authentications: %d, expected 1: %w",
len(new.Authentications), domain.ErrInvalidData)
}
// Admins are allowed to create users for arbitrary sources. // Admins are allowed to create users for arbitrary sources.
if new.Source != domain.UserSourceDatabase && !currentUser.IsAdmin { if new.Authentications[0].Source != domain.UserSourceDatabase && !currentUser.IsAdmin {
return fmt.Errorf("invalid user source: %s, only %s is allowed: %w", return fmt.Errorf("invalid user source: %s, only %s is allowed: %w",
new.Source, domain.UserSourceDatabase, domain.ErrInvalidData) new.Authentications[0].Source, domain.UserSourceDatabase, domain.ErrInvalidData)
} }
// database users must have a password // database users must have a password
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" { if new.Authentications[0].Source == domain.UserSourceDatabase && string(new.Password) == "" {
return fmt.Errorf("missing password: %w", domain.ErrInvalidData) return fmt.Errorf("missing password: %w", domain.ErrInvalidData)
} }
@@ -460,214 +381,112 @@ func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error
return nil return nil
} }
func (m Manager) runLdapSynchronizationService(ctx context.Context) { // region internal-modifiers
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers func (m Manager) enrichUser(ctx context.Context, user *domain.User) *domain.User {
go func(cfg config.LdapProvider) { if user == nil {
syncInterval := cfg.SyncInterval return nil
if syncInterval == 0 {
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
return
}
// perform initial sync
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
} else {
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
}
// start periodic sync
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(syncInterval):
// select blocks until one of the cases evaluate to true
}
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
}
}
}(ldapCfg)
} }
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user
} }
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error { func (m Manager) getUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
slog.Debug("starting to synchronize users", "provider", provider.ProviderName) user, err := m.users.GetUser(ctx, id)
dn, err := ldap.ParseDN(provider.AdminGroupDN)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse admin group DN: %w", err) return nil, fmt.Errorf("unable to load user %s: %w", id, err)
} }
provider.ParsedAdminGroupDN = dn return m.enrichUser(ctx, user), nil
conn, err := internal.LdapConnect(provider)
if err != nil {
return fmt.Errorf("failed to setup LDAP connection: %w", err)
}
defer internal.LdapDisconnect(conn)
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
if err != nil {
return err
}
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
// Update existing LDAP users
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
if err != nil {
return err
}
// Disable missing LDAP users
if provider.DisableMissing {
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
if err != nil {
return err
}
}
return nil
} }
func (m Manager) updateLdapUsers( func (m Manager) update(ctx context.Context, existingUser, user *domain.User, keepAuthentications bool) (
ctx context.Context, *domain.User,
provider *config.LdapProvider, error,
rawUsers []internal.RawLdapUser, ) {
fields *config.LdapFields, if err := m.validateModifications(ctx, existingUser, user); err != nil {
adminGroupDN *ldap.DN, return nil, fmt.Errorf("update not allowed: %w", err)
) error {
for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
}
if provider.SyncLogUserInfo {
slog.Debug("ldap user data",
"raw-user", rawUser, "user", user.Identifier,
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
}
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
}
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
if existingUser == nil {
// create new user
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
_, err := m.CreateUser(tctx, user)
if err != nil {
cancel()
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
}
} else {
// update existing user
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
user.Disabled = nil
user.DisabledReason = ""
} else {
user.Disabled = existingUser.Disabled
user.DisabledReason = existingUser.DisabledReason
}
if existingUser.Source == domain.UserSourceLdap && userChangedInLdap(existingUser, user) {
err := m.users.SaveUser(tctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
u.UpdatedAt = time.Now()
u.UpdatedBy = domain.CtxSystemLdapSyncer
u.Source = user.Source
u.ProviderName = user.ProviderName
u.Email = user.Email
u.Firstname = user.Firstname
u.Lastname = user.Lastname
u.Phone = user.Phone
u.Department = user.Department
u.IsAdmin = user.IsAdmin
u.Disabled = nil
u.DisabledReason = ""
return u, nil
})
if err != nil {
cancel()
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
}
if existingUser.IsDisabled() && !user.IsDisabled() {
m.bus.Publish(app.TopicUserEnabled, *user)
}
}
}
cancel()
} }
return nil err := user.HashPassword()
if err != nil {
return nil, err
}
if user.Password == "" { // keep old password
user.Password = existingUser.Password
}
err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u, keepAuthentications)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserUpdated, *user)
switch {
case !existingUser.IsDisabled() && user.IsDisabled():
m.bus.Publish(app.TopicUserDisabled, *user)
case existingUser.IsDisabled() && !user.IsDisabled():
m.bus.Publish(app.TopicUserEnabled, *user)
}
return user, nil
} }
func (m Manager) disableMissingLdapUsers( func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, error) {
ctx context.Context, if user.Identifier == "" {
providerName string, return nil, errors.New("missing user identifier")
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
) error {
allUsers, err := m.users.GetAllUsers(ctx)
if err != nil {
return err
} }
for _, user := range allUsers {
if user.Source != domain.UserSourceLdap {
continue // ignore non ldap users
}
if user.ProviderName != providerName {
continue // user was synchronized through different provider
}
if user.IsDisabled() {
continue // ignore deactivated
}
existsInLDAP := false existingUser, err := m.users.GetUser(ctx, user.Identifier)
for _, rawUser := range rawUsers { if err != nil && !errors.Is(err, domain.ErrNotFound) {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")) return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
if user.Identifier == userId { }
existsInLDAP = true if existingUser != nil {
break return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
} }
}
if existsInLDAP {
continue
}
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
// Add default authentication if missing
if len(user.Authentications) == 0 {
ctxUserInfo := domain.GetUserInfo(ctx)
now := time.Now() now := time.Now()
user.Disabled = &now user.Authentications = []domain.UserAuthentication{
user.DisabledReason = domain.DisabledReasonLdapMissing {
BaseModel: domain.BaseModel{
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) { CreatedBy: ctxUserInfo.UserId(),
u.Disabled = user.Disabled UpdatedBy: ctxUserInfo.UserId(),
u.DisabledReason = user.DisabledReason CreatedAt: now,
return u, nil UpdatedAt: now,
}) },
if err != nil { UserIdentifier: user.Identifier,
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err) Source: domain.UserSourceDatabase,
ProviderName: "",
},
} }
m.bus.Publish(app.TopicUserDisabled, user)
} }
return nil if err := m.validateCreation(ctx, user); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = user.HashPassword()
if err != nil {
return nil, err
}
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
return user, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
m.bus.Publish(app.TopicUserCreated, *user)
return user, nil
} }
// endregion internal-modifiers

View File

@@ -3,6 +3,7 @@ package models
import ( import (
"time" "time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@@ -13,11 +14,10 @@ type User struct {
CreatedAt time.Time `json:"CreatedAt"` CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"` UpdatedAt time.Time `json:"UpdatedAt"`
Identifier string `json:"Identifier"` Identifier string `json:"Identifier"`
Email string `json:"Email"` Email string `json:"Email"`
Source string `json:"Source"` AuthSources []UserAuthSource `json:"AuthSources"`
ProviderName string `json:"ProviderName"` IsAdmin bool `json:"IsAdmin"`
IsAdmin bool `json:"IsAdmin"`
Firstname string `json:"Firstname,omitempty"` Firstname string `json:"Firstname,omitempty"`
Lastname string `json:"Lastname,omitempty"` Lastname string `json:"Lastname,omitempty"`
@@ -31,8 +31,22 @@ type User struct {
LockedReason string `json:"LockedReason,omitempty"` LockedReason string `json:"LockedReason,omitempty"`
} }
// UserAuthSource represents a single authentication source for a user.
// For details about the fields, see the domain.UserAuthentication struct.
type UserAuthSource struct {
Source string `json:"Source"`
ProviderName string `json:"ProviderName"`
}
// NewUser creates a new User model from a domain.User // NewUser creates a new User model from a domain.User
func NewUser(src domain.User) User { func NewUser(src domain.User) User {
authSources := internal.Map(src.Authentications, func(authentication domain.UserAuthentication) UserAuthSource {
return UserAuthSource{
Source: string(authentication.Source),
ProviderName: authentication.ProviderName,
}
})
return User{ return User{
CreatedBy: src.CreatedBy, CreatedBy: src.CreatedBy,
UpdatedBy: src.UpdatedBy, UpdatedBy: src.UpdatedBy,
@@ -40,8 +54,7 @@ func NewUser(src domain.User) User {
UpdatedAt: src.UpdatedAt, UpdatedAt: src.UpdatedAt,
Identifier: string(src.Identifier), Identifier: string(src.Identifier),
Email: src.Email, Email: src.Email,
Source: string(src.Source), AuthSources: authSources,
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin, IsAdmin: src.IsAdmin,
Firstname: src.Firstname, Firstname: src.Firstname,
Lastname: src.Lastname, Lastname: src.Lastname,

View File

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

View File

@@ -121,15 +121,25 @@ func (c *StatisticsCollector) collectInterfaceData(ctx context.Context) {
"error", err) "error", err)
continue continue
} }
now := time.Now()
err = c.db.UpdateInterfaceStatus(ctx, in.Identifier, err = c.db.UpdateInterfaceStatus(ctx, in.Identifier,
func(i *domain.InterfaceStatus) (*domain.InterfaceStatus, error) { func(i *domain.InterfaceStatus) (*domain.InterfaceStatus, error) {
i.UpdatedAt = time.Now() td := domain.CalculateTrafficDelta(
string(in.Identifier),
i.UpdatedAt, now,
i.BytesTransmitted, physicalInterface.BytesUpload,
i.BytesReceived, physicalInterface.BytesDownload,
)
i.UpdatedAt = now
i.BytesReceived = physicalInterface.BytesDownload i.BytesReceived = physicalInterface.BytesDownload
i.BytesTransmitted = physicalInterface.BytesUpload i.BytesTransmitted = physicalInterface.BytesUpload
// Update prometheus metrics // Update prometheus metrics
go c.updateInterfaceMetrics(*i) go c.updateInterfaceMetrics(*i)
// Publish stats update event
c.bus.Publish(app.TopicInterfaceStatsUpdated, td)
return i, nil return i, nil
}) })
if err != nil { if err != nil {
@@ -172,6 +182,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err) slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err)
continue continue
} }
now := time.Now()
for _, peer := range peers { for _, peer := range peers {
var connectionStateChanged bool var connectionStateChanged bool
var newPeerStatus domain.PeerStatus var newPeerStatus domain.PeerStatus
@@ -184,8 +195,15 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
lastHandshake = &peer.LastHandshake lastHandshake = &peer.LastHandshake
} }
td := domain.CalculateTrafficDelta(
string(peer.Identifier),
p.UpdatedAt, now,
p.BytesTransmitted, peer.BytesDownload,
p.BytesReceived, peer.BytesUpload,
)
// calculate if session was restarted // calculate if session was restarted
p.UpdatedAt = time.Now() p.UpdatedAt = now
p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload,
lastHandshake) lastHandshake)
p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server
@@ -195,7 +213,8 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
p.CalcConnected() p.CalcConnected()
if wasConnected != p.IsConnected { if wasConnected != p.IsConnected {
slog.Debug("peer connection state changed", "peer", peer.Identifier, "connected", p.IsConnected) slog.Debug("peer connection state changed",
"peer", peer.Identifier, "connected", p.IsConnected)
connectionStateChanged = true connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing newPeerStatus = *p // store new status for event publishing
} }
@@ -203,6 +222,9 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
// Update prometheus metrics // Update prometheus metrics
go c.updatePeerMetrics(ctx, *p) go c.updatePeerMetrics(ctx, *p)
// Publish stats update event
c.bus.Publish(app.TopicPeerStatsUpdated, td)
return p, nil return p, nil
}) })
if err != nil { if err != nil {

View File

@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"slices" "slices"
"strings"
"time" "time"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
@@ -373,6 +374,7 @@ func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "", SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id), DisplayName: string(id),
Type: domain.InterfaceTypeServer, Type: domain.InterfaceTypeServer,
CreateDefaultPeer: m.cfg.Core.CreateDefaultPeer,
DriverType: "", DriverType: "",
Disabled: nil, Disabled: nil,
DisabledReason: "", DisabledReason: "",
@@ -867,6 +869,17 @@ func (m Manager) importInterface(
iface.Backend = backend.GetId() iface.Backend = backend.GetId()
iface.PeerDefAllowedIPsStr = iface.AddressStr() 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 // try to predict the interface type based on the number of peers
switch len(peers) { switch len(peers) {
case 0: case 0:
@@ -904,6 +917,61 @@ func (m Manager) importInterface(
return nil 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 { func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
now := time.Now() now := time.Now()
peer := domain.ConvertPhysicalPeer(p) peer := domain.ConvertPhysicalPeer(p)
@@ -917,7 +985,26 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
peer.InterfaceIdentifier = in.Identifier peer.InterfaceIdentifier = in.Identifier
peer.EndpointPublicKey = domain.NewConfigOption(in.PublicKey, true) peer.EndpointPublicKey = domain.NewConfigOption(in.PublicKey, true)
peer.AllowedIPsStr = domain.NewConfigOption(in.PeerDefAllowedIPsStr, true) peer.AllowedIPsStr = domain.NewConfigOption(in.PeerDefAllowedIPsStr, true)
peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's TODO: Should this also match server interface address' prefix length?
// split allowed IP's into interface addresses and extra allowed IP's
var interfaceAddresses []domain.Cidr
var extraAllowedIPs []domain.Cidr
for _, allowedIP := range p.AllowedIPs {
isHost := (allowedIP.IsV4() && allowedIP.NetLength == 32) || (!allowedIP.IsV4() && allowedIP.NetLength == 128)
isNetworkAddr := allowedIP.Addr == allowedIP.NetworkAddr().Addr
// Network addresses (e.g. 10.0.0.0/24) will always be extra allowed IP's.
// For IP addresses, such as 10.0.0.1/24, it is challenging to tell whether it is an interface address or
// an extra allowed IP, therefore we treat such addresses as interface addresses.
if !isHost && isNetworkAddr {
extraAllowedIPs = append(extraAllowedIPs, allowedIP)
} else {
interfaceAddresses = append(interfaceAddresses, allowedIP)
}
}
peer.Interface.Addresses = interfaceAddresses
peer.ExtraAllowedIPsStr = domain.CidrsToString(extraAllowedIPs)
peer.Interface.DnsStr = domain.NewConfigOption(in.PeerDefDnsStr, true) peer.Interface.DnsStr = domain.NewConfigOption(in.PeerDefDnsStr, true)
peer.Interface.DnsSearchStr = domain.NewConfigOption(in.PeerDefDnsSearchStr, true) peer.Interface.DnsSearchStr = domain.NewConfigOption(in.PeerDefDnsSearchStr, true)
peer.Interface.Mtu = domain.NewConfigOption(in.PeerDefMtu, true) peer.Interface.Mtu = domain.NewConfigOption(in.PeerDefMtu, true)

View File

@@ -0,0 +1,94 @@
package wireguard
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/domain"
)
func TestImportPeer_AddressMapping(t *testing.T) {
tests := []struct {
name string
allowedIPs []string
expectedInterface []string
expectedExtraAllowed string
}{
{
name: "IPv4 host address",
allowedIPs: []string{"10.0.0.1/32"},
expectedInterface: []string{"10.0.0.1/32"},
expectedExtraAllowed: "",
},
{
name: "IPv6 host address",
allowedIPs: []string{"fd00::1/128"},
expectedInterface: []string{"fd00::1/128"},
expectedExtraAllowed: "",
},
{
name: "IPv4 network address",
allowedIPs: []string{"10.0.1.0/24"},
expectedInterface: []string{},
expectedExtraAllowed: "10.0.1.0/24",
},
{
name: "IPv4 normal address with mask",
allowedIPs: []string{"10.0.1.5/24"},
expectedInterface: []string{"10.0.1.5/24"},
expectedExtraAllowed: "",
},
{
name: "Mixed addresses",
allowedIPs: []string{
"10.0.0.1/32", "192.168.1.0/24", "172.16.0.5/24", "fd00::1/128", "fd00:1::/64",
},
expectedInterface: []string{"10.0.0.1/32", "172.16.0.5/24", "fd00::1/128"},
expectedExtraAllowed: "192.168.1.0/24,fd00:1::/64",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := &mockDB{}
m := Manager{
db: db,
}
iface := &domain.Interface{
Identifier: "wg0",
Type: domain.InterfaceTypeServer,
}
allowedIPs := make([]domain.Cidr, len(tt.allowedIPs))
for i, s := range tt.allowedIPs {
cidr, _ := domain.CidrFromString(s)
allowedIPs[i] = cidr
}
p := &domain.PhysicalPeer{
Identifier: "peer1",
KeyPair: domain.KeyPair{PublicKey: "peer1-public-key-is-long-enough"},
AllowedIPs: allowedIPs,
}
err := m.importPeer(context.Background(), iface, p)
assert.NoError(t, err)
savedPeer := db.savedPeers["peer1"]
assert.NotNil(t, savedPeer)
// Check interface addresses
actualInterface := make([]string, len(savedPeer.Interface.Addresses))
for i, addr := range savedPeer.Interface.Addresses {
actualInterface[i] = addr.String()
}
assert.ElementsMatch(t, tt.expectedInterface, actualInterface)
// Check extra allowed IPs
assert.Equal(t, tt.expectedExtraAllowed, savedPeer.ExtraAllowedIPsStr)
})
}
}

View File

@@ -35,6 +35,10 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
continue // only create default peers for server interfaces continue // only create default peers for server interfaces
} }
if !iface.CreateDefaultPeer {
continue // only create default peers if the interface flag is set
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool { peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
return peer.InterfaceIdentifier == iface.Identifier return peer.InterfaceIdentifier == iface.Identifier
}) })

View File

@@ -78,7 +78,12 @@ func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceId
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) { func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
return nil, nil return nil, nil
} }
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil } func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
if f.iface != nil {
return []domain.Interface{*f.iface}, nil
}
return nil, nil
}
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) { func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
return nil, nil return nil, nil
} }
@@ -192,3 +197,58 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId) t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
} }
} }
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
// Arrange
cfg := &config.Config{}
cfg.Core.CreateDefaultPeer = true
bus := &mockBus{}
ctrlMgr := &ControllerManager{
controllers: map[domain.InterfaceBackend]backendInstance{
config.LocalBackendName: {Implementation: &mockController{}},
},
}
db := &mockDB{
iface: &domain.Interface{
Identifier: "wg0",
Type: domain.InterfaceTypeServer,
CreateDefaultPeer: false, // Flag is disabled!
},
}
m := Manager{
cfg: cfg,
bus: bus,
db: db,
wg: ctrlMgr,
}
userId := domain.UserIdentifier("user@example.com")
ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: true})
// Act
err := m.CreateDefaultPeer(ctx, userId)
// Assert
if err != nil {
t.Fatalf("CreateDefaultPeer returned error: %v", err)
}
if len(db.savedPeers) != 0 {
t.Fatalf("expected no peers to be created because interface flag is false, but got %d", len(db.savedPeers))
}
// Now enable the flag and try again
db.iface.CreateDefaultPeer = true
err = m.CreateDefaultPeer(ctx, userId)
if err != nil {
t.Fatalf("CreateDefaultPeer returned error after enabling flag: %v", err)
}
if len(db.savedPeers) != 1 {
t.Fatalf("expected 1 peer to be created because interface flag is true, but got %d", len(db.savedPeers))
}
}

View File

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

View File

@@ -149,6 +149,7 @@ func defaultConfig() *Config {
RequestLogging: getEnvBool("WG_PORTAL_WEB_REQUEST_LOGGING", false), RequestLogging: getEnvBool("WG_PORTAL_WEB_REQUEST_LOGGING", false),
ExposeHostInfo: getEnvBool("WG_PORTAL_WEB_EXPOSE_HOST_INFO", false), ExposeHostInfo: getEnvBool("WG_PORTAL_WEB_EXPOSE_HOST_INFO", false),
ExternalUrl: getEnvStr("WG_PORTAL_WEB_EXTERNAL_URL", "http://localhost:8888"), ExternalUrl: getEnvStr("WG_PORTAL_WEB_EXTERNAL_URL", "http://localhost:8888"),
BasePath: getEnvStr("WG_PORTAL_WEB_BASE_PATH", ""),
ListeningAddress: getEnvStr("WG_PORTAL_WEB_LISTENING_ADDRESS", ":8888"), ListeningAddress: getEnvStr("WG_PORTAL_WEB_LISTENING_ADDRESS", ":8888"),
SessionIdentifier: getEnvStr("WG_PORTAL_WEB_SESSION_IDENTIFIER", "wgPortalSession"), SessionIdentifier: getEnvStr("WG_PORTAL_WEB_SESSION_IDENTIFIER", "wgPortalSession"),
SessionSecret: getEnvStr("WG_PORTAL_WEB_SESSION_SECRET", "very_secret"), SessionSecret: getEnvStr("WG_PORTAL_WEB_SESSION_SECRET", "very_secret"),
@@ -157,6 +158,7 @@ func defaultConfig() *Config {
SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"), SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"),
CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""), CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""),
KeyFile: getEnvStr("WG_PORTAL_WEB_KEY_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") cfg.Advanced.LogLevel = getEnvStr("WG_PORTAL_ADVANCED_LOG_LEVEL", "info")
@@ -195,6 +197,7 @@ func defaultConfig() *Config {
From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"), From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"),
LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false), LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false),
AllowPeerEmail: getEnvBool("WG_PORTAL_MAIL_ALLOW_PEER_EMAIL", 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 cfg.Webhook.Url = getEnvStr("WG_PORTAL_WEBHOOK_URL", "") // no webhook by default

View File

@@ -43,4 +43,8 @@ type MailConfig struct {
LinkOnly bool `yaml:"link_only"` 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 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"` AllowPeerEmail bool `yaml:"allow_peer_email"`
// TemplatesPath is an optional base path on the filesystem that contains email templates (.gotpl and .gohtml).
// If the directory exists but is empty, the embedded default templates will be written there on startup.
// If templates are present in the directory, they override the embedded defaults.
TemplatesPath string `yaml:"templates_path"`
} }

View File

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

Some files were not shown because too many files have changed in this diff Show More