Compare commits

..

78 Commits

Author SHA1 Message Date
Christoph Haas
9dc87dda4f Merge branch 'master' into stable 2026-06-05 20:59:17 +02:00
Christoph Haas
ea3742c193 feat: add short-lived cache for peer-ownership checks
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-06-05 20:57:43 +02:00
Christoph Haas
de2f7c6835 doc: add section that describes how to configure OAuth2 callback URL 2026-06-05 20:34:25 +02:00
dependabot[bot]
d8da5ff95a chore(deps): bump github.com/go-playground/validator/v10 (#708)
Bumps the patch group with 1 update: [github.com/go-playground/validator/v10](https://github.com/go-playground/validator).


Updates `github.com/go-playground/validator/v10` from 10.30.2 to 10.30.3
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.30.2...v10.30.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 20:19:24 +02:00
h44z
316f389f11 Merge commit from fork
* sec: do not expose traffic stats to all users, harden origin check in websocket endpoint

* add tests to validate new logic
2026-06-05 20:13:18 +02:00
dependabot[bot]
e3dc31a133 chore(deps): bump the patch group with 3 updates (#699)
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 patch group with 3 updates: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn), [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `github.com/go-webauthn/webauthn` from 0.17.3 to 0.17.4
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.17.3...v0.17.4)

Updates `golang.org/x/crypto` from 0.51.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

Updates `golang.org/x/sys` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/sys/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.17.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
- dependency-name: golang.org/x/sys
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 21:11:31 +02:00
Aram Akhavan
dea358c8cf fix: pfsense backend (#703)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* Return empty string instead of "<nil>" when a genericjsonobject key doesn't exist.

* Fix pfsense backend

* Fix API request parameter names and types
* Refactor interface and peer creation to send the necessary parameters
* Automatically call apply when interfaces or peers are changed

Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com>

---------

Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com>
2026-05-29 21:55:54 +02:00
h44z
72cfd1d8a9 feat: add support for PKCE (#686) (#702)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-05-28 20:49:13 +02:00
Aram Akhavan
1517041363 fix: fetch user info from OIDC userinfo endpoint (#698)
The OIDC client was only extracting claims from the ID token, but many
OIDC providers (like Authelia) don't include all user information in the
ID token. Fields like 'preferred_username' are typically only available
via the userinfo endpoint.

This fix fetches additional user information from the provider's userinfo
endpoint and merges it with the ID token claims, ensuring that all
required user fields are available for user registration and login.

Fixes #697

Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com>
2026-05-28 20:48:37 +02:00
dependabot[bot]
a5bb2042aa chore(deps): bump pgregory.net/rapid from 1.2.0 to 1.3.0 (#692)
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 [pgregory.net/rapid](https://github.com/flyingmutant/rapid) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/flyingmutant/rapid/releases)
- [Commits](https://github.com/flyingmutant/rapid/compare/v1.2.0...v1.3.0)

---
updated-dependencies:
- dependency-name: pgregory.net/rapid
  dependency-version: 1.3.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-05-23 20:33:46 +02:00
Mark Lawrence
8fd2721345 Document necessary systemd-networkd configuration (#694)
By default, the systemd-networkd.service(8) removes routing policy
created by other tools when it starts. This can cause wireguard tunnels
to stop working during a system upgrade or other administration
actions. Document the configuration necessary to prevent this occuring.

Signed-off-by: Mark Lawrence <mark@rekudos.net>
2026-05-23 20:33:14 +02:00
nbk1982
8fe50bf7dd Fix single-peer interface import mode (#695)
Co-authored-by: nbk1982 <16351736+nbk1982@users.noreply.github.com>
2026-05-23 20:31:37 +02:00
Dan Berg
0cf04d07e0 fix vue and oauth redirects under web base path (#683)
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-05-19 21:52:54 +02:00
Christoph Haas
c2b4a5d03c chore: update Go dependencies
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-05-18 22:33:42 +02:00
nesbyte
835f76bf58 feat(docs): how to troubleshoot admin_group_regex with oidc (#684)
Added instructions for identifying claims in OIDC user info payload for admin rights.
2026-05-18 22:31:46 +02:00
Mykhailo Roit
958dcb8fa9 feat: sanitize external identity provider user data (#681)
* feat: sanitize external user data

* remove config option to disable Sanitization: sanitize_external_user_data

* cleanup

---------

Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-05-18 22:28:27 +02:00
ratorin
ff935a404e feat(i18n): add Japanese (ja) translation (#682)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
- Add frontend/src/lang/translations/ja.json (full translation of all 422 entries from en.json)
- Register ja in frontend/src/lang/index.js so it appears in the language selector

Tested locally with v2.2.3 build. UI strings render correctly in Japanese.

Closes: language support request

Co-authored-by: Taro Kawakami <tarokun@tunagufactory.jp>
2026-05-03 21:23:34 +02:00
h44z
1c133b6f6e Improved default peer handling (#674)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* create default peers for newly created interfaces (#666)

* allow to manually create default peers for an interface (#666)
2026-04-16 21:55:41 +02:00
Christoph Haas
51e4c0ebf1 chore: update deps 2026-04-16 19:50:05 +02:00
dependabot[bot]
b44b79d42c chore(deps): bump the patch group with 2 updates (#664)
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 patch group with 2 updates: [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) and [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn).


Updates `github.com/go-playground/validator/v10` from 10.30.1 to 10.30.2
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.30.1...v10.30.2)

Updates `github.com/go-webauthn/webauthn` from 0.16.1 to 0.16.3
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.1...v0.16.3)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 13:25:44 +02:00
Michael Tupitsyn
71806455dd OIDC - support IdP logout (#670)
* OIDC - support IdP logout

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Add support of logout_idp_session parameter

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Fix merge conflict issue

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Restore original package-lock.json

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Cleanup

---------

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-04-12 13:18:04 +02:00
Michael Tupitsyn
9b437205b1 Add support for auth.oidc.allowed_user_groups (#667) (#668)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>
2026-04-11 18:24:18 +02:00
h44z
401642701a feat: improve pagination (#662) (#663)
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-04-07 22:17:53 +02:00
Mykhailo Roit
72f9123592 Add test-in-docker target to Makefile (#659)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* Add test-in-docker target to Makefile

Add a target to run tests in Docker for non-Linux environments.

* Add GOVERSION variable to Makefile

* fix: update test-in-docker command to use user permissions

* Fix docker command syntax in Makefile
2026-04-03 22:01:07 +02:00
Christoph Haas
1c3eacb08d Merge branch 'master' into stable 2026-04-01 20:12:38 +02:00
Mykhailo Roit
0e9e9d697f fix: "created_at" for users (#656)
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: created_at for users

* added tests for: created_at for users

* cleanup fixes

---------

Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-04-01 11:58:22 +02:00
Christoph
87bfd5b23a feat: allow encrypting user api token using gorm serializer 2026-04-01 11:42:07 +02:00
h44z
920806b231 chore: update frontend deps (#657)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-04-01 00:20:35 +02:00
Leandre Chamberland-Dozois
ec08e31eb7 feat(frontend): add confirmation dialog before deleting users, peers, and interfaces (#654)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
* feat(frontend): add confirmation dialog before deleting users, peers, and interfaces (#652)

Add a browser confirm() dialog to the delete functions in UserEditModal,
PeerEditModal, and InterfaceEditModal to prevent accidental deletions.
The bulk-delete actions in UserView already had this protection; this
change brings single-item deletion in line with that behavior.

Translation keys (confirm-delete) added for all 10 supported locales:
de, en, es, fr, ko, pt, ru, uk, vi, zh.

Signed-off-by: LeC-D <leo.openc@gmail.com>

* fix broken translation files

---------

Signed-off-by: LeC-D <leo.openc@gmail.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-03-31 19:49:53 +02:00
Jacopo Clark
c1a7edcc9a fix: prevent interface address clearing during startup (#651)
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: jc <37738506+theguy147@users.noreply.github.com>
Co-authored-by: jc <37738506+theguy147@users.noreply.github.com>
2026-03-25 22:08:06 +01:00
Christoph Haas
cc472216b4 Merge branch 'master' into stable 2026-03-22 22:34:48 +01:00
dependabot[bot]
31ad35809c chore(deps): bump the actions group across 1 directory with 8 updates (#639)
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 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [nolar/setup-k3d-k3s](https://github.com/nolar/setup-k3d-k3s) | `1.0.9` | `1.0.10` |
| [docker/login-action](https://github.com/docker/login-action) | `3.7.0` | `4.0.0` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3.7.0` | `4.0.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3.12.0` | `4.0.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5.10.0` | `6.0.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.19.2` | `7.0.0` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.0` | `8.0.1` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2.4.2` | `2.6.1` |



Updates `nolar/setup-k3d-k3s` from 1.0.9 to 1.0.10
- [Release notes](https://github.com/nolar/setup-k3d-k3s/releases)
- [Commits](293b8e5822...8bf8d22160)

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

Updates `docker/setup-qemu-action` from 3.7.0 to 4.0.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](c7c5346462...ce360397dd)

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

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

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

Updates `actions/download-artifact` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](70fc10c6e5...3e5f45b2cf)

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

---
updated-dependencies:
- dependency-name: nolar/setup-k3d-k3s
  dependency-version: 1.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 2.6.1
  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-03-22 22:27:23 +01:00
dependabot[bot]
3ae3da223d chore(deps): bump the patch group across 1 directory with 4 updates (#643)
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 the patch group with 2 updates in the / directory: [github.com/go-ldap/ldap/v3](https://github.com/go-ldap/ldap) and [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn).


Updates `github.com/go-ldap/ldap/v3` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/go-ldap/ldap/releases)
- [Commits](https://github.com/go-ldap/ldap/compare/v3.4.12...v3.4.13)

Updates `github.com/go-webauthn/webauthn` from 0.16.0 to 0.16.1
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.0...v0.16.1)

Updates `golang.org/x/crypto` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0)

Updates `golang.org/x/sys` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/sys/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: github.com/go-ldap/ldap/v3
  dependency-version: 3.4.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
- dependency-name: golang.org/x/sys
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-22 10:18:11 +01:00
Mykhailo Roit
243954a889 feat(metrics): add user label to peer metrics (#647)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-03-21 18:52:21 +01:00
Jacopo Clark
402cc1b5f3 feat: Implement LDAP interface-specific provisioning filters (#642)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* Implement LDAP filter-based access control for interface provisioning

* test: add unit tests for LDAP interface filtering logic

* smaller improvements / cleanup

---------

Co-authored-by: jc <37738506+theguy147@users.noreply.github.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-03-19 23:13:19 +01:00
h44z
f70f60a3f5 fix: configurable handshake validity interval and improved defaults (#645)
* fix: support configurable rekey timeout interval for peer connectivity tracking (#641)

* change default check-time to 180s
2026-03-19 23:11:40 +01:00
dependabot[bot]
2585be118f chore(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#635)
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 [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.36.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-03-18 22:50:34 +01:00
Tim
9c56e92443 Updated documentation (#640)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Include pass about systemd networkd managing foreign routes and deleting them on restart.

Signed-off-by: Tim <tim@tuimz.nl>
Co-authored-by: Tim Aerdts <tim@teaminova.nl>
2026-03-17 18:33:57 +01:00
Christoph
95394628d3 Merge branch 'master' into stable 2026-03-03 17:02:52 +01:00
Christoph
59d6263fef fix: pin softprops/action-gh-release to 2.4.2 (see https://github.com/softprops/action-gh-release/issues/709)
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-03-03 16:52:40 +01:00
dependabot[bot]
d4eb671a86 chore(deps): bump the actions group with 2 updates (#633)
Bumps the actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


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

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

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.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-03-03 16:48:40 +01:00
dependabot[bot]
c08035a0f5 chore(deps): bump github.com/go-webauthn/webauthn from 0.15.0 to 0.16.0 (#632)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.15.0...v0.16.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.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-03-03 16:47:19 +01:00
Gwilherm Folliot
9770471a62 fix: ldap user creation when login in (#631)
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: Gwilherm Folliot <fl0w@3l0w.fr>
2026-03-01 22:18:13 +01:00
Christoph Haas
b553375c43 Merge branch 'master' into stable 2026-02-28 22:32:43 +01:00
dependabot[bot]
5b2b5df2a4 chore(deps): bump golang from 1.25-alpine to 1.26-alpine (#628)
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 from 1.25-alpine to 1.26-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 23:08:17 +01:00
htiryaki-oe24
0a8ec71b3f Fixes minor chart issues #629 (#630)
(cherry picked from commit 3e0ffec07c)
2026-02-24 22:41:28 +01:00
h44z
fe4485037a Merge commit from fork 2026-02-24 22:40:13 +01:00
h44z
bbcda1a033 Merge commit from fork
* fix: improve user permission checks

* fix: improve user permission checks
2026-02-24 22:32:37 +01:00
htiryaki-oe24
3e0ffec07c Fixes minor chart issues #629 (#630)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Chart / lint-test (push) Has been cancelled
Chart / publish (push) Has been cancelled
2026-02-23 22:55:40 +01:00
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
Arnaud Rocher
6e47d8c3e9 fix: parity of Base64/URL encoding between frontend and backend (#611)
Signed-off-by: Arnaud Rocher <arnaud.roche3@gmail.com>
(cherry picked from commit 5d58df8a19)
2026-01-29 22:39:33 +01:00
h44z
eb28492539 Merge commit from fork
* fix: prevent open redirect in OAuth return URL validation

* reformat check

---------

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

* feature: updated handshake representations in templates

* feature: added handshake field to Swagger schema
2023-12-23 12:56:52 +01:00
Christoph Haas
2f79dd04c0 adopt github actions 2023-10-26 11:29:34 +02:00
Christoph Haas
e5ed9736b3 update docker build settings, move to new docker hub repository, use stable branch and major version tags 2023-10-26 11:22:58 +02:00
Christoph Haas
c8353b85ae Merge branch 'replace_ext_lib' into stable 2023-10-26 10:40:06 +02:00
Christoph Haas
6142031387 update gin 2023-10-26 10:39:01 +02:00
Christoph Haas
dd86d0ff49 replace inaccessible external lib 2023-10-26 10:31:29 +02:00
Christoph Haas
bdd426a679 populate peer device type (#170) 2023-10-26 10:20:08 +02:00
116 changed files with 7563 additions and 2622 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - 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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: '3.x' python-version: '3.x'
@@ -44,7 +44,7 @@ jobs:
- name: Run chart-testing (lint) - name: Run chart-testing (lint)
run: ct lint --config ct.yaml run: ct lint --config ct.yaml
- uses: nolar/setup-k3d-k3s@293b8e5822a20bc0d5bcdd4826f1a665e72aba96 # v1.0.9 - uses: nolar/setup-k3d-k3s@8bf8d22160e8b1d184dcb780e390d6952a7eec65 # v1.0.10
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -60,9 +60,9 @@ jobs:
permissions: permissions:
packages: write packages: write
steps: steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: binaries name: binaries
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: 3.x python-version: 3.x

View File

@@ -20,7 +20,7 @@ RUN npm run build
###### ######
# Build backend # Build backend
###### ######
FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS builder
# Set the working directory # Set the working directory
WORKDIR /build WORKDIR /build
# Download dependencies # Download dependencies

View File

@@ -1,7 +1,8 @@
# Go parameters # Go parameters
GOCMD=go GOCMD=go
GOVERSION=1.25
MODULENAME=github.com/h44z/wg-portal MODULENAME=github.com/h44z/wg-portal
GOFILES:=$(shell go list ./... | grep -v /vendor/) GOFILES=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*)) BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal IMAGE=h44z/wg-portal
@@ -51,6 +52,11 @@ format:
.PHONY: test .PHONY: test
test: test-vet test-race test: test-vet test-race
#> test-in-docker: Run tests in Docker (for non-Linux environments e.g. MacOS)
.PHONY: test-in-docker
test-in-docker:
docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -v $(PWD):/app -w /app golang:$(GOVERSION) make test
#< test-vet: Static code analysis #< test-vet: Static code analysis
.PHONY: test-vet .PHONY: test-vet
test-vet: build-dependencies test-vet: build-dependencies

View File

@@ -80,7 +80,7 @@ func main() {
internal.AssertNoError(err) internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx) auditRecorder.StartBackgroundJobs(ctx)
userManager, err := users.NewUserManager(cfg, eventBus, database, database) userManager, err := users.NewUserManager(cfg, eventBus, database, database, database)
internal.AssertNoError(err) internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx) userManager.StartBackgroundJobs(ctx)
@@ -135,6 +135,8 @@ 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, apiV0BackendPeers)
apiV0EndpointWebsocket.StartBackgroundJobs(ctx)
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,
@@ -144,6 +146,7 @@ func main() {
apiV0EndpointPeers, apiV0EndpointPeers,
apiV0EndpointConfig, apiV0EndpointConfig,
apiV0EndpointTest, apiV0EndpointTest,
apiV0EndpointWebsocket,
) )
// endregion API v0 (SPA frontend) // endregion API v0 (SPA frontend)

View File

@@ -6,8 +6,9 @@ advanced:
core: core:
admin_user: test@test.de admin_user: test@test.de
admin_password: secret admin_password: secret
create_default_peer: true create_default_peer_on_login: true
create_default_peer_on_creation: false create_default_peer_on_user_creation: false
create_default_peer_on_interface_creation: false
web: web:
external_url: http://localhost:8888 external_url: http://localhost:8888
@@ -46,7 +47,10 @@ auth:
extra_scopes: extra_scopes:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
use_pkce: true
pkce_method: S256
registration_enabled: true registration_enabled: true
logout_idp_session: true
- id: oidc2 - id: oidc2
provider_name: google2 provider_name: google2
display_name: Login with</br>Google2 display_name: Login with</br>Google2
@@ -57,6 +61,7 @@ auth:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true registration_enabled: true
logout_idp_session: true
oauth: oauth:
- id: google_plain_oauth - id: google_plain_oauth
provider_name: google3 provider_name: google3
@@ -76,6 +81,7 @@ auth:
user_identifier: sub user_identifier: sub
is_admin: this-attribute-must-be-true is_admin: this-attribute-must-be-true
registration_enabled: true registration_enabled: true
use_pkce: false
- id: google_plain_oauth_with_groups - id: google_plain_oauth_with_groups
provider_name: google4 provider_name: google4
display_name: Login with</br>Google4 display_name: Login with</br>Google4
@@ -110,4 +116,4 @@ backend:
api_verify_tls: true api_verify_tls: true
api_timeout: 30s api_timeout: 30s
concurrency: 5 concurrency: 5
debug: false debug: false

View File

@@ -16,7 +16,7 @@ annotations:
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.7.2 version: 0.7.3
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to

View File

@@ -1,6 +1,6 @@
# wg-portal # wg-portal
![Version: 0.7.2](https://img.shields.io/badge/Version-0.7.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2](https://img.shields.io/badge/AppVersion-v2-informational?style=flat-square) ![Version: 0.7.3](https://img.shields.io/badge/Version-0.7.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2](https://img.shields.io/badge/AppVersion-v2-informational?style=flat-square)
WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
@@ -41,6 +41,7 @@ The [Values](#values) section lists the parameters that can be configured during
| config.web | tpl/object | `{}` | [Web configuration](https://wgportal.org/latest/documentation/configuration/overview/#web) options.<br> `listening_address` will be set automatically from `service.web.port`. `external_url` is required to enable ingress and certificate resources. | | config.web | tpl/object | `{}` | [Web configuration](https://wgportal.org/latest/documentation/configuration/overview/#web) options.<br> `listening_address` will be set automatically from `service.web.port`. `external_url` is required to enable ingress and certificate resources. |
| revisionHistoryLimit | string | `10` | The number of old ReplicaSets to retain to allow rollback. | | revisionHistoryLimit | string | `10` | The number of old ReplicaSets to retain to allow rollback. |
| workloadType | string | `"Deployment"` | Workload type - `Deployment` or `StatefulSet` | | workloadType | string | `"Deployment"` | Workload type - `Deployment` or `StatefulSet` |
| replicas | int | `1` | The replicas for the workload. |
| strategy | object | `{"type":"RollingUpdate"}` | Update strategy for the workload Valid values are: `RollingUpdate` or `Recreate` for Deployment, `RollingUpdate` or `OnDelete` for StatefulSet | | strategy | object | `{"type":"RollingUpdate"}` | Update strategy for the workload Valid values are: `RollingUpdate` or `Recreate` for Deployment, `RollingUpdate` or `OnDelete` for StatefulSet |
| image.repository | string | `"ghcr.io/h44z/wg-portal"` | Image repository | | image.repository | string | `"ghcr.io/h44z/wg-portal"` | Image repository |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
@@ -74,12 +75,15 @@ The [Values](#values) section lists the parameters that can be configured during
| service.web.type | string | `"ClusterIP"` | Web service type | | service.web.type | string | `"ClusterIP"` | Web service type |
| service.web.port | int | `8888` | Web service port Used for the web interface listener | | service.web.port | int | `8888` | Web service port Used for the web interface listener |
| service.web.appProtocol | string | `"http"` | Web service appProtocol. Will be auto set to `https` if certificate is enabled. | | service.web.appProtocol | string | `"http"` | Web service appProtocol. Will be auto set to `https` if certificate is enabled. |
| service.web.extraSelectorLabels | object | `{}` | Extra labels to append to the selector labels. |
| service.wireguard.annotations | object | `{}` | Annotations for the WireGuard service | | service.wireguard.annotations | object | `{}` | Annotations for the WireGuard service |
| service.wireguard.type | string | `"LoadBalancer"` | Wireguard service type | | service.wireguard.type | string | `"LoadBalancer"` | Wireguard service type |
| service.wireguard.ports | list | `[51820]` | Wireguard service ports. Exposes the WireGuard ports for created interfaces. Lowerest port is selected as start port for the first interface. Increment next port by 1 for each additional interface. | | service.wireguard.ports | list | `[51820]` | Wireguard service ports. Exposes the WireGuard ports for created interfaces. Lowerest port is selected as start port for the first interface. Increment next port by 1 for each additional interface. |
| service.wireguard.extraSelectorLabels | object | `{}` | Extra labels to append to the selector labels. |
| service.metrics.port | int | `8787` | | | service.metrics.port | int | `8787` | |
| ingress.enabled | bool | `false` | Specifies whether an ingress resource should be created | | ingress.enabled | bool | `false` | Specifies whether an ingress resource should be created |
| ingress.className | string | `""` | Ingress class name | | ingress.className | string | `""` | Ingress class name |
| ingress.pathType | string | `"ImplementationSpecific"` | Ingress pathType value. Valid values are `ImplementationSpecific`, `Exact` or `Prefix`. |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.tls | bool | `false` | Ingress TLS configuration. Enable certificate resource or add ingress annotation to create required secret | | ingress.tls | bool | `false` | Ingress TLS configuration. Enable certificate resource or add ingress annotation to create required secret |
| certificate.enabled | bool | `false` | Specifies whether a certificate resource should be created. If enabled, certificate will be used for the web. | | certificate.enabled | bool | `false` | Specifies whether a certificate resource should be created. If enabled, certificate will be used for the web. |

View File

@@ -49,7 +49,7 @@ spec:
{{- with .scope.type }} {{- with .scope.type }}
type: {{ . }} type: {{ . }}
{{- end }} {{- end }}
selector: {{- include "wg-portal.selectorLabels" .context | nindent 4 }} selector: {{- include "wg-portal.util.merge" (list .context .scope.extraSelectorLabels "wg-portal.selectorLabels") | nindent 4 }}
{{- end -}} {{- end -}}
{{/* {{/*

View File

@@ -8,6 +8,9 @@ spec:
{{- with .Values.revisionHistoryLimit }} {{- with .Values.revisionHistoryLimit }}
revisionHistoryLimit: {{ . }} revisionHistoryLimit: {{ . }}
{{- end }} {{- end }}
{{- with .Values.replicas }}
replicas: {{ . }}
{{- end }}
{{- with .Values.strategy }} {{- with .Values.strategy }}
strategy: {{- toYaml . | nindent 4 }} strategy: {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}

View File

@@ -15,7 +15,7 @@ spec:
http: http:
paths: paths:
- path: {{ default "/" (urlParse (tpl .Values.config.web.external_url .)).path }} - path: {{ default "/" (urlParse (tpl .Values.config.web.external_url .)).path }}
pathType: {{ default "ImplementationSpecific" .pathType }} pathType: {{ default "ImplementationSpecific" .Values.ingress.pathType }}
backend: backend:
service: service:
name: {{ include "wg-portal.fullname" . }} name: {{ include "wg-portal.fullname" . }}

View File

@@ -8,6 +8,9 @@ spec:
{{- with .Values.revisionHistoryLimit }} {{- with .Values.revisionHistoryLimit }}
revisionHistoryLimit: {{ . }} revisionHistoryLimit: {{ . }}
{{- end }} {{- end }}
{{- with .Values.replicas }}
replicas: {{ . }}
{{- end }}
{{- with .Values.strategy }} {{- with .Values.strategy }}
updateStrategy: {{- toYaml . | nindent 4 }} updateStrategy: {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}

View File

@@ -35,6 +35,9 @@ config:
revisionHistoryLimit: "" revisionHistoryLimit: ""
# -- Workload type - `Deployment` or `StatefulSet` # -- Workload type - `Deployment` or `StatefulSet`
workloadType: Deployment workloadType: Deployment
# -- The replicas for the workload.
# @default -- `1`
replicas: 1
# -- Update strategy for the workload # -- Update strategy for the workload
# Valid values are: # Valid values are:
# `RollingUpdate` or `Recreate` for Deployment, # `RollingUpdate` or `Recreate` for Deployment,
@@ -124,6 +127,8 @@ service:
port: 8888 port: 8888
# -- Web service appProtocol. Will be auto set to `https` if certificate is enabled. # -- Web service appProtocol. Will be auto set to `https` if certificate is enabled.
appProtocol: http appProtocol: http
# -- Extra labels to append to the selector labels.
extraSelectorLabels: {}
wireguard: wireguard:
# -- Annotations for the WireGuard service # -- Annotations for the WireGuard service
annotations: {} annotations: {}
@@ -135,6 +140,8 @@ service:
# Increment next port by 1 for each additional interface. # Increment next port by 1 for each additional interface.
ports: ports:
- 51820 - 51820
# -- Extra labels to append to the selector labels.
extraSelectorLabels: {}
metrics: metrics:
port: 8787 port: 8787
@@ -143,6 +150,10 @@ ingress:
enabled: false enabled: false
# -- Ingress class name # -- Ingress class name
className: "" className: ""
# -- Ingress pathType value.
# Valid values are `ImplementationSpecific`, `Exact` or `Prefix`.
# @default -- `"ImplementationSpecific"`
pathType: "ImplementationSpecific"
# -- Ingress annotations # -- Ingress annotations
annotations: {} annotations: {}
# -- Ingress TLS configuration. # -- Ingress TLS configuration.

View File

@@ -8,7 +8,7 @@ core:
admin_password: password admin_password: password
admin_api_token: super-s3cr3t-api-token-or-a-UUID admin_api_token: super-s3cr3t-api-token-or-a-UUID
import_existing: false import_existing: false
create_default_peer: true create_default_peer_on_login: true
self_provisioning_allowed: true self_provisioning_allowed: true
backend: backend:
@@ -86,6 +86,9 @@ auth:
memberof: memberOf memberof: memberOf
admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL
registration_enabled: true registration_enabled: true
# Restrict interface access based on LDAP filters
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
log_user_info: true log_user_info: true
``` ```
@@ -141,6 +144,9 @@ auth:
extra_scopes: extra_scopes:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
allowed_user_groups:
- the-admin-group
- vpn-users
field_map: field_map:
user_identifier: sub user_identifier: sub
email: email email: email
@@ -198,6 +204,9 @@ auth:
- email - email
- profile - profile
- i-want-some-groups - i-want-some-groups
allowed_user_groups:
- admin-group-name
- vpn-users
field_map: field_map:
email: email email: email
firstname: name firstname: name

View File

@@ -28,6 +28,7 @@ core:
backend: backend:
default: local default: local
rekey_timeout_interval: 125s
local_resolvconf_prefix: tun. local_resolvconf_prefix: tun.
advanced: advanced:
@@ -154,17 +155,33 @@ More advanced options are found in the subsequent `Advanced` section.
- **Environment Variable:** `WG_PORTAL_CORE_EDITABLE_KEYS` - **Environment Variable:** `WG_PORTAL_CORE_EDITABLE_KEYS`
- **Description:** Allow editing of WireGuard key-pairs directly in the UI. - **Description:** Allow editing of WireGuard key-pairs directly in the UI.
### `create_default_peer` ### `create_default_peer` (deprecated)
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
- **Description:** **DEPRECATED** in favor of [create_default_peer_on_login](#create_default_peer_on_login). If set to `true`, this option is equivalent to enabling `create_default_peer_on_login`. It will be removed in a future release (2.4).
### `create_default_peer_on_creation` (deprecated)
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
- **Description:** **DEPRECATED** in favor of [create_default_peer_on_user_creation](#create_default_peer_on_user_creation) and [create_default_peer_on_interface_creation](#create_default_peer_on_interface_creation). If set to `true`, both of those options are enabled. It will be removed in a future release (2.4).
### `create_default_peer_on_login`
- **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 where the "Create default peer" flag is set. - **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). - **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_user_creation`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION` - **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_USER_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 where the "Create default peer" flag is set. - **Description:** If a new user is created (e.g., through LDAP sync or registration) 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. - **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_interface_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_INTERFACE_CREATION`
- **Description:** When a new server interface is created with the "Create default peer" flag set, automatically create a default WireGuard peer on that interface for every existing user who does not yet have a peer on it.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `re_enable_peer_after_user_enable` ### `re_enable_peer_after_user_enable`
- **Default:** `true` - **Default:** `true`
@@ -203,6 +220,13 @@ The current MikroTik backend is in **BETA** and may not support all features.
- **Description:** The default backend to use for managing WireGuard interfaces. - **Description:** The default backend to use for managing WireGuard interfaces.
Valid options are: `local`, or other backend id's configured in the `mikrotik` section. Valid options are: `local`, or other backend id's configured in the `mikrotik` section.
### `rekey_timeout_interval`
- **Default:** `180s`
- **Environment Variable:** `WG_PORTAL_BACKEND_REKEY_TIMEOUT_INTERVAL`
- **Description:** The interval after which a WireGuard peer is considered disconnected if no handshake updates are received.
This corresponds to the WireGuard rekey timeout setting of 120 seconds plus a 60-second buffer to account for latency or retry handling.
Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 180 seconds is used.
### `local_resolvconf_prefix` ### `local_resolvconf_prefix`
- **Default:** `tun.` - **Default:** `tun.`
- **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX` - **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX`
@@ -528,6 +552,7 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
#### `provider_name` #### `provider_name`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers. - **Description:** A **unique** name for this provider. Must not conflict with other providers.
This name is used to derive the callback URL for the OIDC provider: `<external_url>/api/v0/auth/login/<provider_name>/callback`.
#### `display_name` #### `display_name`
- **Default:** *(empty)* - **Default:** *(empty)*
@@ -553,6 +578,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map` #### `field_map`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** Maps OIDC claims to WireGuard Portal user fields. - **Description:** Maps OIDC claims to WireGuard Portal user fields.
@@ -574,6 +603,7 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`. - **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`.
- `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`). - `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`).
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
- To identify which claim to match against, set log_level: debug and reload the config. Log in with the intended admin account and inspect the logs for the OIDC user info payload. If the required claim is missing it must be added by the OIDC provider. If it is present, use its value as the pattern for admin_group_regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** `false` - **Default:** `false`
@@ -588,6 +618,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
#### `logout_idp_session`
- **Default:** `true`
- **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session.
--- ---
### OAuth ### OAuth
@@ -598,6 +640,7 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
#### `provider_name` #### `provider_name`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers. - **Description:** A **unique** name for this provider. Must not conflict with other providers.
This name is used to derive the callback URL for the OAuth provider: `<external_url>/api/v0/auth/login/<provider_name>/callback`.
#### `display_name` #### `display_name`
- **Default:** *(empty)* - **Default:** *(empty)*
@@ -631,6 +674,10 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map` #### `field_map`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** Maps OAuth attributes to WireGuard Portal fields. - **Description:** Maps OAuth attributes to WireGuard Portal fields.
@@ -666,6 +713,14 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
--- ---
### LDAP ### LDAP
@@ -734,6 +789,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user. - **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
If the filter returns multiple or no users, the login will fail. If the filter returns multiple or no users, the login will fail.
#### `interface_filter`
- **Default:** *(empty)*
- **Description:** A map of LDAP filters to restrict access to specific WireGuard interfaces. The map keys are the interface identifiers (e.g., `wg0`), and the values are LDAP filters. Only users matching the filter will be allowed to provision peers for the respective interface.
For example:
```yaml
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
wg1: "(description=special-access)"
```
#### `admin_group` #### `admin_group`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal. - **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal.

View File

@@ -51,13 +51,31 @@ sudo install wg-portal /opt/wg-portal/
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/). 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. Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
## Systemd Service Setup ## Systemd Integration
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand. > **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 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`. > The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
### Limit Systemd-Networkd Management Scope
If you are using `systemd-networkd` to manage the rest of your network
configuration, you will need to ensure it doesn't remove routing policy
created by `wg-portal` when it restarts:
```shell
sudo mkdir --parents /etc/systemd/networkd.conf.d/
sudo tee --append /etc/systemd/networkd.conf.d/foreign-routing.conf <<EOF
[Network]
ManageForeignRoutingPolicyRules=no
EOF
sudo systemctl restart systemd-networkd.service
sudo systemctl status systemd-networkd.service
```
### Wireguard Portal Service Setup
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`: 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 ```shell

View File

@@ -35,6 +35,14 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
> :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file. > :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file.
To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI. To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI.
> :warning: If the host is running **systemd-networkd**, routes managed by WireGuard Portal may be removed whenever systemd-networkd restarts, as it will clean up routes it considers "foreign". To prevent this, add the following to your host's network configuration (e.g. `/etc/systemd/networkd.conf` or a drop-in file):
> ```ini
> [Network]
> ManageForeignRoutingPolicyRules=no
> ManageForeignRoutes=no
> ```
> After editing, reload the configuration with `sudo systemctl restart systemd-networkd`. For more information refer to the [systemd-networkd documentation](https://www.freedesktop.org/software/systemd/man/latest/networkd.conf.html#ManageForeignRoutes=).
- **Within the WireGuard Portal Docker container**: - **Within the WireGuard Portal Docker container**:
WireGuard interfaces can be managed directly from within the WireGuard Portal container itself. WireGuard interfaces can be managed directly from within the WireGuard Portal container itself.
This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image. This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image.

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

@@ -51,6 +51,15 @@ To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. 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). Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
When registering the OAuth2 or OIDC application with your provider, configure the callback/redirect URL as follows:
```text
<external_url>/api/v0/auth/login/<provider_name>/callback
```
Replace `<external_url>` with the value configured in [`external_url`](../configuration/overview.md#external_url) and
`<provider_name>` with the exact `provider_name` from the matching OAuth2 or OIDC provider configuration.
#### Limiting Login to Specific Domains #### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers. You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers.
@@ -66,6 +75,40 @@ auth:
- "outlook.com" - "outlook.com"
``` ```
#### Limiting Login to Specific User Groups
You can limit the login to specific user groups by setting the `allowed_user_groups` property for OAuth2 or OIDC providers.
If this property is not empty, the user's `user_groups` claim must contain at least one matching group.
To use this feature, ensure your group claim is mapped via `field_map.user_groups`.
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_user_groups:
- "wg-users"
- "wg-admins"
field_map:
user_groups: "groups"
```
If `allowed_user_groups` is configured and the authenticated user has no matching group in `user_groups`, login is denied.
Minimal deny-by-group example:
```yaml
auth:
oauth:
- provider_name: "oauth1"
# ... other settings
allowed_user_groups:
- "vpn-users"
field_map:
user_groups: "groups"
```
#### Limit Login to Existing Users #### 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. You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers.
@@ -147,6 +190,26 @@ You can map users to admin roles based on their group membership in the LDAP ser
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin. 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. 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.
### Interface-specific Provisioning Filters
You can restrict which users are allowed to provision peers for specific WireGuard interfaces by setting the `interface_filter` property.
This property is a map where each key corresponds to a WireGuard interface identifier, and the value is an LDAP filter.
A user will only be able to see and provision peers for an interface if they match the specified LDAP filter for that interface.
Example:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
wg1: "(department=IT)"
```
This feature works by materializing the list of authorized users for each interface during the periodic LDAP synchronization.
Even if a user bypasses the UI, the backend will enforce these restrictions at the service layer.
## User Synchronization ## User Synchronization

View File

@@ -61,7 +61,7 @@ backend:
> :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. > :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. 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. wg-portal is developed for and tested against REST API v2.8.0.
### Prerequisites on pfSense: ### Prerequisites on pfSense:
- pfSense with the REST API package enabled (`System -> API`) and WireGuard configured. - pfSense with the REST API package enabled (`System -> API`) and WireGuard configured.

View File

@@ -8,6 +8,23 @@ To enable encryption, set the [`encryption_passphrase`](../configuration/overvie
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed! > :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. > Only new or updated records will be encrypted; existing data remains in plaintext until its next modified.
## External Identity Provider Data Sanitization
When users authenticate via LDAP, OIDC, or OAuth, WireGuard Portal sanitizes the field values received from the provider before storing them. This protects against several classes of attack that a compromised or misconfigured identity provider could introduce:
- **Unsafe control characters** — Unicode control and format characters, null bytes, and invalid UTF-8 bytes are stripped from external profile fields before they reach the Vue.js UI or email templates.
- **Email header injection** — carriage return and line feed characters in email fields are rejected entirely, and email fields must parse as plain email addresses.
- **Log injection** — unsafe control and format characters are stripped from all external profile fields and from sanitization log context.
- **Denial of service via oversized fields** — field lengths are capped (e.g., 256 runes for identifiers, 254 characters for email addresses).
- **Reserved identifier collision** — reserved user identifiers such as `"all"`, `"new"`, `"id"`, and internal system user identifiers are rejected.
- **Unsafe authorization groups** — OIDC/OAuth group claims are sanitized before group-based checks; groups changed by control/format stripping or truncation are dropped rather than repaired into allowed/admin matches.
Sanitization is always enabled and cannot be disabled.
When sanitization modifies or clears a field value, a `WARN` log entry is emitted with the provider name, provider type, and field name — but never the raw or sanitized value, to avoid leaking sensitive data into logs. This makes it straightforward to detect and investigate potentially malicious or misconfigured providers.
---
## UI and API Access ## UI and API Access
WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches. WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches.
@@ -21,4 +38,4 @@ A detailed explanation is available in the [Reverse Proxy](../getting-started/re
### Secure Authentication ### 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. 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. 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. For local accounts, administrators should enforce strong password requirements.

View File

@@ -43,4 +43,12 @@ If you set the `disable_missing` property to `true`, any user that is not found
All peers associated with that user will also be disabled. 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`. 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. This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.
##### Interface-specific Access Materialization
If `interface_filter` is configured in the LDAP provider, the synchronization process will evaluate these filters for each enabled user.
The results are materialized in the `interfaces` table of the database in a hidden field.
This materialized list is used by the backend to quickly determine if a user has permission to provision peers for a specific interface, without having to query the LDAP server for every request.
The list is refreshed every time the LDAP synchronization runs.
For more details on how to configure these filters, see the [Authentication](./authentication.md#interface-specific-provisioning-filters) section.

View File

@@ -14,7 +14,7 @@
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";
</script> </script>
<script src="/api/v0/config/frontend.js"></script> <script src="/api/v0/config/frontend.js" vite-ignore></script>
</head> </head>
<body class="d-flex flex-column min-vh-100"> <body class="d-flex flex-column min-vh-100">
<noscript> <noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,28 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/nunito-sans": "^5.2.7", "@fontsource/nunito-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@kyvg/vue3-notification": "^3.4.2", "@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.3.0",
"@vojtechlanka/vue-tags-input": "^3.1.1", "@vojtechlanka/vue-tags-input": "^3.1.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootswatch": "^5.3.8", "bootswatch": "^5.3.8",
"cidr-tools": "^11.0.3", "cidr-tools": "^11.3.2",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"ip-address": "^10.1.0", "ip-address": "^10.1.0",
"is-cidr": "^6.0.1", "is-cidr": "^6.0.3",
"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.25", "vue": "^3.5.32",
"vue-i18n": "^11.2.2", "vue-i18n": "^11.3.2",
"vue-prism-component": "github:h44z/vue-prism-component", "vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.6.3" "vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.6",
"sass-embedded": "^1.93.3", "sass-embedded": "^1.99.0",
"vite": "^7.2.7" "vite": "^8.0.8"
} }
} }

View File

@@ -53,6 +53,7 @@ const formData = ref(freshInterface())
const isSaving = ref(false) const isSaving = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const isApplyingDefaults = ref(false) const isApplyingDefaults = ref(false)
const isCreatingDefaultPeers = ref(false)
const isBackendValid = computed(() => { const isBackendValid = computed(() => {
if (!props.visible || !selectedInterface.value) { if (!props.visible || !selectedInterface.value) {
@@ -313,8 +314,42 @@ async function applyPeerDefaults() {
} }
} }
async function createDefaultPeers() {
if (props.interfaceId==='#NEW#') {
return; // do nothing for new interfaces
}
if (!formData.value.CreateDefaultPeer) {
return; // only allowed if the interface flag is set
}
if (isCreatingDefaultPeers.value) return
isCreatingDefaultPeers.value = true
try {
await interfaces.CreateDefaultPeers(selectedInterface.value.Identifier)
notify({
title: "Default Peers Created",
text: "Created default peers for all users on this interface.",
type: 'success',
})
await peers.LoadPeers(selectedInterface.value.Identifier) // reload peers list
} catch (e) {
console.log(e)
notify({
title: "Failed to create default peers!",
text: e.toString(),
type: 'error',
})
} finally {
isCreatingDefaultPeers.value = false
}
}
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.interface-edit.confirm-delete', {id: selectedInterface.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier) await interfaces.DeleteInterface(selectedInterface.value.Identifier)
@@ -489,9 +524,15 @@ 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')"> <div class="d-flex align-items-center justify-content-between" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')">
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox"> <div class="form-check form-switch mb-0">
<label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label> <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>
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-primary btn-sm" type="button" @click.prevent="createDefaultPeers" :disabled="!formData.CreateDefaultPeer || isCreatingDefaultPeers">
<span v-if="isCreatingDefaultPeers" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('modals.interface-edit.button-create-default-peers') }}
</button>
</div> </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">

View File

@@ -26,13 +26,13 @@
display:block; display:block;
} }
.modal.show { .modal.show {
opacity: 1; opacity: 1.0;
} }
.modal-backdrop { .modal-backdrop {
background-color: rgba(0,0,0,0.6) !important; background-color: rgba(0,0,0,0.6) !important;
} }
.modal-backdrop.show { .modal-backdrop.show {
opacity: 1 !important; opacity: 1.0 !important;
} }
</style> </style>

View File

@@ -0,0 +1,121 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalCount: {
type: Number,
required: true
},
pageSize: {
type: Number,
required: true
},
onGotoPage: {
type: Function,
required: true
},
onNextPage: {
type: Function,
required: true
},
onPrevPage: {
type: Function,
required: true
},
hasNextPage: {
type: Boolean,
required: true
},
hasPrevPage: {
type: Boolean,
required: true
}
});
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
const pages = computed(() => {
const current = props.currentPage;
const last = totalPages.value;
const delta = 2; // Number of pages to show before and after current page
const range = [];
const rangeWithDots = [];
// If total pages is small, just show all pages
if (last <= 7) {
for (let i = 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
return rangeWithDots;
}
// Calculate the range around the current page
let start = Math.max(2, current - delta);
let end = Math.min(last - 1, current + delta);
// Adjust range to always show a consistent number of pages if possible
if (current <= delta + 2) {
end = 2 + delta * 2;
} else if (current >= last - delta - 1) {
start = last - delta * 2 - 1;
}
// Add dots before the range if needed
if (start > 2) {
rangeWithDots.push({ type: 'page', value: 1 });
rangeWithDots.push({ type: 'dots', value: 'dots-start' });
} else {
for (let i = 1; i < start; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
// Add the central range
for (let i = start; i <= end; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
// Add dots after the range if needed
if (end < last - 1) {
rangeWithDots.push({ type: 'dots', value: 'dots-end' });
rangeWithDots.push({ type: 'page', value: last });
} else {
for (let i = end + 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
return rangeWithDots;
});
</script>
<template>
<ul class="pagination pagination-sm mb-0" v-if="totalPages > 1">
<li :class="{ disabled: !hasPrevPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasPrevPage && onPrevPage()">&laquo;</a>
</li>
<li v-for="item in pages" :key="item.type === 'page' ? item.value : item.value" :class="{ active: currentPage === item.value, disabled: item.type === 'dots' }" class="page-item">
<a v-if="item.type === 'page'" class="page-link" href="#" @click.prevent="onGotoPage(item.value)">{{ item.value }}</a>
<span v-else class="page-link">...</span>
</li>
<li :class="{ disabled: !hasNextPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasNextPage && onNextPage()">&raquo;</a>
</li>
</ul>
</template>
<style scoped>
.page-link {
cursor: pointer;
}
.page-item.disabled .page-link {
cursor: default;
}
</style>

View File

@@ -294,6 +294,7 @@ async function save() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.peer-edit.confirm-delete', {id: selectedPeer.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await peers.DeletePeer(selectedPeer.value.Identifier) await peers.DeletePeer(selectedPeer.value.Identifier)

View File

@@ -114,6 +114,7 @@ async function save() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.user-edit.confirm-delete', {id: selectedUser.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await users.DeleteUser(selectedUser.value.Identifier) await users.DeleteUser(selectedUser.value.Identifier)

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

@@ -9,6 +9,7 @@ import uk from './translations/uk.json';
import vi from './translations/vi.json'; import vi from './translations/vi.json';
import zh from './translations/zh.json'; import zh from './translations/zh.json';
import es from './translations/es.json'; import es from './translations/es.json';
import ja from './translations/ja.json';
import {createI18n} from "vue-i18n"; import {createI18n} from "vue-i18n";
@@ -33,7 +34,8 @@ const i18n = createI18n({
"uk": uk, "uk": uk,
"vi": vi, "vi": vi,
"zh": zh, "zh": zh,
"es": es, "es": es,
"ja": ja,
} }
}); });

View File

@@ -382,7 +382,8 @@
"persist-local-changes": { "persist-local-changes": {
"label": "Lokale Änderungen speichern" "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." "sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben.",
"confirm-delete": "Benutzer '{id}' wirklich löschen?"
}, },
"interface-view": { "interface-view": {
"headline": "Konfiguration für Schnittstelle:" "headline": "Konfiguration für Schnittstelle:"
@@ -503,7 +504,9 @@
"placeholder": "Persistentes Keepalive (0 = Standard)" "placeholder": "Persistentes Keepalive (0 = Standard)"
} }
}, },
"button-apply-defaults": "Peer-Standardeinstellungen anwenden" "button-apply-defaults": "Peer-Standardeinstellungen anwenden",
"button-create-default-peers": "Standard-Peers erstellen",
"confirm-delete": "Interface '{id}' wirklich löschen?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -625,7 +628,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Ablaufdatum" "label": "Ablaufdatum"
} },
"confirm-delete": "Peer '{id}' wirklich löschen?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Mehrere Peers erstellen", "headline-peer": "Mehrere Peers erstellen",

View File

@@ -271,16 +271,16 @@
"headline-preshared-key": "New Preshared Key", "headline-preshared-key": "New Preshared Key",
"button-generate": "Generate", "button-generate": "Generate",
"private-key": { "private-key": {
"label": "Private Key", "label": "Private Key",
"placeholder": "The private key" "placeholder": "The private key"
}, },
"public-key": { "public-key": {
"label": "Public Key", "label": "Public Key",
"placeholder": "The public key" "placeholder": "The public key"
}, },
"preshared-key": { "preshared-key": {
"label": "Preshared Key", "label": "Preshared Key",
"placeholder": "The pre-shared key" "placeholder": "The pre-shared key"
} }
}, },
"calculator": { "calculator": {
@@ -289,18 +289,18 @@
"headline-allowed-ip": "New Allowed IPs", "headline-allowed-ip": "New Allowed IPs",
"button-exclude-private": "Exclude Private IP Ranges", "button-exclude-private": "Exclude Private IP Ranges",
"allowed-ip": { "allowed-ip": {
"label": "Allowed IPs", "label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0", "placeholder": "0.0.0.0/0, ::/0",
"empty": "Value cannot be empty" "empty": "Value cannot be empty"
}, },
"dissallowed-ip": { "dissallowed-ip": {
"label": "Disallowed IPs", "label": "Disallowed IPs",
"placeholder": "10.0.0.0/8, 192.168.0.0/16", "placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Invalid address: {addr}" "invalid": "Invalid address: {addr}"
}, },
"new-allowed-ip": { "new-allowed-ip": {
"label": "Allowed IPs", "label": "Allowed IPs",
"placeholder": "" "placeholder": ""
} }
}, },
"modals": { "modals": {
@@ -382,7 +382,8 @@
"persist-local-changes": { "persist-local-changes": {
"label": "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." "sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization.",
"confirm-delete": "Are you sure you want to delete user '{id}'?"
}, },
"interface-view": { "interface-view": {
"headline": "Config for Interface:" "headline": "Config for Interface:"
@@ -503,8 +504,9 @@
"placeholder": "Persistent Keepalive (0 = default)" "placeholder": "Persistent Keepalive (0 = default)"
} }
}, },
"button-apply-defaults": "Apply Peer Defaults",
"button-apply-defaults": "Apply Peer Defaults" "button-create-default-peers": "Create Default Peers",
"confirm-delete": "Are you sure you want to delete interface '{id}'?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -626,7 +628,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Expiry date" "label": "Expiry date"
} },
"confirm-delete": "Are you sure you want to delete peer '{id}'?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Create multiple peers", "headline-peer": "Create multiple peers",

File diff suppressed because it is too large Load Diff

View File

@@ -126,9 +126,7 @@
"peer-expiring": "Le pair expire le", "peer-expiring": "Le pair expire le",
"peer-connected": "Connecté", "peer-connected": "Connecté",
"peer-not-connected": "Non connecté", "peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :", "peer-handshake": "Dernière négociation :"
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
}, },
"users": { "users": {
"headline": "Administration des utilisateurs", "headline": "Administration des utilisateurs",
@@ -264,7 +262,8 @@
}, },
"admin": { "admin": {
"label": "Est Admin" "label": "Est Admin"
} },
"confirm-delete": "Voulez-vous vraiment supprimer l'utilisateur \"{id}\" ?"
}, },
"interface-view": { "interface-view": {
"headline": "Configuration pour l'interface :" "headline": "Configuration pour l'interface :"
@@ -377,7 +376,9 @@
"placeholder": "Persistent Keepalive (0 = par défaut)" "placeholder": "Persistent Keepalive (0 = par défaut)"
} }
}, },
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs" "button-apply-defaults": "Appliquer les valeurs par défaut des pairs",
"button-create-default-peers": "Créer les pairs par défaut",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Pair :", "headline-peer": "Pair :",
@@ -493,7 +494,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Date d'expiration" "label": "Date d'expiration"
} },
"confirm-delete": "Voulez-vous vraiment supprimer le pair \"{id}\" ?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Créer plusieurs pairs", "headline-peer": "Créer plusieurs pairs",

View File

@@ -0,0 +1,651 @@
{
"languages": {
"en": "日本語"
},
"general": {
"pagination": {
"size": "件数",
"all": "全件 (低速)"
},
"search": {
"placeholder": "検索...",
"button": "検索"
},
"select-all": "すべて選択",
"yes": "はい",
"no": "いいえ",
"cancel": "キャンセル",
"close": "閉じる",
"save": "保存",
"delete": "削除"
},
"login": {
"headline": "ログイン",
"username": {
"label": "ユーザー名",
"placeholder": "ユーザー名を入力してください"
},
"password": {
"label": "パスワード",
"placeholder": "パスワードを入力してください"
},
"button": "ログイン",
"button-webauthn": "パスキーでログイン"
},
"menu": {
"home": "ホーム",
"interfaces": "インターフェース",
"users": "ユーザー",
"lang": "言語切替",
"profile": "マイプロフィール",
"settings": "設定",
"audit": "監査ログ",
"login": "ログイン",
"logout": "ログアウト",
"keygen": "鍵生成",
"calculator": "IP計算機"
},
"home": {
"headline": "WireGuard® VPN ポータル",
"info-headline": "詳細情報",
"abstract": "WireGuard® は最新の暗号技術を活用した、シンプルかつ高速なモダンVPNです。IPsec より高速・シンプル・軽量・実用的な設計を目指し、OpenVPN を大きく上回る性能を発揮します。",
"installation": {
"box-header": "WireGuard インストール",
"headline": "インストール",
"content": "クライアントソフトウェアのインストール手順は WireGuard 公式サイトでご確認いただけます。",
"button": "インストール手順を開く"
},
"about-wg": {
"box-header": "WireGuard について",
"headline": "概要",
"content": "WireGuard® は最新の暗号技術を活用したシンプルかつ高速なモダンVPNです。",
"button": "詳細"
},
"about-portal": {
"box-header": "WireGuard Portal について",
"headline": "WireGuard Portal",
"content": "WireGuard Portal は WireGuard を Web から簡単に設定できる管理ポータルです。",
"button": "詳細"
},
"profiles": {
"headline": "VPN プロファイル",
"abstract": "個人 VPN 設定の確認・ダウンロードはユーザープロファイルから行えます。",
"content": "設定済みプロファイルの一覧は下のボタンから開けます。",
"button": "マイプロフィールを開く"
},
"admin": {
"headline": "管理エリア",
"abstract": "管理エリアでは、WireGuard ピア、サーバーインターフェース、および WireGuard Portal にログイン可能なユーザーを管理できます。",
"content": "",
"button-admin": "サーバー管理を開く",
"button-user": "ユーザー管理を開く"
}
},
"interfaces": {
"headline": "インターフェース管理",
"headline-peers": "現在の VPN ピア",
"headline-endpoints": "現在のエンドポイント",
"no-interface": {
"default-selection": "利用可能なインターフェースなし",
"headline": "インターフェースが見つかりません...",
"abstract": "上の「+」ボタンから新しい WireGuard インターフェースを作成してください。"
},
"no-peer": {
"headline": "ピアがありません",
"abstract": "選択した WireGuard インターフェースには現在ピアが登録されていません。"
},
"table-heading": {
"name": "名前",
"user": "ユーザー",
"ip": "IP",
"endpoint": "エンドポイント",
"status": "ステータス"
},
"interface": {
"headline": "インターフェース状態:",
"backend": "バックエンド",
"unknown-backend": "不明",
"wrong-backend": "バックエンドが無効です。代わりにローカル WireGuard バックエンドを使用します。",
"key": "公開鍵",
"endpoint": "公開エンドポイント",
"port": "待受ポート",
"peers": "有効ピア数",
"total-peers": "全ピア数",
"endpoints": "有効エンドポイント数",
"total-endpoints": "全エンドポイント数",
"ip": "IPアドレス",
"default-allowed-ip": "デフォルト許可IP",
"dns": "DNSサーバー",
"mtu": "MTU",
"default-keep-alive": "デフォルトキープアライブ間隔",
"default-dns": "デフォルトDNSサーバー",
"button-show-config": "設定を表示",
"button-download-config": "設定をダウンロード",
"button-store-config": "wg-quick 用に設定を保存",
"button-edit": "インターフェースを編集"
},
"button-add-interface": "インターフェース追加",
"button-add-peer": "ピア追加",
"button-add-peers": "複数ピアを追加",
"button-show-peer": "ピア詳細",
"button-edit-peer": "ピア編集",
"button-bulk-delete": "選択したピアを削除",
"button-bulk-enable": "選択したピアを有効化",
"button-bulk-disable": "選択したピアを無効化",
"confirm-bulk-delete": "{count} 件のピアを削除してもよろしいですか?",
"confirm-bulk-disable": "{count} 件のピアを無効化してもよろしいですか?",
"peer-disabled": "ピアは無効化されています。理由:",
"peer-expiring": "ピアの有効期限:",
"peer-connected": "接続中",
"peer-not-connected": "未接続",
"peer-handshake": "最終ハンドシェイク:"
},
"users": {
"headline": "ユーザー管理",
"table-heading": {
"id": "ID",
"email": "メール",
"firstname": "名",
"lastname": "姓",
"sources": "ソース",
"peers": "ピア",
"admin": "管理者"
},
"no-user": {
"headline": "ユーザーがいません",
"abstract": "現在 WireGuard Portal に登録されているユーザーはいません。"
},
"button-add-user": "ユーザー追加",
"button-show-user": "ユーザー詳細",
"button-edit-user": "ユーザー編集",
"button-bulk-delete": "選択したユーザーを削除",
"button-bulk-enable": "選択したユーザーを有効化",
"button-bulk-disable": "選択したユーザーを無効化",
"button-bulk-lock": "選択したユーザーをロック",
"button-bulk-unlock": "選択したユーザーのロック解除",
"confirm-bulk-delete": "{count} 件のユーザーを削除してもよろしいですか?",
"confirm-bulk-disable": "{count} 件のユーザーを無効化してもよろしいですか?",
"confirm-bulk-lock": "{count} 件のユーザーをロックしてもよろしいですか?",
"user-disabled": "ユーザーは無効化されています。理由:",
"user-locked": "アカウントはロックされています。理由:",
"admin": "管理者権限あり",
"no-admin": "管理者権限なし"
},
"profile": {
"headline": "マイ VPN ピア",
"table-heading": {
"name": "名前",
"ip": "IP",
"stats": "ステータス",
"interface": "サーバーインターフェース"
},
"no-peer": {
"headline": "ピアがありません",
"abstract": "現在、あなたのユーザープロフィールにはピアが関連付けられていません。"
},
"peer-connected": "接続中",
"button-add-peer": "ピア追加",
"button-show-peer": "ピア詳細",
"button-edit-peer": "ピア編集"
},
"settings": {
"headline": "設定",
"abstract": "ここで個人設定を変更できます。",
"api": {
"headline": "API 設定",
"abstract": "RESTful API の設定はこちらで行います。",
"active-description": "あなたのアカウントで API は現在有効です。すべての API リクエストは Basic 認証で行います。以下の認証情報を使用してください。",
"inactive-description": "API は現在無効です。下のボタンを押して有効化してください。",
"user-label": "API ユーザー名:",
"user-placeholder": "API ユーザー",
"token-label": "API パスワード:",
"token-placeholder": "API トークン",
"token-created-label": "API アクセス許可日時: ",
"button-disable-title": "API を無効化します。現在のトークンは無効になります。",
"button-disable-text": "API を無効化",
"button-enable-title": "API を有効化します。新しいトークンが生成されます。",
"button-enable-text": "API を有効化",
"api-link": "API ドキュメント"
},
"webauthn": {
"headline": "パスキー設定",
"abstract": "パスキーはパスワード不要でユーザー認証を行う最新の方法です。ブラウザに安全に保存され、WireGuard Portal へのログインに使用できます。",
"active-description": "現在、あなたのアカウントには少なくとも 1 つのパスキーが登録されています。",
"inactive-description": "あなたのアカウントにはパスキーが登録されていません。下のボタンから新しいパスキーを登録してください。",
"table": {
"name": "名前",
"created": "作成日時",
"actions": ""
},
"credentials-list": "登録済みパスキー",
"modal-delete": {
"headline": "パスキー削除",
"abstract": "このパスキーを削除してもよろしいですか? 削除後はこのパスキーでログインできなくなります。",
"created": "作成日時:",
"button-delete": "削除",
"button-cancel": "キャンセル"
},
"button-rename-title": "リネーム",
"button-rename-text": "パスキーの名前を変更します。",
"button-save-title": "保存",
"button-save-text": "新しいパスキー名を保存します。",
"button-cancel-title": "キャンセル",
"button-cancel-text": "リネームをキャンセルします。",
"button-delete-title": "削除",
"button-delete-text": "パスキーを削除します。削除後はこのパスキーでログインできなくなります。",
"button-register-title": "パスキー登録",
"button-register-text": "新しいパスキーを登録してアカウントを保護します。"
},
"password": {
"headline": "パスワード設定",
"abstract": "ここでパスワードを変更できます。",
"current-label": "現在のパスワード",
"new-label": "新しいパスワード",
"new-confirm-label": "新しいパスワード(確認)",
"change-button-text": "パスワード変更",
"invalid-confirm-label": "パスワードが一致しません",
"weak-label": "パスワードが脆弱です"
}
},
"audit": {
"headline": "監査ログ",
"abstract": "WireGuard Portal で実行されたすべての操作の監査ログを確認できます。",
"no-entries": {
"headline": "ログがありません",
"abstract": "現在、監査ログは記録されていません。"
},
"entries-headline": "ログエントリ",
"table-heading": {
"id": "#",
"time": "日時",
"user": "ユーザー",
"severity": "重要度",
"origin": "発生元",
"message": "メッセージ"
}
},
"keygen": {
"headline": "WireGuard 鍵生成",
"abstract": "新しい WireGuard 鍵を生成します。鍵はローカルブラウザで生成され、サーバーには送信されません。",
"headline-keypair": "新しい鍵ペア",
"headline-preshared-key": "新しい事前共有鍵",
"button-generate": "生成",
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"preshared-key": {
"label": "事前共有鍵",
"placeholder": "事前共有鍵"
}
},
"calculator": {
"headline": "WireGuard IP 計算機",
"abstract": "WireGuard の Allowed IPs を生成します。IP サブネットはローカルブラウザで生成され、サーバーには送信されません。",
"headline-allowed-ip": "新しい Allowed IPs",
"button-exclude-private": "プライベートIP範囲を除外",
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "値は必須です"
},
"dissallowed-ip": {
"label": "除外IP",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "無効なアドレス: {addr}"
},
"new-allowed-ip": {
"label": "Allowed IPs",
"placeholder": ""
}
},
"modals": {
"user-view": {
"headline": "ユーザーアカウント:",
"tab-user": "情報",
"tab-peers": "ピア",
"headline-info": "ユーザー情報:",
"headline-notes": "備考:",
"email": "メール",
"firstname": "名",
"lastname": "姓",
"phone": "電話番号",
"department": "部署",
"api-enabled": "API アクセス",
"disabled": "アカウント無効",
"locked": "アカウントロック中",
"no-peers": "このユーザーには関連するピアがありません。",
"peers": {
"name": "名前",
"interface": "インターフェース",
"ip": "IP"
}
},
"user-edit": {
"headline-edit": "ユーザー編集:",
"headline-new": "新規ユーザー",
"header-general": "全般",
"header-personal": "ユーザー情報",
"header-notes": "備考",
"header-state": "状態",
"identifier": {
"label": "識別子",
"placeholder": "一意のユーザー識別子"
},
"source": {
"label": "ソース",
"placeholder": "ユーザーのソース"
},
"password": {
"label": "パスワード",
"placeholder": "強固なパスワード",
"description": "現在のパスワードを保持する場合は空のままにします。",
"too-weak": "パスワードが脆弱です。より強固なパスワードを使用してください。"
},
"email": {
"label": "メール",
"placeholder": "メールアドレス"
},
"phone": {
"label": "電話",
"placeholder": "電話番号"
},
"department": {
"label": "部署",
"placeholder": "部署名"
},
"firstname": {
"label": "名",
"placeholder": "名"
},
"lastname": {
"label": "姓",
"placeholder": "姓"
},
"notes": {
"label": "備考",
"placeholder": ""
},
"disabled": {
"label": "無効化 (WireGuard 接続およびログインを禁止)"
},
"locked": {
"label": "ロック (ログイン禁止、WireGuard 接続は引き続き有効)"
},
"admin": {
"label": "管理者"
},
"persist-local-changes": {
"label": "ローカル変更を保持"
},
"sync-warning": "同期されたユーザーを変更するには、ローカル変更の保持を有効化してください。そうしないと次回の同期時に変更が上書きされます。",
"confirm-delete": "ユーザー '{id}' を削除してもよろしいですか?"
},
"interface-view": {
"headline": "インターフェース設定:"
},
"interface-edit": {
"headline-edit": "インターフェース編集:",
"headline-new": "新規インターフェース",
"tab-interface": "インターフェース",
"tab-peerdef": "ピアのデフォルト",
"header-general": "全般",
"header-network": "ネットワーク",
"header-crypto": "暗号化",
"header-hooks": "インターフェースフック",
"header-peer-hooks": "フック",
"header-state": "状態",
"identifier": {
"label": "識別子",
"placeholder": "一意のインターフェース識別子"
},
"mode": {
"label": "インターフェースモード",
"server": "サーバーモード",
"client": "クライアントモード",
"any": "不明モード"
},
"backend": {
"label": "インターフェースバックエンド",
"invalid-label": "元のバックエンドが利用できなくなったため、ローカル WireGuard バックエンドを使用します。",
"local": "ローカル WireGuard バックエンド"
},
"display-name": {
"label": "表示名",
"placeholder": "インターフェースの説明的な名前"
},
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"ip": {
"label": "IPアドレス",
"placeholder": "IPアドレス (CIDR 形式)"
},
"listen-port": {
"label": "待受ポート",
"placeholder": "待ち受けポート"
},
"dns": {
"label": "DNSサーバー",
"placeholder": "使用するDNSサーバー"
},
"dns-search": {
"label": "DNS 検索ドメイン",
"placeholder": "DNS 検索プレフィックス"
},
"mtu": {
"label": "MTU",
"placeholder": "インターフェース MTU (0 = デフォルト)"
},
"firewall-mark": {
"label": "ファイアウォールマーク",
"placeholder": "送信トラフィックに付与するファイアウォールマーク (0 = 自動)"
},
"routing-table": {
"label": "ルーティングテーブル",
"placeholder": "ルーティングテーブルID",
"description": "特殊値: off = 経路を管理しない、0 = 自動"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-up": {
"label": "Post-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-down": {
"label": "Post-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"disabled": {
"label": "インターフェース無効化"
},
"create-default-peer": {
"label": "新規ユーザー用にデフォルトピアを作成"
},
"save-config": {
"label": "wg-quick 設定を自動保存"
},
"defaults": {
"endpoint": {
"label": "エンドポイントアドレス",
"placeholder": "エンドポイントアドレス",
"description": "ピアが接続するエンドポイントアドレス。(例: wg.example.com または wg.example.com:51820)"
},
"networks": {
"label": "IP ネットワーク",
"placeholder": "ネットワークアドレス",
"description": "ピアにはこれらのサブネットから IP が割り当てられます。"
},
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "デフォルトの Allowed IPs"
},
"mtu": {
"label": "MTU",
"placeholder": "クライアント MTU (0 = デフォルト)"
},
"keep-alive": {
"label": "キープアライブ間隔",
"placeholder": "Persistent Keepalive (0 = デフォルト)"
}
},
"button-apply-defaults": "ピアのデフォルトを適用",
"button-create-default-peers": "デフォルトピアを作成",
"confirm-delete": "インターフェース '{id}' を削除してもよろしいですか?"
},
"peer-view": {
"headline-peer": "ピア:",
"headline-endpoint": "エンドポイント:",
"section-info": "ピア情報",
"section-status": "現在の状態",
"section-config": "設定",
"identifier": "識別子",
"ip": "IPアドレス",
"allowed-ip": "Allowed IPs",
"extra-allowed-ip": "サーバー側 Allowed IPs",
"user": "関連ユーザー",
"notes": "備考",
"expiry-status": "有効期限",
"disabled-status": "無効化日時",
"traffic": "通信量",
"connection-status": "接続統計",
"upload": "アップロード(サーバー → ピア、バイト)",
"download": "ダウンロード(ピア → サーバー、バイト)",
"pingable": "ping 応答",
"handshake": "最終ハンドシェイク",
"connected-since": "接続開始日時",
"endpoint": "エンドポイント",
"endpoint-key": "エンドポイント公開鍵",
"keepalive": "Persistent Keepalive",
"button-download": "設定をダウンロード",
"button-email": "設定をメール送信",
"style-label": "設定スタイル"
},
"peer-edit": {
"headline-edit-peer": "ピア編集:",
"headline-edit-endpoint": "エンドポイント編集:",
"headline-new-peer": "ピア作成",
"headline-new-endpoint": "エンドポイント作成",
"header-general": "全般",
"header-network": "ネットワーク",
"header-crypto": "暗号化",
"header-hooks": "フック (ピア側で実行)",
"header-state": "状態",
"display-name": {
"label": "表示名",
"placeholder": "ピアの説明的な名前"
},
"linked-user": {
"label": "関連ユーザー",
"placeholder": "このピアを所有するユーザーアカウント"
},
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵",
"help": "秘密鍵はサーバー上に安全に保存されます。すでにユーザーがコピーを持っている場合は空欄でも構いません。サーバーはピアの公開鍵のみで動作します。"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"preshared-key": {
"label": "事前共有鍵",
"placeholder": "オプションの事前共有鍵"
},
"endpoint-public-key": {
"label": "エンドポイント公開鍵",
"placeholder": "リモートエンドポイントの公開鍵"
},
"endpoint": {
"label": "エンドポイントアドレス",
"placeholder": "リモートエンドポイントのアドレス"
},
"ip": {
"label": "IPアドレス",
"placeholder": "IPアドレス (CIDR 形式)"
},
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "Allowed IPs (CIDR 形式)"
},
"extra-allowed-ip": {
"label": "追加の Allowed IPs",
"placeholder": "追加 Allowed IPs (サーバー側)",
"description": "これらの IP はリモート WireGuard インターフェースの Allowed IPs に追加されます。"
},
"dns": {
"label": "DNSサーバー",
"placeholder": "使用するDNSサーバー"
},
"dns-search": {
"label": "DNS 検索ドメイン",
"placeholder": "DNS 検索プレフィックス"
},
"keep-alive": {
"label": "キープアライブ間隔",
"placeholder": "Persistent Keepalive (0 = デフォルト)"
},
"mtu": {
"label": "MTU",
"placeholder": "クライアント MTU (0 = デフォルト)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-up": {
"label": "Post-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-down": {
"label": "Post-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"disabled": {
"label": "ピア無効化"
},
"ignore-global": {
"label": "グローバル設定を無視"
},
"expires-at": {
"label": "有効期限"
},
"confirm-delete": "ピア '{id}' を削除してもよろしいですか?"
},
"peer-multi-create": {
"headline-peer": "複数ピア作成",
"headline-endpoint": "複数エンドポイント作成",
"identifiers": {
"label": "ユーザー識別子",
"placeholder": "ユーザー識別子",
"description": "ピアを作成するユーザー識別子(ユーザー名)。"
},
"prefix": {
"headline-peer": "ピア:",
"headline-endpoint": "エンドポイント:",
"label": "表示名プレフィックス",
"placeholder": "プレフィックス",
"description": "ピア表示名に追加されるプレフィックス。"
}
}
}
}

View File

@@ -282,6 +282,7 @@
"label": "관리자 여부" "label": "관리자 여부"
} }
}, },
"confirm-delete": "사용자 '{id}'를 삭제하시겠습니까?",
"interface-view": { "interface-view": {
"headline": "인터페이스 구성:" "headline": "인터페이스 구성:"
}, },
@@ -393,7 +394,9 @@
"placeholder": "영구 Keepalive (0 = 기본값)" "placeholder": "영구 Keepalive (0 = 기본값)"
} }
}, },
"button-apply-defaults": "피어 기본값 적용" "button-apply-defaults": "피어 기본값 적용",
"button-create-default-peers": "기본 피어 생성",
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "피어:", "headline-peer": "피어:",
@@ -509,7 +512,8 @@
}, },
"expires-at": { "expires-at": {
"label": "만료 날짜" "label": "만료 날짜"
} },
"confirm-delete": "피어 '{id}'를 삭제하시겠습니까?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "여러 피어 생성", "headline-peer": "여러 피어 생성",

View File

@@ -300,7 +300,8 @@
}, },
"admin": { "admin": {
"label": "É Administrador" "label": "É Administrador"
} },
"confirm-delete": "Tem certeza que deseja excluir o utilizador '{id}'?"
}, },
"interface-view": { "interface-view": {
"headline": "Configuração para a Interface:" "headline": "Configuração para a Interface:"
@@ -413,7 +414,9 @@
"placeholder": "Keepalive persistente (0 = padrão)" "placeholder": "Keepalive persistente (0 = padrão)"
} }
}, },
"button-apply-defaults": "Aplicar Padrões de Peer" "button-apply-defaults": "Aplicar Padrões de Peer",
"button-create-default-peers": "Criar Peers Padrão",
"confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -530,7 +533,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Data de expiração" "label": "Data de expiração"
} },
"confirm-delete": "Tem certeza que deseja excluir o par '{id}'?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Criar múltiplos peers", "headline-peer": "Criar múltiplos peers",

View File

@@ -259,16 +259,16 @@
"headline-preshared-key": "Новый общий ключ", "headline-preshared-key": "Новый общий ключ",
"button-generate": "Генерировать", "button-generate": "Генерировать",
"private-key": { "private-key": {
"label": "Приватный ключ", "label": "Приватный ключ",
"placeholder": "Приватный ключ" "placeholder": "Приватный ключ"
}, },
"public-key": { "public-key": {
"label": "Публичный ключ", "label": "Публичный ключ",
"placeholder": "Публичный ключ" "placeholder": "Публичный ключ"
}, },
"preshared-key": { "preshared-key": {
"label": "Общий ключ", "label": "Общий ключ",
"placeholder": "Общий ключ" "placeholder": "Общий ключ"
} }
}, },
"calculator": { "calculator": {
@@ -277,18 +277,18 @@
"headline-allowed-ip": "Новые разрешенные IP-адреса", "headline-allowed-ip": "Новые разрешенные IP-адреса",
"button-exclude-private": "Исключить частные диапазоны IP-адресов", "button-exclude-private": "Исключить частные диапазоны IP-адресов",
"allowed-ip": { "allowed-ip": {
"label": "Разрешенные IP-адреса", "label": "Разрешенные IP-адреса",
"placeholder": "0.0.0.0/0, ::/0", "placeholder": "0.0.0.0/0, ::/0",
"empty": "Поле ввода не должно быть пустым" "empty": "Поле ввода не должно быть пустым"
}, },
"dissallowed-ip": { "dissallowed-ip": {
"label": "Запрещенные IP-адреса", "label": "Запрещенные IP-адреса",
"placeholder": "10.0.0.0/8, 192.168.0.0/16", "placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Некорректный адрес: {addr}" "invalid": "Некорректный адрес: {addr}"
}, },
"new-allowed-ip": { "new-allowed-ip": {
"label": "Разрешенные IP-адреса", "label": "Разрешенные IP-адреса",
"placeholder": "" "placeholder": ""
} }
}, },
"modals": { "modals": {
@@ -366,7 +366,8 @@
}, },
"admin": { "admin": {
"label": "Является администратором" "label": "Является администратором"
} },
"confirm-delete": "Вы уверены, что хотите удалить пользователя «{id}»?"
}, },
"interface-view": { "interface-view": {
"headline": "Конфигурация интерфейса:" "headline": "Конфигурация интерфейса:"
@@ -484,7 +485,9 @@
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)" "placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
} }
}, },
"button-apply-defaults": "Применить настройки пира по умолчанию" "button-apply-defaults": "Применить настройки пира по умолчанию",
"button-create-default-peers": "Создать пиров по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Пир:", "headline-peer": "Пир:",
@@ -605,7 +608,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Дата истечения срока действия" "label": "Дата истечения срока действия"
} },
"confirm-delete": "Вы уверены, что хотите удалить пир «{id}»?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Создать несколько узлов", "headline-peer": "Создать несколько узлов",

View File

@@ -151,7 +151,6 @@
"admin": "Користувач має адміністративні привілеї", "admin": "Користувач має адміністративні привілеї",
"no-admin": "Користувач не має адміністративних привілеїв" "no-admin": "Користувач не має адміністративних привілеїв"
}, },
"profile": { "profile": {
"headline": "Мої VPN-піри", "headline": "Мої VPN-піри",
"table-heading": { "table-heading": {
@@ -189,7 +188,6 @@
"api-link": "Документація API" "api-link": "Документація API"
} }
}, },
"modals": { "modals": {
"user-view": { "user-view": {
"headline": "Обліковий запис користувача:", "headline": "Обліковий запис користувача:",
@@ -264,7 +262,8 @@
}, },
"admin": { "admin": {
"label": "Адміністратор" "label": "Адміністратор"
} },
"confirm-delete": "Ви впевнені, що хочете видалити користувача «{id}»?"
}, },
"interface-view": { "interface-view": {
"headline": "Конфігурація для інтерфейсу:" "headline": "Конфігурація для інтерфейсу:"
@@ -377,7 +376,9 @@
"placeholder": "Постійний Keepalive (0 = за замовчуванням)" "placeholder": "Постійний Keepalive (0 = за замовчуванням)"
} }
}, },
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів" "button-apply-defaults": "Застосувати значення за замовчуванням для пірів",
"button-create-default-peers": "Створити пірів за замовчуванням",
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Пір:", "headline-peer": "Пір:",
@@ -493,7 +494,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Дата закінчення терміну дії" "label": "Дата закінчення терміну дії"
} },
"confirm-delete": "Ви впевнені, що хочете видалити пір «{id}»?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Створити декілька пір", "headline-peer": "Створити декілька пір",

View File

@@ -240,7 +240,8 @@
}, },
"admin": { "admin": {
"label": "Là Quản trị viên" "label": "Là Quản trị viên"
} },
"confirm-delete": "Ban co chac muon xoa nguoi dung '{id}' khong?"
}, },
"interface-view": { "interface-view": {
"headline": "Cấu hình cho Giao diện:" "headline": "Cấu hình cho Giao diện:"
@@ -353,8 +354,9 @@
"placeholder": "Giữ kết nối liên tục (0 = mặc định)" "placeholder": "Giữ kết nối liên tục (0 = mặc định)"
} }
}, },
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer",
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer" "button-create-default-peers": "Tạo Peer Mặc định",
"confirm-delete": "Ban co chac muon xoa giao dien '{id}' khong?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -470,7 +472,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Ngày hết hạn" "label": "Ngày hết hạn"
} },
"confirm-delete": "Ban co chac muon xoa peer '{id}' khong?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Tạo nhiều peer", "headline-peer": "Tạo nhiều peer",

View File

@@ -242,6 +242,7 @@
"label": "管理员" "label": "管理员"
} }
}, },
"confirm-delete": "确定要删除用户“{id}”吗?",
"interface-view": { "interface-view": {
"headline": "接口配置: " "headline": "接口配置: "
}, },
@@ -353,7 +354,9 @@
"placeholder": "持久保持连接 (0 = 默认)" "placeholder": "持久保持连接 (0 = 默认)"
} }
}, },
"button-apply-defaults": "应用节点默认值" "button-apply-defaults": "应用节点默认值",
"button-create-default-peers": "创建默认节点",
"confirm-delete": "确定要删除接口“{id}”吗?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "节点: ", "headline-peer": "节点: ",
@@ -469,7 +472,8 @@
}, },
"expires-at": { "expires-at": {
"label": "过期日期" "label": "过期日期"
} },
"confirm-delete": "确定要删除对等点“{id}”吗?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "创建多个节点", "headline-peer": "创建多个节点",

View File

@@ -1,14 +1,15 @@
import {createRouter, createWebHashHistory} from 'vue-router' import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth' import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security' import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification"; import {notify} from "@kyvg/vue3-notification";
const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(routerBase),
routes: [ routes: [
{ {
path: '/', path: '/',
@@ -20,11 +21,6 @@ const router = createRouter({
name: 'login', name: 'login',
component: LoginView component: LoginView
}, },
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{ {
path: '/interfaces', path: '/interfaces',
name: 'interfaces', name: 'interfaces',

View File

@@ -11,7 +11,6 @@ export const auditStore = defineStore('audit', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
}), }),
getters: { getters: {
@@ -41,33 +40,22 @@ export const auditStore = defineStore('audit', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
this.pageOffset += this.pageSize if (this.hasNextPage) {
this.pageOffset += this.pageSize
this.calculatePages() }
}, },
previousPage() { previousPage() {
this.pageOffset -= this.pageSize if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
this.calculatePages() }
}, },
setEntries(entries) { setEntries(entries) {
this.entries = entries this.entries = entries
this.calculatePages()
this.fetching = false this.fetching = false
}, },
async LoadEntries() { async LoadEntries() {

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";
@@ -107,12 +108,19 @@ export const authStore = defineStore('auth',{
this.setUserInfo(null) this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^ this.ResetReturnUrl() // just to be sure^^
let logoutResponse = null
try { try {
await apiWrapper.post(`/auth/logout`) logoutResponse = await apiWrapper.post(`/auth/logout`)
} catch (e) { } catch (e) {
console.log("Logout request failed:", e) console.log("Logout request failed:", e)
} }
const redirectUrl = logoutResponse?.RedirectUrl
if (redirectUrl) {
window.location.href = redirectUrl
return
}
notify({ notify({
title: "Logged Out", title: "Logged Out",
text: "Logout successful!", text: "Logout successful!",
@@ -295,9 +303,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
@@ -136,6 +148,18 @@ export const interfaceStore = defineStore('interfaces', {
throw new Error(error) throw new Error(error)
}) })
}, },
async CreateDefaultPeers(id) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/create-default-peers`)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async SaveConfiguration(id) { async SaveConfiguration(id) {
this.fetching = true this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`) return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`)

View File

@@ -19,10 +19,10 @@ export const peerStore = defineStore('peers', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
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 +76,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,
}, },
@@ -83,34 +86,24 @@ export const peerStore = defineStore('peers', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
this.pageOffset += this.pageSize if (this.hasNextPage) {
this.pageOffset += this.pageSize
this.calculatePages() }
}, },
previousPage() { previousPage() {
this.pageOffset -= this.pageSize if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
this.calculatePages() }
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers
this.calculatePages()
this.fetching = false this.fetching = false
this.trafficStats = {}
}, },
setPeer(peer) { setPeer(peer) {
this.peer = peer this.peer = peer
@@ -126,11 +119,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)

View File

@@ -20,7 +20,6 @@ export const profileStore = defineStore('profile', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
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
@@ -74,34 +73,25 @@ export const profileStore = defineStore('profile', {
}, },
hasStatistics: (state) => state.statsEnabled, hasStatistics: (state) => state.statsEnabled,
CountInterfaces: (state) => state.interfaces.length, CountInterfaces: (state) => state.interfaces.length,
HasInterface: (state) => (id) => state.interfaces.some((i) => i.Identifier === id),
}, },
actions: { actions: {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
this.pageOffset += this.pageSize if (this.hasNextPage) {
this.pageOffset += this.pageSize
this.calculatePages() }
}, },
previousPage() { previousPage() {
this.pageOffset -= this.pageSize if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
this.calculatePages() }
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers

View File

@@ -12,7 +12,6 @@ export const userStore = defineStore('users', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
}), }),
getters: { getters: {
@@ -43,33 +42,22 @@ export const userStore = defineStore('users', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
this.pageOffset += this.pageSize if (this.hasNextPage) {
this.pageOffset += this.pageSize
this.calculatePages() }
}, },
previousPage() { previousPage() {
this.pageOffset -= this.pageSize if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
this.calculatePages() }
}, },
setUsers(users) { setUsers(users) {
this.users = users this.users = users
this.calculatePages()
this.fetching = false this.fetching = false
}, },
setUserPeers(peers) { setUserPeers(peers) {

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { onMounted } from "vue"; import { onMounted } from "vue";
import {auditStore} from "@/stores/audit"; import {auditStore} from "@/stores/audit";
import Pagination from "@/components/Pagination.vue";
const audit = auditStore() const audit = auditStore()
@@ -60,28 +61,24 @@ onMounted(async () => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{disabled:audit.pageOffset===0}" class="page-item"> :currentPage="audit.currentPage"
<a class="page-link" @click="audit.previousPage">&laquo;</a> :totalCount="audit.FilteredCount"
</li> :pageSize="audit.pageSize"
:hasNextPage="audit.hasNextPage"
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item"> :hasPrevPage="audit.hasPrevPage"
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a> :onGotoPage="audit.gotoPage"
</li> :onNextPage="audit.nextPage"
:onPrevPage="audit.previousPage"
<li :class="{disabled:!audit.hasNextPage}" class="page-item"> />
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @change="audit.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -92,5 +89,4 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import PeerViewModal from "@/components/PeerViewModal.vue";
import PeerEditModal from "../components/PeerEditModal.vue"; import PeerEditModal from "@/components/PeerEditModal.vue";
import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue"; import PeerMultiCreateModal from "@/components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "../components/InterfaceEditModal.vue"; import InterfaceEditModal from "@/components/InterfaceEditModal.vue";
import InterfaceViewModal from "../components/InterfaceViewModal.vue"; import InterfaceViewModal from "@/components/InterfaceViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers"; import {peerStore} from "@/stores/peers";
@@ -210,6 +211,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>
@@ -451,14 +458,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>
@@ -471,26 +483,23 @@ onMounted(async () => {
<hr v-if="interfaces.Count!==0"> <hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3"> <div v-if="interfaces.Count!==0" class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{disabled:peers.pageOffset===0}" class="page-item"> :currentPage="peers.currentPage"
<a class="page-link" @click="peers.previousPage">&laquo;</a> :totalCount="peers.FilteredCount"
</li> :pageSize="peers.pageSize"
:hasNextPage="peers.hasNextPage"
<li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item"> :hasPrevPage="peers.hasPrevPage"
<a class="page-link" @click="peers.gotoPage(page)">{{page}}</a> :onGotoPage="peers.gotoPage"
</li> :onNextPage="peers.nextPage"
:onPrevPage="peers.previousPage"
<li :class="{disabled:!peers.hasNextPage}" class="page-item"> />
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @change="peers.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>

View File

@@ -83,7 +83,9 @@ const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier); console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true; loggingIn.value = true;
console.log(router.currentRoute.value); console.log(router.currentRoute.value);
let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath; const currentUrl = new URL(`${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`, window.location.origin);
currentUrl.hash = router.currentRoute.value.fullPath;
let currentUri = currentUrl.toString();
let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`; let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`;
redirectUrl += "?redirect=true"; redirectUrl += "?redirect=true";
redirectUrl += "&return=" + encodeURIComponent(currentUri); redirectUrl += "&return=" + encodeURIComponent(currentUri);

View File

@@ -6,6 +6,7 @@ import { useI18n } from "vue-i18n";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { peerStore } from "@/stores/peers"; import { peerStore } from "@/stores/peers";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue"; import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import Pagination from "@/components/Pagination.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { humanFileSize } from "@/helpers/utils";
@@ -66,7 +67,6 @@ onMounted(async () => {
await profile.LoadPeers() await profile.LoadPeers()
await profile.LoadStats() await profile.LoadStats()
await profile.LoadInterfaces() await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
}) })
</script> </script>
@@ -80,6 +80,8 @@ onMounted(async () => {
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<h2 class="mt-2">{{ $t('profile.headline') }}</h2> <h2 class="mt-2">{{ $t('profile.headline') }}</h2>
</div> </div>
<div class="col-12 col-lg-3 text-lg-end" v-if="!settings.Setting('SelfProvisioning') || profile.CountInterfaces===0">
</div>
<div class="col-12 col-lg-4 text-lg-end"> <div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline"> <div class="form-group d-inline">
<div class="input-group mb-3"> <div class="input-group mb-3">
@@ -90,8 +92,8 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-lg-3 text-lg-end"> <div class="col-12 col-lg-3 text-lg-end" v-if="settings.Setting('SelfProvisioning') && profile.CountInterfaces>0">
<div class="form-group" v-if="settings.Setting('SelfProvisioning')"> <div class="form-group">
<div class="input-group mb-3"> <div class="input-group mb-3">
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'"> <button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i> <i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
@@ -160,8 +162,7 @@ onMounted(async () => {
</td> </td>
<td v-if="profile.hasStatistics"> <td v-if="profile.hasStatistics">
<div v-if="profile.Statistics(peer.Identifier).IsConnected"> <div v-if="profile.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span> <span class="badge rounded-pill bg-success" :title="$t('profile.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + profile.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small>
<span :title="profile.Statistics(peer.Identifier).LastHandshake">{{ $t('profile.peer-connected') }}</span>
</div> </div>
<div v-else> <div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span> <span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
@@ -174,7 +175,7 @@ onMounted(async () => {
<td class="text-center"> <td class="text-center">
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i <a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i
class="fas fa-eye me-2"></i></a> class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier"><i <a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier" v-if="settings.Setting('SelfProvisioning') && profile.HasInterface(peer.InterfaceIdentifier)"><i
class="fas fa-cog"></i></a> class="fas fa-cog"></i></a>
</td> </td>
</tr> </tr>
@@ -184,36 +185,33 @@ onMounted(async () => {
<hr> <hr>
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{ disabled: profile.pageOffset === 0 }" class="page-item"> :currentPage="profile.currentPage"
<a class="page-link" @click="profile.previousPage">&laquo;</a> :totalCount="profile.FilteredPeerCount"
</li> :pageSize="profile.pageSize"
:hasNextPage="profile.hasNextPage"
<li v-for="page in profile.pages" :key="page" :class="{ active: profile.currentPage === page }" class="page-item"> :hasPrevPage="profile.hasPrevPage"
<a class="page-link" @click="profile.gotoPage(page)">{{ page }}</a> :onGotoPage="profile.gotoPage"
</li> :onNextPage="profile.nextPage"
:onPrevPage="profile.previousPage"
<li :class="{ disabled: !profile.hasNextPage }" class="page-item"> />
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector"> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">
{{ $t('general.pagination.size')}}: {{ $t('general.pagination.size')}}:
</label> </label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @change="profile.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option> <option value="999999999">{{ $t('general.pagination.all') }}</option>
</select> </select>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div></template> </div></template>

View File

@@ -1,8 +1,9 @@
<script setup> <script setup>
import {userStore} from "@/stores/users"; import {userStore} from "@/stores/users";
import {ref, onMounted, computed} 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 Pagination from "@/components/Pagination.vue";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
const users = userStore() const users = userStore()
@@ -165,28 +166,24 @@ onMounted(() => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{disabled:users.pageOffset===0}" class="page-item"> :currentPage="users.currentPage"
<a class="page-link" @click="users.previousPage">&laquo;</a> :totalCount="users.FilteredCount"
</li> :pageSize="users.pageSize"
:hasNextPage="users.hasNextPage"
<li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item"> :hasPrevPage="users.hasPrevPage"
<a class="page-link" @click="users.gotoPage(page)">{{page}}</a> :onGotoPage="users.gotoPage"
</li> :onNextPage="users.nextPage"
:onPrevPage="users.previousPage"
<li :class="{disabled:!users.hasNextPage}" class="page-item"> />
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @change="users.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -197,5 +194,4 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

97
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.7
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.17.0 github.com/coreos/go-oidc/v3 v3.18.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.13
github.com/go-pkgz/routegroup v1.6.0 github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.15.0 github.com/go-webauthn/webauthn v0.17.4
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,88 +22,90 @@ 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.47.0 golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.34.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.40.0 golang.org/x/sys v0.45.0
golang.org/x/text v0.37.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
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.3 gorm.io/driver/sqlserver v1.6.3
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
pgregory.net/rapid v1.3.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.1 // 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.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // 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.4 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.2 // indirect github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // 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.10.0 // 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.6 // 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.7 // 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.9.2 // 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
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.4.0 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.6.0 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect github.com/microsoft/go-mssqldb v1.10.0 // 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 v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.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.67.4 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.20.1 // 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/shopspring/decimal v1.4.0 // indirect
github.com/tinylib/msgp v1.6.4 // 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.4 // 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-20251209150349-8475f28825e9 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.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.67.1 // indirect modernc.org/libc v1.72.3 // 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.40.1 // indirect modernc.org/sqlite v1.50.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

248
go.sum
View File

@@ -1,31 +1,31 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
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.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
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.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.1/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.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
@@ -38,8 +38,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/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.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
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=
@@ -48,47 +48,47 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
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.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
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=
@@ -97,23 +97,23 @@ 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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
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.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A= github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
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.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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=
@@ -121,8 +121,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.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
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=
@@ -130,6 +132,8 @@ 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=
@@ -139,8 +143,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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=
@@ -172,17 +176,17 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594= github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
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.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
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=
@@ -191,22 +195,24 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= 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.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= 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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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=
@@ -230,6 +236,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
@@ -254,8 +262,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -270,18 +278,16 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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=
@@ -299,10 +305,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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=
@@ -310,8 +316,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
@@ -332,8 +338,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -362,23 +368,23 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
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=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= 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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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=
@@ -398,33 +404,35 @@ 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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
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=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/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.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -232,28 +232,26 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{})) slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{}))
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{} var existingSysStat SysStat
var err error
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1 // Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 { if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1 const schemaVersion = 1
sysStat := SysStat{ existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
MigratedAt: time.Now(), if err != nil {
SchemaVersion: schemaVersion, return err
}
if err := r.db.Create(&sysStat).Error; err != nil {
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 // Migration: 1 --> 2
if existingSysStat.SchemaVersion == 1 { if existingSysStat.SchemaVersion == 1 {
const schemaVersion = 2 const schemaVersion = 2
// Preserve existing behavior for installations that had default-peer-creation enabled. // Preserve existing behavior for installations that had default-peer-creation enabled.
if r.cfg.Core.CreateDefaultPeer { if r.cfg.DefaultPeerCreationEnabled() {
err := r.db.Model(&domain.Interface{}). err := r.db.Model(&domain.Interface{}).
Where("type = ?", domain.InterfaceTypeServer). Where("type = ?", domain.InterfaceTypeServer).
Update("create_default_peer", true).Error Update("create_default_peer", true).Error
@@ -262,14 +260,10 @@ func (r *SqlRepo) migrate() error {
} }
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion) slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
} }
sysStat := SysStat{ existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
MigratedAt: time.Now(), if err != nil {
SchemaVersion: schemaVersion, return err
} }
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 // Migration: 2 --> 3
@@ -307,19 +301,45 @@ func (r *SqlRepo) migrate() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err) return fmt.Errorf("failed to migrate to multi-auth: %w", err)
} }
sysStat := SysStat{ existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
MigratedAt: time.Now(), if err != nil {
SchemaVersion: schemaVersion, return err
} }
if err := r.db.Create(&sysStat).Error; err != nil { }
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
// Migration: 3 --> 4
if existingSysStat.SchemaVersion == 3 {
const schemaVersion = 4
cutoff := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// Fix zero created_at timestamps for users. Set the to the last known update timestamp.
err := r.db.Model(&domain.User{}).Where("created_at < ?", cutoff).
Update("created_at", gorm.Expr("updated_at")).Error
if err != nil {
slog.Warn("failed to fix zero created_at for users", "error", err)
}
slog.Debug("fixed zero created_at timestamps for users", "schema_version", schemaVersion)
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
} }
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
} }
return nil return nil
} }
func (r *SqlRepo) addMigration(schemaVersion uint64) (SysStat, error) {
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return SysStat{}, fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
return sysStat, nil
}
// region interfaces // region interfaces
// GetInterface returns the interface with the given id. // GetInterface returns the interface with the given id.
@@ -482,7 +502,7 @@ func (r *SqlRepo) getOrCreateInterface(
Identifier: id, Identifier: id,
} }
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -691,7 +711,7 @@ func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id do
Identifier: id, Identifier: id,
} }
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,168 @@
package adapters
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
return db
}
func TestUpsertUser_SetsCreatedAtWhenZero(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
// CreatedAt is zero
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.False(t, user.CreatedAt.IsZero(), "CreatedAt should be set when it was zero")
assert.Equal(t, ui.UserId(), user.UpdatedBy, "UpdatedBy should be set when it was empty")
assert.WithinDuration(t, user.UpdatedAt, user.CreatedAt, time.Second,
"CreatedAt should be close to UpdatedAt for new user")
}
func TestUpsertUser_PreservesExistingCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
BaseModel: domain.BaseModel{
CreatedAt: originalTime,
CreatedBy: "original-creator",
},
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.Equal(t, originalTime, user.CreatedAt, "CreatedAt should not be overwritten")
assert.Equal(t, "original-creator", user.CreatedBy, "CreatedBy should not be overwritten")
}
func TestSaveUser_NewUserGetsCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
before := time.Now().Add(-time.Second)
err := repo.SaveUser(ctx, "new-user", func(u *domain.User) (*domain.User, error) {
u.Email = "new@example.com"
return u, nil
})
require.NoError(t, err)
var saved domain.User
require.NoError(t, db.First(&saved, "identifier = ?", "new-user").Error)
assert.False(t, saved.CreatedAt.IsZero(), "CreatedAt should not be zero")
assert.True(t, saved.CreatedAt.After(before), "CreatedAt should be recent")
assert.NotEmpty(t, saved.CreatedBy, "CreatedBy should be set")
}
func TestMigration_FixesZeroCreatedAt(t *testing.T) {
db := newTestDB(t)
// Manually create tables and seed schema version 3
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
// Insert schema versions 1, 2, 3 so migration starts at 3
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
// Insert a user with zero created_at but valid updated_at
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"zero-user", "zero@example.com", time.Time{}, updatedAt,
).Error)
// Run migration
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
// Verify created_at was backfilled from updated_at
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "zero-user").Error)
assert.Equal(t, updatedAt, user.CreatedAt, "created_at should be backfilled from updated_at")
// Verify schema version advanced to 4
var latest SysStat
require.NoError(t, db.Order("schema_version DESC").First(&latest).Error)
assert.Equal(t, uint64(4), latest.SchemaVersion)
}
func TestMigration_DoesNotTouchValidCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
createdAt := time.Date(2024, 3, 1, 8, 0, 0, 0, time.UTC)
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"valid-user", "valid@example.com", createdAt, updatedAt,
).Error)
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "valid-user").Error)
assert.Equal(t, createdAt, user.CreatedAt, "valid created_at should not be modified")
}

View File

@@ -0,0 +1,73 @@
package adapters
import (
"context"
"reflect"
"testing"
"github.com/glebarez/sqlite"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func init() {
schema.RegisterSerializer("encstr", dummySerializer{})
}
type dummySerializer struct{}
func (dummySerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) error {
return nil
}
func (dummySerializer) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue any) (any, error) {
if fieldValue == nil {
return nil, nil
}
if v, ok := fieldValue.(string); ok {
return v, nil
}
if v, ok := fieldValue.(domain.PreSharedKey); ok {
return string(v), nil
}
return fieldValue, nil
}
func TestSqlRepo_SaveInterface_Simple(t *testing.T) {
// Initialize in-memory database
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
// Migrate only what's needed for this test (avoids Peer and its encstr serializer)
require.NoError(t, db.AutoMigrate(&domain.Interface{}, &domain.Cidr{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
ifaceId := domain.InterfaceIdentifier("wg0")
// 1. Create an interface with one address
addr, _ := domain.CidrFromString("10.0.0.1/24")
initialIface := &domain.Interface{
Identifier: ifaceId,
Addresses: []domain.Cidr{addr},
}
require.NoError(t, db.Create(initialIface).Error)
// 2. Perform a "partial" update using SaveInterface (this is the buggy path)
err = repo.SaveInterface(ctx, ifaceId, func(in *domain.Interface) (*domain.Interface, error) {
in.DisplayName = "New Name"
return in, nil
})
require.NoError(t, err)
// 3. Verify that the address was NOT deleted
var finalIface domain.Interface
require.NoError(t, db.Preload("Addresses").First(&finalIface, "identifier = ?", ifaceId).Error)
require.Equal(t, "New Name", finalIface.DisplayName)
require.Len(t, finalIface.Addresses, 1, "Address list should still have 1 entry!")
require.Equal(t, "10.0.0.1/24", finalIface.Addresses[0].Cidr)
}

View File

@@ -30,7 +30,7 @@ type MetricsServer struct {
// Wireguard metrics labels // Wireguard metrics labels
var ( var (
ifaceLabels = []string{"interface"} ifaceLabels = []string{"interface"}
peerLabels = []string{"interface", "addresses", "id", "name"} peerLabels = []string{"interface", "addresses", "id", "name", "user"}
) )
// NewMetricsServer returns a new prometheus server // NewMetricsServer returns a new prometheus server
@@ -126,6 +126,7 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
peer.Interface.AddressStr(), peer.Interface.AddressStr(),
string(status.PeerId), string(status.PeerId),
peer.DisplayName, peer.DisplayName,
string(peer.UserIdentifier),
} }
if status.LastHandshake != nil { if status.LastHandshake != nil {

View File

@@ -140,7 +140,7 @@ func (c *PfsenseController) GetInterface(ctx context.Context, id domain.Interfac
} }
tunnelId := wgReply.Data[0].GetString("id") tunnelId := wgReply.Data[0].GetString("id")
// Query the specific tunnel endpoint to get full details including addresses // Query the specific tunnel endpoint to get full details including addresses
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id} // Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
if tunnelId != "" { if tunnelId != "" {
@@ -227,7 +227,7 @@ func (c *PfsenseController) extractAddresses(
if addrObj, ok := addrItem.(map[string]any); ok { if addrObj, ok := addrItem.(map[string]any); ok {
address := "" address := ""
mask := 0 mask := 0
// Extract address // Extract address
if addrVal, ok := addrObj["address"]; ok { if addrVal, ok := addrObj["address"]; ok {
if addrStr, ok := addrVal.(string); ok { if addrStr, ok := addrVal.(string); ok {
@@ -236,7 +236,7 @@ func (c *PfsenseController) extractAddresses(
address = fmt.Sprintf("%v", addrVal) address = fmt.Sprintf("%v", addrVal)
} }
} }
// Extract mask // Extract mask
if maskVal, ok := addrObj["mask"]; ok { if maskVal, ok := addrObj["mask"]; ok {
if maskInt, ok := maskVal.(int); ok { if maskInt, ok := maskVal.(int); ok {
@@ -249,7 +249,7 @@ func (c *PfsenseController) extractAddresses(
} }
} }
} }
// Convert to CIDR format // Convert to CIDR format
if address != "" && mask > 0 { if address != "" && mask > 0 {
cidrStr := fmt.Sprintf("%s/%d", address, mask) cidrStr := fmt.Sprintf("%s/%d", address, mask)
@@ -286,11 +286,11 @@ func (c *PfsenseController) extractAddresses(
// Each object has "address" and "mask" fields (similar to allowedips structure) // Each object has "address" and "mask" fields (similar to allowedips structure)
func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr { func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr {
addresses := make([]domain.Cidr, 0, len(addressArray)) addresses := make([]domain.Cidr, 0, len(addressArray))
for _, addrObj := range addressArray { for _, addrObj := range addressArray {
address := addrObj.GetString("address") address := addrObj.GetString("address")
mask := addrObj.GetInt("mask") mask := addrObj.GetInt("mask")
if address != "" && mask > 0 { if address != "" && mask > 0 {
cidrStr := fmt.Sprintf("%s/%d", address, mask) cidrStr := fmt.Sprintf("%s/%d", address, mask)
if cidr, err := domain.CidrFromString(cidrStr); err == nil { if cidr, err := domain.CidrFromString(cidrStr); err == nil {
@@ -303,7 +303,7 @@ func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJso
} }
} }
} }
return addresses return addresses
} }
@@ -405,7 +405,7 @@ func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.Interf
peerTun = peer.GetString("tunnel") peerTun = peer.GetString("tunnel")
} }
} }
// Only include peers that match the requested interface name // Only include peers that match the requested interface name
if peerTun != string(deviceId) { if peerTun != string(deviceId) {
if c.cfg.Debug { if c.cfg.Debug {
@@ -425,7 +425,7 @@ func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.Interf
} }
peers = append(peers, peerModel) peers = append(peers, peerModel)
} }
if c.cfg.Debug { if c.cfg.Debug {
slog.Debug("filtered peers for interface", slog.Debug("filtered peers for interface",
"interface", deviceId, "interface", deviceId,
@@ -465,7 +465,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject
if itemObj, ok := item.(map[string]any); ok { if itemObj, ok := item.(map[string]any); ok {
address := "" address := ""
mask := 0 mask := 0
// Extract address // Extract address
if addrVal, ok := itemObj["address"]; ok { if addrVal, ok := itemObj["address"]; ok {
if addrStr, ok := addrVal.(string); ok { if addrStr, ok := addrVal.(string); ok {
@@ -474,7 +474,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject
address = fmt.Sprintf("%v", addrVal) address = fmt.Sprintf("%v", addrVal)
} }
} }
// Extract mask // Extract mask
if maskVal, ok := itemObj["mask"]; ok { if maskVal, ok := itemObj["mask"]; ok {
if maskInt, ok := maskVal.(int); ok { if maskInt, ok := maskVal.(int); ok {
@@ -487,7 +487,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject
} }
} }
} }
// Convert to CIDR format (e.g., "10.1.2.3/32") // Convert to CIDR format (e.g., "10.1.2.3/32")
if address != "" && mask > 0 { if address != "" && mask > 0 {
cidrStr := fmt.Sprintf("%s/%d", address, mask) cidrStr := fmt.Sprintf("%s/%d", address, mask)
@@ -502,7 +502,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr) allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
} }
} }
// Fallback to string parsing if array parsing didn't work // Fallback to string parsing if array parsing didn't work
if len(allowedAddresses) == 0 { if len(allowedAddresses) == 0 {
allowedIPsStr := peer.GetString("allowedips") allowedIPsStr := peer.GetString("allowedips")
@@ -516,7 +516,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject
endpoint := peer.GetString("endpoint") endpoint := peer.GetString("endpoint")
port := peer.GetString("port") port := peer.GetString("port")
// Combine endpoint and port if both are available // Combine endpoint and port if both are available
if endpoint != "" && port != "" { if endpoint != "" && port != "" {
// Check if endpoint already contains a port // Check if endpoint already contains a port
@@ -617,18 +617,22 @@ func (c *PfsenseController) SaveInterface(
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
physicalInterface, err := c.getOrCreateInterface(ctx, id) physicalInterface, err := c.getInterface(ctx, id)
if err != nil { if err != nil {
return err return err
} }
deviceId := "" if physicalInterface == nil {
if physicalInterface.GetExtras() != nil { physicalInterface = &domain.PhysicalInterface{
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok { Identifier: id,
deviceId = extras.Id ImportSource: domain.ControllerTypePfsense,
DeviceType: domain.ControllerTypePfsense,
} }
physicalInterface.SetExtras(domain.PfsenseInterfaceExtras{})
} }
deviceId := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras).Id
if updateFunc != nil { if updateFunc != nil {
physicalInterface, err = updateFunc(physicalInterface) physicalInterface, err = updateFunc(physicalInterface)
if err != nil { if err != nil {
@@ -643,14 +647,14 @@ func (c *PfsenseController) SaveInterface(
} }
} }
if err := c.updateInterface(ctx, physicalInterface); err != nil { if err := c.createOrUpdateInterface(ctx, physicalInterface); err != nil {
return err return err
} }
return nil return nil
} }
func (c *PfsenseController) getOrCreateInterface( func (c *PfsenseController) getInterface(
ctx context.Context, ctx context.Context,
id domain.InterfaceIdentifier, id domain.InterfaceIdentifier,
) (*domain.PhysicalInterface, error) { ) (*domain.PhysicalInterface, error) {
@@ -659,50 +663,84 @@ func (c *PfsenseController) getOrCreateInterface(
"name": string(id), "name": string(id),
}, },
}) })
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return c.loadInterfaceData(ctx, wgReply.Data[0]) return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error)
} }
if len(wgReply.Data) == 0 {
// create a new tunnel if it does not exist return nil, nil
// 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 c.loadInterfaceData(ctx, wgReply.Data[0])
return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error)
} }
func (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error { type pfsenseWireGuardAddress struct {
Address string `json:"address"`
Mask int `json:"mask"`
Descr string `json:"descr"`
}
func cidrToPfsense(cidr *domain.Cidr) pfsenseWireGuardAddress {
return pfsenseWireGuardAddress{
Address: cidr.Addr,
Mask: cidr.NetLength,
// supported in pfsense, but not in wg-portal GUI
Descr: "",
}
}
func (c *PfsenseController) createOrUpdateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
extras := pi.GetExtras().(domain.PfsenseInterfaceExtras) extras := pi.GetExtras().(domain.PfsenseInterfaceExtras)
interfaceId := extras.Id interfaceId := extras.Id
payload := lowlevel.GenericJsonObject{ payload := lowlevel.GenericJsonObject{
"name": string(pi.Identifier), "name": string(pi.Identifier),
"description": extras.Comment, "descr": extras.Comment,
"mtu": strconv.Itoa(pi.Mtu), "mtu": pi.Mtu,
"listenport": strconv.Itoa(pi.ListenPort), "listenport": strconv.Itoa(pi.ListenPort),
"privatekey": pi.KeyPair.PrivateKey, "privatekey": pi.KeyPair.PrivateKey,
"disabled": strconv.FormatBool(!pi.DeviceUp), "disabled": strconv.FormatBool(!pi.DeviceUp),
} }
// Add addresses if present addresses := make([]pfsenseWireGuardAddress, 0, len(pi.Addresses))
if len(pi.Addresses) > 0 { for _, addr := range pi.Addresses {
addresses := make([]string, 0, len(pi.Addresses)) addresses = append(addresses, cidrToPfsense(&addr))
for _, addr := range pi.Addresses { }
addresses = append(addresses, addr.String()) payload["addresses"] = addresses
if interfaceId == "" {
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", payload)
if createReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to create interface %s: %v", pi.Identifier, createReply.Error)
} }
payload["addresses"] = strings.Join(addresses, ",") // Capture the newly-assigned ID so callers see it
if newId := createReply.Data.GetString("id"); newId != "" {
extras.Id = newId
pi.SetExtras(extras)
}
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after creating interface %s: %v",
pi.Identifier, applyReply.Error)
}
return nil
} }
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id} interfaceIdInt, err := strconv.Atoi(interfaceId)
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload) if err != nil {
return fmt.Errorf("invalid pfSense interface id %q for %s: %w", interfaceId, pi.Identifier, err)
}
payload["id"] = interfaceIdInt
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel", payload)
if wgReply.Status != lowlevel.PfsenseApiStatusOk { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error) return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
} }
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after updating interface %s: %v",
pi.Identifier, applyReply.Error)
}
return nil return nil
} }
@@ -726,8 +764,10 @@ func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.Inter
} }
interfaceId := wgReply.Data[0].GetString("id") interfaceId := wgReply.Data[0].GetString("id")
// Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id} // Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}&apply=true
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId) deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{"id": interfaceId, "apply": "true"},
})
if deleteReply.Status != lowlevel.PfsenseApiStatusOk { if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error) return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
} }
@@ -746,18 +786,22 @@ func (c *PfsenseController) SavePeer(
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id) physicalPeer, err := c.getPeer(ctx, deviceId, id)
if err != nil { if err != nil {
return err return err
} }
peerId := "" if physicalPeer == nil {
if physicalPeer.GetExtras() != nil { physicalPeer = &domain.PhysicalPeer{
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok { Identifier: id,
peerId = extras.Id KeyPair: domain.KeyPair{PublicKey: string(id)},
ImportSource: domain.ControllerTypePfsense,
} }
physicalPeer.SetExtras(domain.PfsensePeerExtras{})
} }
peerId := physicalPeer.GetExtras().(domain.PfsensePeerExtras).Id
physicalPeer, err = updateFunc(physicalPeer) physicalPeer, err = updateFunc(physicalPeer)
if err != nil { if err != nil {
return err return err
@@ -770,14 +814,14 @@ func (c *PfsenseController) SavePeer(
} }
} }
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil { if err := c.createOrUpdatePeer(ctx, deviceId, physicalPeer); err != nil {
return err return err
} }
return nil return nil
} }
func (c *PfsenseController) getOrCreatePeer( func (c *PfsenseController) getPeer(
ctx context.Context, ctx context.Context,
deviceId domain.InterfaceIdentifier, deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier, id domain.PeerIdentifier,
@@ -787,40 +831,25 @@ func (c *PfsenseController) getOrCreatePeer(
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{ wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{ Filters: map[string]string{
"publickey": string(id), "publickey": string(id),
"tun": string(deviceId), // Use "tun" field name as that's what the API uses "tun": string(deviceId), // Use "tun" field name as that's what the API uses
}, },
}) })
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId) return nil, fmt.Errorf("failed to query peer %s for interface %s: %v", id, deviceId, wgReply.Error)
existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0]) }
if err != nil { if len(wgReply.Data) == 0 {
return nil, err return nil, nil
}
return &existingPeer, nil
} }
// create a new peer if it does not exist slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId)
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular) existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0])
slog.Debug("creating new pfSense peer", "peer", id, "interface", deviceId) if err != nil {
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", lowlevel.GenericJsonObject{ return nil, err
"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 &existingPeer, nil
return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error)
} }
func (c *PfsenseController) updatePeer( func (c *PfsenseController) createOrUpdatePeer(
ctx context.Context, ctx context.Context,
deviceId domain.InterfaceIdentifier, deviceId domain.InterfaceIdentifier,
pp *domain.PhysicalPeer, pp *domain.PhysicalPeer,
@@ -828,36 +857,74 @@ func (c *PfsenseController) updatePeer(
extras := pp.GetExtras().(domain.PfsensePeerExtras) extras := pp.GetExtras().(domain.PfsensePeerExtras)
peerId := extras.Id 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{ payload := lowlevel.GenericJsonObject{
"name": extras.Name, "tun": string(deviceId),
"description": extras.Comment, "descr": extras.Name,
"presharedkey": string(pp.PresharedKey), "presharedkey": string(pp.PresharedKey),
"publickey": pp.KeyPair.PublicKey, "publickey": pp.KeyPair.PublicKey,
"privatekey": pp.KeyPair.PrivateKey, "persistentkeepalive": pp.PersistentKeepalive,
"persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive), "enabled": !extras.Disabled,
"disabled": strconv.FormatBool(extras.Disabled),
"allowedips": allowedIPsStr,
} }
if pp.Endpoint != "" { if pp.Endpoint != "" {
payload["endpoint"] = pp.Endpoint payload["endpoint"] = pp.Endpoint
} }
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id} allowedIps := make([]pfsenseWireGuardAddress, 0, len(pp.AllowedIPs))
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload) for _, addr := range pp.AllowedIPs {
allowedIps = append(allowedIps, cidrToPfsense(&addr))
}
payload["allowedips"] = allowedIps
if peerId == "" {
slog.Debug("creating new pfSense peer",
"peer", pp.Identifier,
"interface", deviceId,
"allowed-ips", domain.CidrsToString(pp.AllowedIPs),
"disabled", extras.Disabled)
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", payload)
if createReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to create peer %s for interface %s: %v",
pp.Identifier, deviceId, createReply.Error)
}
if newId := createReply.Data.GetString("id"); newId != "" {
extras.Id = newId
pp.SetExtras(extras)
}
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after creating peer %s on interface %s: %v",
pp.Identifier, deviceId, applyReply.Error)
}
slog.Debug("successfully created pfSense peer", "peer", pp.Identifier, "interface", deviceId)
return nil
}
slog.Debug("updating pfSense peer",
"peer", pp.Identifier,
"interface", deviceId,
"allowed-ips", domain.CidrsToString(pp.AllowedIPs),
"allowed-ips-count", len(pp.AllowedIPs),
"disabled", extras.Disabled)
peerIdInt, err := strconv.Atoi(peerId)
if err != nil {
return fmt.Errorf("invalid pfSense peer id %q for %s: %w", peerId, pp.Identifier, err)
}
payload["id"] = peerIdInt
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer", payload)
if wgReply.Status != lowlevel.PfsenseApiStatusOk { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error) return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
} }
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after updating peer %s on interface %s: %v",
pp.Identifier, deviceId, applyReply.Error)
}
if extras.Disabled { if extras.Disabled {
slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId) slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId)
} else { } else {
@@ -882,7 +949,7 @@ func (c *PfsenseController) DeletePeer(
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{ wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{ Filters: map[string]string{
"publickey": string(id), "publickey": string(id),
"tun": string(deviceId), // Use "tun" field name as that's what the API uses "tun": string(deviceId), // Use "tun" field name as that's what the API uses
}, },
}) })
if wgReply.Status != lowlevel.PfsenseApiStatusOk { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
@@ -893,8 +960,10 @@ func (c *PfsenseController) DeletePeer(
} }
peerId := wgReply.Data[0].GetString("id") peerId := wgReply.Data[0].GetString("id")
// Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id} // Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}&apply=true
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId) deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{"id": peerId, "apply": "true"},
})
if deleteReply.Status != lowlevel.PfsenseApiStatusOk { if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error) return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error)
} }
@@ -976,4 +1045,3 @@ func (c *PfsenseController) PingAddresses(
} }
// endregion statistics-related // endregion statistics-related

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

@@ -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"
@@ -18,6 +19,7 @@ type InterfaceServiceInterfaceManager interface {
DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
PrepareInterface(ctx context.Context) (*domain.Interface, error) PrepareInterface(ctx context.Context) (*domain.Interface, error)
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error
} }
type InterfaceServiceConfigFileManager interface { type InterfaceServiceConfigFileManager interface {
@@ -89,3 +91,10 @@ func (i InterfaceService) PersistInterfaceConfig(ctx context.Context, id domain.
func (i InterfaceService) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error { func (i InterfaceService) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
return i.interfaces.ApplyPeerDefaults(ctx, in) return i.interfaces.ApplyPeerDefaults(ctx, in)
} }
func (i InterfaceService) CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error {
if !i.cfg.DefaultPeerCreationEnabled() {
return fmt.Errorf("default peer creation is not enabled")
}
return i.interfaces.CreateDefaultPeers(ctx, id)
}

View File

@@ -54,6 +54,17 @@ func (u UserService) GetAllUsers(ctx context.Context) ([]domain.User, error) {
} }
func (u UserService) UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) { func (u UserService) UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
sessionUser := domain.GetUserInfo(ctx)
currentUser, err := u.users.GetUser(ctx, user.Identifier)
if err != nil {
return nil, err
}
// if this endpoint is used by non-admins, make sure that the user can only modify a specific subset of attributes
if !sessionUser.IsAdmin {
user.CopyAdminAttributes(currentUser, u.cfg.Advanced.ApiAdminOnly)
}
return u.users.UpdateUser(ctx, user) return u.users.UpdateUser(ctx, user)
} }

View File

@@ -24,9 +24,11 @@ type AuthenticationService interface {
// PlainLogin authenticates a user with a username and password. // PlainLogin authenticates a user with a username and password.
PlainLogin(ctx context.Context, username, password string) (*domain.User, error) PlainLogin(ctx context.Context, username, password string) (*domain.User, error)
// OauthLoginStep1 initiates the OAuth login flow. // OauthLoginStep1 initiates the OAuth login flow.
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce, codeVerifier string, err error)
// OauthLoginStep2 completes the OAuth login flow and logins the user in. // OauthLoginStep2 completes the OAuth login flow and logins the user in.
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) OauthLoginStep2(ctx context.Context, providerId, nonce, code, codeVerifier string) (*domain.User, string, error)
// OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported.
OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool)
} }
type WebAuthnService interface { type WebAuthnService interface {
@@ -200,9 +202,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
provider := request.Path(r, "provider") provider := request.Path(r, "provider")
var returnUrl *url.URL var returnUrl *url.URL
var returnParams string redirectToReturn := func(loginState string) {
redirectToReturn := func() { respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
} }
if returnTo != "" { if returnTo != "" {
@@ -211,21 +212,18 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"}) model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return return
} }
if u, err := url.Parse(returnTo); err == nil { u, err := url.Parse(returnTo)
returnUrl = u if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return
} }
queryParams := returnUrl.Query() returnUrl = u
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
} }
if currentSession.LoggedIn { if currentSession.LoggedIn {
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "already logged in"}) model.Error{Code: http.StatusBadRequest, Message: "already logged in"})
@@ -233,12 +231,12 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
return return
} }
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider) authCodeUrl, state, nonce, codeVerifier, err := e.authService.OauthLoginStep1(context.Background(), provider)
if err != nil { if err != nil {
slog.Debug("failed to create oauth auth code URL", slog.Debug("failed to create oauth auth code URL",
"provider", provider, "error", err) "provider", provider, "error", err)
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusInternalServerError, respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
@@ -249,6 +247,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
authSession := e.session.GetData(r.Context()) authSession := e.session.GetData(r.Context())
authSession.OauthState = state authSession.OauthState = state
authSession.OauthNonce = nonce authSession.OauthNonce = nonce
authSession.OauthCodeVerifier = codeVerifier
authSession.OauthProvider = provider authSession.OauthProvider = provider
authSession.OauthReturnTo = returnTo authSession.OauthReturnTo = returnTo
e.session.SetData(r.Context(), authSession) e.session.SetData(r.Context(), authSession)
@@ -277,27 +276,19 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
var returnUrl *url.URL var returnUrl *url.URL
var returnParams string redirectToReturn := func(loginState string) {
redirectToReturn := func() { respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
} }
if currentSession.OauthReturnTo != "" { if currentSession.OauthReturnTo != "" && e.isValidReturnUrl(currentSession.OauthReturnTo) {
if u, err := url.Parse(currentSession.OauthReturnTo); err == nil { if u, err := url.Parse(currentSession.OauthReturnTo); err == nil {
returnUrl = u returnUrl = u
} }
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
} }
if currentSession.LoggedIn { if currentSession.LoggedIn {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"}) respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"})
} }
@@ -311,8 +302,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if provider != currentSession.OauthProvider { if provider != currentSession.OauthProvider {
slog.Debug("invalid oauth provider in callback", slog.Debug("invalid oauth provider in callback",
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState) "expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"}) model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"})
@@ -322,8 +313,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if oauthState != currentSession.OauthState { if oauthState != currentSession.OauthState {
slog.Debug("invalid oauth state in callback", slog.Debug("invalid oauth state in callback",
"expected", currentSession.OauthState, "got", oauthState, "provider", provider) "expected", currentSession.OauthState, "got", oauthState, "provider", provider)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"}) model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"})
@@ -332,14 +323,14 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
} }
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode) oauthCode, currentSession.OauthCodeVerifier)
cancel() cancel()
if err != nil { if err != nil {
slog.Debug("failed to process oauth code", slog.Debug("failed to process oauth code",
"provider", provider, "state", oauthState, "error", err) "provider", provider, "state", oauthState, "error", err)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusUnauthorized, respond.JSON(w, http.StatusUnauthorized,
model.Error{Code: http.StatusUnauthorized, Message: err.Error()}) model.Error{Code: http.StatusUnauthorized, Message: err.Error()})
@@ -347,20 +338,17 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, provider, idTokenHint)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusOK, user) respond.JSON(w, http.StatusOK, user)
} }
} }
} }
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) { func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, oauthProvider, idTokenHint string) {
// start a fresh session // start a fresh session
e.session.DestroyData(r.Context()) e.session.DestroyData(r.Context())
@@ -375,8 +363,10 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
currentSession.OauthState = "" currentSession.OauthState = ""
currentSession.OauthNonce = "" currentSession.OauthNonce = ""
currentSession.OauthProvider = "" currentSession.OauthCodeVerifier = ""
currentSession.OauthProvider = oauthProvider
currentSession.OauthReturnTo = "" currentSession.OauthReturnTo = ""
currentSession.OauthIdToken = idTokenHint
e.session.SetData(r.Context(), currentSession) e.session.SetData(r.Context(), currentSession)
} }
@@ -419,7 +409,7 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, user) respond.JSON(w, http.StatusOK, user)
} }
@@ -431,31 +421,102 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
// @Tags Authentication // @Tags Authentication
// @Summary Get all available external login providers. // @Summary Get all available external login providers.
// @Produce json // @Produce json
// @Success 200 {object} model.Error // @Success 200 {object} model.LogoutResponse
// @Router /auth/logout [post] // @Router /auth/logout [post]
func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc { func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
if !currentSession.LoggedIn { // Not logged in if !currentSession.LoggedIn { // Not logged in
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "not logged in"}) respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "not logged in"})
return return
} }
postLogoutRedirectUri := e.frontendUrl("/login")
var redirectUrl *string
if currentSession.OauthProvider != "" {
if idpLogoutUrl, ok := e.authService.OauthProviderLogoutUrl(currentSession.OauthProvider,
currentSession.OauthIdToken, postLogoutRedirectUri); ok {
redirectUrl = &idpLogoutUrl
}
}
e.session.DestroyData(r.Context()) e.session.DestroyData(r.Context())
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "logout ok"}) respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "logout ok", RedirectUrl: redirectUrl})
} }
} }
// 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 return false
} }
returnUrlParsed, err := url.Parse(returnUrl)
if err != nil {
return false
}
if returnUrlParsed.Scheme != expectedUrl.Scheme || returnUrlParsed.Host != expectedUrl.Host {
return false
}
if e.cfg.Web.BasePath != "" {
expectedPath := e.cfg.Web.BasePath + "/app"
if returnUrlParsed.Path != expectedPath && !strings.HasPrefix(returnUrlParsed.Path, expectedPath+"/") {
return false
}
}
return true return true
} }
func (e AuthEndpoint) frontendUrl(route string) string {
frontendUrl := e.cfg.Web.ExternalUrl + e.cfg.Web.BasePath + "/app/"
if route != "" {
frontendUrl += "#" + route
}
return frontendUrl
}
func (e AuthEndpoint) returnUrlWithLoginState(returnUrl *url.URL, loginState string) string {
if returnUrl == nil {
frontendURL, err := url.Parse(e.frontendUrl("/login"))
if err != nil {
return e.frontendUrl("/login")
}
returnUrl = frontendURL
}
redirectUrl := *returnUrl
if redirectUrl.Fragment != "" {
fragmentPath := redirectUrl.Fragment
fragmentQuery := ""
if queryStart := strings.Index(fragmentPath, "?"); queryStart >= 0 {
fragmentQuery = fragmentPath[queryStart+1:]
fragmentPath = fragmentPath[:queryStart]
}
queryParams, err := url.ParseQuery(fragmentQuery)
if err != nil {
queryParams = url.Values{}
}
queryParams.Set("wgLoginState", loginState)
redirectUrl.Fragment = fragmentPath + "?" + queryParams.Encode()
return redirectUrl.String()
}
queryParams := redirectUrl.Query()
queryParams.Set("wgLoginState", loginState)
redirectUrl.RawQuery = queryParams.Encode()
return redirectUrl.String()
}
// handleWebAuthnCredentialsGet returns a gorm Handler function. // handleWebAuthnCredentialsGet returns a gorm Handler function.
// //
// @ID auth_handleWebAuthnCredentialsGet // @ID auth_handleWebAuthnCredentialsGet
@@ -684,7 +745,7 @@ func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }

View File

@@ -0,0 +1,114 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/h44z/wg-portal/internal/config"
)
type testSession struct {
data SessionData
}
func (s *testSession) SetData(_ context.Context, val SessionData) {
s.data = val
}
func (s *testSession) GetData(_ context.Context) SessionData {
return s.data
}
func (s *testSession) DestroyData(_ context.Context) {
s.data = SessionData{}
}
func newBasePathAuthEndpoint(session Session) AuthEndpoint {
return AuthEndpoint{
cfg: &config.Config{
Web: config.WebConfig{
ExternalUrl: "https://wg.example.com",
BasePath: "/subpath",
},
},
session: session,
}
}
func TestAuthEndpointIsValidReturnUrlRequiresBasePathApp(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
valid := []string{
"https://wg.example.com/subpath/app/#/login",
"https://wg.example.com/subpath/app/#/login?all=true",
"https://wg.example.com/subpath/app/?beforeHash=true#/login",
}
for _, returnURL := range valid {
if !ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be valid: %s", returnURL)
}
}
invalid := []string{
"https://wg.example.com/#/login",
"https://wg.example.com/subpath/#/login",
"https://other.example.com/subpath/app/#/login",
}
for _, returnURL := range invalid {
if ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be invalid: %s", returnURL)
}
}
}
func TestAuthEndpointOauthCallbackRedirectsToBasePathHashRoute(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointReturnUrlWithLoginStatePreservesHashQuery(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login?all=true",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?all=true&wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
if got, want := ep.frontendUrl("/login"), "https://wg.example.com/subpath/app/#/login"; got != want {
t.Fatalf("expected frontend URL %q, got %q", want, got)
}
}

View File

@@ -145,7 +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, CreateDefaultPeer: e.cfg.DefaultPeerCreationEnabled(),
}) })
} }
} }

View File

@@ -33,6 +33,8 @@ type InterfaceService interface {
PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error
// ApplyPeerDefaults applies the peer defaults to all peers of the given interface. // ApplyPeerDefaults applies the peer defaults to all peers of the given interface.
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
// CreateDefaultPeers creates default peers for all existing users on the given interface.
CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error
} }
type InterfaceEndpoint struct { type InterfaceEndpoint struct {
@@ -73,6 +75,7 @@ func (e InterfaceEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /config/{id}", e.handleConfigGet()) apiGroup.HandleFunc("GET /config/{id}", e.handleConfigGet())
apiGroup.HandleFunc("POST /{id}/save-config", e.handleSaveConfigPost()) apiGroup.HandleFunc("POST /{id}/save-config", e.handleSaveConfigPost())
apiGroup.HandleFunc("POST /{id}/apply-peer-defaults", e.handleApplyPeerDefaultsPost()) apiGroup.HandleFunc("POST /{id}/apply-peer-defaults", e.handleApplyPeerDefaultsPost())
apiGroup.HandleFunc("POST /{id}/create-default-peers", e.handleCreateDefaultPeersPost())
apiGroup.HandleFunc("GET /peers/{id}", e.handlePeersGet()) apiGroup.HandleFunc("GET /peers/{id}", e.handlePeersGet())
} }
@@ -421,3 +424,34 @@ func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc {
respond.Status(w, http.StatusNoContent) respond.Status(w, http.StatusNoContent)
} }
} }
// handleCreateDefaultPeersPost returns a gorm Handler function.
//
// @ID interfaces_handleCreateDefaultPeersPost
// @Tags Interface
// @Summary Create default peers for all existing users on the given interface.
// @Produce json
// @Param id path string true "The interface identifier"
// @Success 204 "No content if creating the default peers was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /interface/{id}/create-default-peers [post]
func (e InterfaceEndpoint) handleCreateDefaultPeersPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := Base64UrlDecode(request.Path(r, "id"))
if id == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
if err := e.interfaceService.CreateDefaultPeers(r.Context(), domain.InterfaceIdentifier(id)); 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,225 @@
package handlers
import (
"context"
"net/http"
"net/url"
"strings"
"sync"
"time"
"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"
)
const (
websocketPeerUserIdentifierCacheTTL = 90 * time.Second
websocketPeerUserIdentifierCacheCleanupInterval = websocketPeerUserIdentifierCacheTTL * 2
)
type WebsocketEventBus interface {
Subscribe(topic string, fn any) error
Unsubscribe(topic string, fn any) error
}
type WebsocketPeerService interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
}
type WebsocketEndpoint struct {
authenticator Authenticator
bus WebsocketEventBus
peerService WebsocketPeerService
upgrader websocket.Upgrader
ownershipCache map[domain.PeerIdentifier]peerUserIdentifierCacheEntry
ownershipCacheMux sync.Mutex
}
func NewWebsocketEndpoint(
cfg *config.Config,
auth Authenticator,
bus WebsocketEventBus,
peerService WebsocketPeerService,
) *WebsocketEndpoint {
return &WebsocketEndpoint{
authenticator: auth,
bus: bus,
peerService: peerService,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return matchOrigin(cfg.Web.ExternalUrl, r.Header.Get("Origin"))
},
},
ownershipCache: make(map[domain.PeerIdentifier]peerUserIdentifierCacheEntry),
ownershipCacheMux: sync.Mutex{},
}
}
func (e *WebsocketEndpoint) GetName() string {
return "WebsocketEndpoint"
}
func (e *WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) {
g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket())
}
// StartBackgroundJobs starts background jobs like the expired peers check.
// This method is non-blocking.
func (e *WebsocketEndpoint) StartBackgroundJobs(ctx context.Context) {
go e.startOwnerCacheCleanup(ctx)
}
// 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
}
// peerUserIdentifierCacheEntry is a cache entry object that reduces database load when checking peer ownership.
type peerUserIdentifierCacheEntry struct {
userIdentifier domain.UserIdentifier
expiresAt time.Time
}
func (e *WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userInfo := domain.GetUserInfo(r.Context())
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) {
if !userInfo.IsAdmin {
// lookup peer user-info to validate ownership
peerUserIdentifier, err := e.getPeerUserIdentifier(ctx, domain.PeerIdentifier(status.EntityId))
if err != nil {
return
}
if peerUserIdentifier == "" {
return // if peer is not assigned to any user, dont send stats
}
if peerUserIdentifier != userInfo.Id {
return // only expose stats for own peers
}
}
_ = writeJSON(wsMessage{Type: "peer_stats", Data: status})
}
interfaceStatsHandler := func(status domain.TrafficDelta) {
if !userInfo.IsAdmin {
return // interface stats will only be exposed to admins
}
_ = 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()
}
}
func (e *WebsocketEndpoint) getPeerUserIdentifier(
ctx context.Context,
peerIdentifier domain.PeerIdentifier,
) (domain.UserIdentifier, error) {
now := time.Now()
e.ownershipCacheMux.Lock()
entry, ok := e.ownershipCache[peerIdentifier]
if ok && now.Before(entry.expiresAt) {
e.ownershipCacheMux.Unlock()
return entry.userIdentifier, nil
}
e.ownershipCacheMux.Unlock()
peer, err := e.peerService.GetPeer(ctx, peerIdentifier)
if err != nil {
return "", err
}
e.ownershipCacheMux.Lock()
defer e.ownershipCacheMux.Unlock()
e.ownershipCache[peerIdentifier] = peerUserIdentifierCacheEntry{
userIdentifier: peer.UserIdentifier,
expiresAt: now.Add(websocketPeerUserIdentifierCacheTTL),
}
return peer.UserIdentifier, nil
}
func (e *WebsocketEndpoint) startOwnerCacheCleanup(ctx context.Context) {
ticker := time.NewTicker(websocketPeerUserIdentifierCacheCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
e.cleanupOwnerCache(now)
}
}
}
func (e *WebsocketEndpoint) cleanupOwnerCache(now time.Time) {
e.ownershipCacheMux.Lock()
defer e.ownershipCacheMux.Unlock()
for peerIdentifier, entry := range e.ownershipCache {
if !now.Before(entry.expiresAt) {
delete(e.ownershipCache, peerIdentifier)
}
}
}
func matchOrigin(externalBaseUrl, origin string) bool {
originURL, err := url.Parse(origin)
if err != nil {
return false
}
externalURL, err := url.Parse(externalBaseUrl)
if err != nil {
return false
}
return originURL.Scheme == externalURL.Scheme &&
strings.EqualFold(originURL.Host, externalURL.Host)
}

View File

@@ -0,0 +1,249 @@
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/websocket"
evbus "github.com/vardius/message-bus"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// region test-helper
type websocketTestPeerService struct {
peers map[domain.PeerIdentifier]*domain.Peer
}
func (s websocketTestPeerService) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
peer, ok := s.peers[id]
if !ok {
return nil, errors.New("peer not found")
}
return peer, nil
}
func newTestWebsocketConnection(
t *testing.T,
bus evbus.MessageBus,
userInfo *domain.ContextUserInfo,
peers map[domain.PeerIdentifier]*domain.Peer,
) (*websocket.Conn, func()) {
t.Helper()
cfg := &config.Config{}
endpoint := NewWebsocketEndpoint(cfg, nil, bus, websocketTestPeerService{peers: peers})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(domain.SetUserInfo(r.Context(), userInfo))
endpoint.handleWebsocket()(w, r)
}))
cfg.Web.ExternalUrl = server.URL
wsURL := "ws" + server.URL[len("http"):]
conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{"Origin": []string{server.URL}})
if err != nil {
server.Close()
t.Fatalf("failed to dial websocket: %v", err)
}
cleanup := func() {
conn.Close()
server.Close()
}
return conn, cleanup
}
func assertWebsocketMessage(t *testing.T, conn *websocket.Conn, messageType string, entityId string) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
var message wsMessage
if err := conn.ReadJSON(&message); err != nil {
t.Fatalf("failed to read websocket message: %v", err)
}
if message.Type != messageType {
t.Fatalf("unexpected message type: got %q, want %q", message.Type, messageType)
}
data, ok := message.Data.(map[string]any)
if !ok {
t.Fatalf("unexpected message data type: %T", message.Data)
}
if data["EntityId"] != entityId {
t.Fatalf("unexpected entity id: got %v, want %q", data["EntityId"], entityId)
}
}
func assertNoWebsocketMessage(t *testing.T, conn *websocket.Conn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
var message wsMessage
if err := conn.ReadJSON(&message); err == nil {
t.Fatalf("unexpected websocket message: %+v", message)
}
}
// endregion test-helper
func TestWebsocketEndpointAllowsOwnPeerStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},
map[domain.PeerIdentifier]*domain.Peer{
"own-peer": {Identifier: "own-peer", UserIdentifier: "user-a"},
})
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "own-peer", BytesReceivedPerSecond: 1})
assertWebsocketMessage(t, conn, "peer_stats", "own-peer")
}
func TestWebsocketEndpointCleansExpiredPeerUserIdentifierCache(t *testing.T) {
now := time.Now()
endpoint := &WebsocketEndpoint{
ownershipCache: map[domain.PeerIdentifier]peerUserIdentifierCacheEntry{
"expired-peer": {
userIdentifier: "user-a",
expiresAt: now.Add(-time.Second),
},
"active-peer": {
userIdentifier: "user-b",
expiresAt: now.Add(time.Second),
},
},
}
endpoint.cleanupOwnerCache(now)
if _, ok := endpoint.ownershipCache["expired-peer"]; ok {
t.Fatal("expired peer cache entry was not removed")
}
if _, ok := endpoint.ownershipCache["active-peer"]; !ok {
t.Fatal("active peer cache entry was removed")
}
}
func TestWebsocketEndpointFiltersOtherPeerStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},
map[domain.PeerIdentifier]*domain.Peer{
"other-peer": {Identifier: "other-peer", UserIdentifier: "user-b"},
})
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointFiltersUnknownPeerStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},
map[domain.PeerIdentifier]*domain.Peer{
"other-peer": {Identifier: "other-peer", UserIdentifier: ""},
})
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointFiltersUnknownPeerStatsForNonAdmin2(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, nil)
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "unknown-peer", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointFiltersInterfaceStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, nil)
defer cleanup()
bus.Publish(app.TopicInterfaceStatsUpdated, domain.TrafficDelta{EntityId: "wg0", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointAllowsAllStatsForAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "admin", IsAdmin: true}, nil)
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1})
assertWebsocketMessage(t, conn, "peer_stats", "other-peer")
bus.Publish(app.TopicInterfaceStatsUpdated, domain.TrafficDelta{EntityId: "wg0", BytesReceivedPerSecond: 1})
assertWebsocketMessage(t, conn, "interface_stats", "wg0")
}
func Test_matchOrigin(t *testing.T) {
tests := []struct {
name string
externalBaseUrl string
origin string
want bool
}{
{
name: "matching origin",
externalBaseUrl: "https://example.com",
origin: "https://example.com",
want: true,
},
{
name: "matching origin with path",
externalBaseUrl: "https://example.com/app1",
origin: "https://example.com/app2",
want: true,
},
{
name: "non-matching origin with different host",
externalBaseUrl: "https://example.com",
origin: "https://example.com.malicious.com",
want: false,
},
{
name: "non-matching origin with different scheme",
externalBaseUrl: "https://example.com",
origin: "http://example.com",
want: false,
},
{
name: "invalid origin URL",
externalBaseUrl: "https://example.com",
origin: "://invalid-url",
want: false,
},
{
name: "invalid externalBaseUrl",
externalBaseUrl: "://invalid-url",
origin: "https://example.com",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchOrigin(tt.externalBaseUrl, tt.origin)
if got != tt.want {
t.Errorf("matchOrigin() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -26,10 +26,12 @@ type SessionData struct {
Lastname string Lastname string
Email string Email string
OauthState string OauthState string
OauthNonce string OauthNonce string
OauthProvider string OauthCodeVerifier string
OauthReturnTo string OauthProvider string
OauthReturnTo string
OauthIdToken string
WebAuthnData string WebAuthnData string
@@ -79,15 +81,17 @@ func (s *SessionWrapper) DestroyData(ctx context.Context) {
func (s *SessionWrapper) defaultSessionData() SessionData { func (s *SessionWrapper) defaultSessionData() SessionData {
return SessionData{ return SessionData{
LoggedIn: false, LoggedIn: false,
IsAdmin: false, IsAdmin: false,
UserIdentifier: "", UserIdentifier: "",
Firstname: "", Firstname: "",
Lastname: "", Lastname: "",
Email: "", Email: "",
OauthState: "", OauthState: "",
OauthNonce: "", OauthNonce: "",
OauthProvider: "", OauthCodeVerifier: "",
OauthReturnTo: "", OauthProvider: "",
OauthReturnTo: "",
OauthIdToken: "",
} }
} }

View File

@@ -45,6 +45,11 @@ type OauthInitiationResponse struct {
State string State string
} }
type LogoutResponse struct {
Message string `json:"Message"`
RedirectUrl *string `json:"RedirectUrl,omitempty"`
}
type WebAuthnCredentialRequest struct { type WebAuthnCredentialRequest struct {
Name string `json:"Name"` Name string `json:"Name"`
} }

View File

@@ -47,6 +47,11 @@ const (
AuthenticatorTypeOidc AuthenticatorType = "oidc" AuthenticatorTypeOidc AuthenticatorType = "oidc"
) )
const (
pkceMethodS256 = "S256" // SHA-256 hashing
pkceMethodPlain = "plain" // plain text
)
// AuthenticatorOauth is the interface for all OAuth authenticators. // AuthenticatorOauth is the interface for all OAuth authenticators.
type AuthenticatorOauth interface { type AuthenticatorOauth interface {
// GetName returns the name of the authenticator. // GetName returns the name of the authenticator.
@@ -65,6 +70,15 @@ type AuthenticatorOauth interface {
RegistrationEnabled() bool RegistrationEnabled() bool
// GetAllowedDomains returns the list of whitelisted domains // GetAllowedDomains returns the list of whitelisted domains
GetAllowedDomains() []string GetAllowedDomains() []string
// GetAllowedUserGroups returns the list of whitelisted user groups.
// If non-empty, at least one user group must match.
GetAllowedUserGroups() []string
// GetLogoutUrl returns an IdP logout URL if supported by the provider.
GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool)
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string)
// PKCETokenOptions returns PKCE options for the token exchange.
PKCETokenOptions(verifier string) []oauth2.AuthCodeOption
} }
// AuthenticatorLdap is the interface for all LDAP authenticators. // AuthenticatorLdap is the interface for all LDAP authenticators.
@@ -351,7 +365,6 @@ func (a *Authenticator) passwordAuthentication(
domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists
var ldapUserInfo *domain.AuthenticatorUserInfo var ldapUserInfo *domain.AuthenticatorUserInfo
var ldapProvider AuthenticatorLdap
var userInDatabase = false var userInDatabase = false
existingUser, err := a.users.GetUser(ctx, identifier) existingUser, err := a.users.GetUser(ctx, identifier)
@@ -417,14 +430,14 @@ func (a *Authenticator) passwordAuthentication(
"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) user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapAuth.GetName(), true)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to process user information: %w", err) return nil, fmt.Errorf("unable to process user information: %w", err)
} }
existingUser = user existingUser = user
slog.Debug("created new LDAP user in db", slog.Debug("created new LDAP user in db",
"identifier", user.Identifier, "provider", ldapProvider.GetName()) "identifier", user.Identifier, "provider", ldapAuth.GetName())
authOK = true authOK = true
break break
@@ -444,30 +457,34 @@ func (a *Authenticator) passwordAuthentication(
// OauthLoginStep1 starts the oauth authentication flow by returning the authentication URL, state and nonce. // OauthLoginStep1 starts the oauth authentication flow by returning the authentication URL, state and nonce.
func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) ( func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) (
authCodeUrl, state, nonce string, authCodeUrl, state, nonce, codeVerifier string,
err error, err error,
) { ) {
oauthProvider, ok := a.oauthAuthenticators[providerId] oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok { if !ok {
return "", "", "", fmt.Errorf("missing oauth provider %s", providerId) return "", "", "", "", fmt.Errorf("missing oauth provider %s", providerId)
} }
// Prepare authentication flow, set state cookies // Prepare authentication flow, set state cookies
state, err = a.randString(16) state, err = a.randString(16)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("failed to generate state: %w", err) return "", "", "", "", fmt.Errorf("failed to generate state: %w", err)
} }
// Generate PKCE code verifier and challenge if enabled. Otherwise, options will be empty.
authCodeOptions, codeVerifier := oauthProvider.PKCEAuthCodeOptions()
switch oauthProvider.GetType() { switch oauthProvider.GetType() {
case AuthenticatorTypeOAuth: case AuthenticatorTypeOAuth:
authCodeUrl = oauthProvider.AuthCodeURL(state) authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...)
case AuthenticatorTypeOidc: case AuthenticatorTypeOidc:
nonce, err = a.randString(16) nonce, err = a.randString(16)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("failed to generate nonce: %w", err) return "", "", "", "", fmt.Errorf("failed to generate nonce: %w", err)
} }
authCodeUrl = oauthProvider.AuthCodeURL(state, oidc.Nonce(nonce)) authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce))
authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...)
} }
return return
@@ -498,31 +515,66 @@ func isDomainAllowed(email string, allowedDomains []string) bool {
return false return false
} }
// OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool {
// fetching the user information. if len(allowedUserGroups) == 0 {
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) { return true
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return nil, fmt.Errorf("missing oauth provider %s", providerId)
} }
oauth2Token, err := oauthProvider.Exchange(ctx, code) allowed := make(map[string]struct{}, len(allowedUserGroups))
if err != nil { for _, group := range allowedUserGroups {
return nil, fmt.Errorf("unable to exchange code: %w", err) trimmed := strings.TrimSpace(group)
if trimmed == "" {
continue
}
allowed[trimmed] = struct{}{}
} }
if len(allowed) == 0 {
return false
}
for _, group := range userGroups {
if _, ok := allowed[strings.TrimSpace(group)]; ok {
return true
}
}
return false
}
// OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and
// fetching the user information.
func (a *Authenticator) OauthLoginStep2(
ctx context.Context,
providerId, nonce, code, codeVerifier string,
) (*domain.User, string, error) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return nil, "", fmt.Errorf("missing oauth provider %s", providerId)
}
oauth2Token, err := oauthProvider.Exchange(ctx, code, oauthProvider.PKCETokenOptions(codeVerifier)...)
if err != nil {
return nil, "", fmt.Errorf("unable to exchange code: %w", err)
}
idTokenHint, _ := oauth2Token.Extra("id_token").(string)
rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce) rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to fetch user information: %w", err) return nil, "", fmt.Errorf("unable to fetch user information: %w", err)
} }
userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo) userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse user information: %w", err) return nil, "", fmt.Errorf("unable to parse user information: %w", err)
} }
if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) { if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) {
return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email) return nil, "", fmt.Errorf("user %s is not in allowed domains", userInfo.Email)
}
if !isAnyAllowedUserGroup(userInfo.UserGroups, oauthProvider.GetAllowedUserGroups()) {
return nil, "", fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier)
} }
ctx = domain.SetUserInfo(ctx, ctx = domain.SetUserInfo(ctx,
@@ -538,7 +590,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: err.Error(), Error: err.Error(),
}, },
}) })
return nil, fmt.Errorf("unable to process user information: %w", err) return nil, "", fmt.Errorf("unable to process user information: %w", err)
} }
if user.IsLocked() || user.IsDisabled() { if user.IsLocked() || user.IsDisabled() {
@@ -550,7 +602,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: "user is locked", Error: "user is locked",
}, },
}) })
return nil, errors.New("user is locked") return nil, "", errors.New("user is locked")
} }
a.bus.Publish(app.TopicAuthLogin, user.Identifier) a.bus.Publish(app.TopicAuthLogin, user.Identifier)
@@ -562,7 +614,16 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
}, },
}) })
return user, nil return user, idTokenHint, nil
}
func (a *Authenticator) OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return "", false
}
return oauthProvider.GetLogoutUrl(idTokenHint, postLogoutRedirectUri)
} }
func (a *Authenticator) processUserInfo( func (a *Authenticator) processUserInfo(

View File

@@ -149,5 +149,9 @@ func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.Authentica
AdminInfoAvailable: adminInfoAvailable, AdminInfoAvailable: adminInfoAvailable,
} }
if err := userInfo.Sanitize("ldap", l.cfg.ProviderName); err != nil {
return nil, err
}
return userInfo, nil return userInfo, nil
} }

View File

@@ -0,0 +1,98 @@
package auth
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeLdapAuthenticator creates a minimal LdapAuthenticator for testing ParseUserInfo.
func makeLdapAuthenticator() *LdapAuthenticator {
return &LdapAuthenticator{
cfg: &config.LdapProvider{
ProviderName: "test-ldap",
FieldMap: config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "uid",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "", // no group membership check
},
},
}
}
// makeRawLdapMap builds a minimal raw LDAP attribute map for ParseUserInfo.
func makeRawLdapMap(uid, mail, givenName, sn, phone, department string) map[string]any {
return map[string]any{
"uid": uid,
"mail": mail,
"givenName": givenName,
"sn": sn,
"telephoneNumber": phone,
"department": department,
}
}
// Test: firstname contains \x00 → output firstname has no null byte,
// one WARN log entry with field: "firstname".
func TestLdapParseUserInfo_NullByteInFirstname(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("alice", "alice@example.com", "Ali\x00ce", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := auth.ParseUserInfo(raw)
records := restore()
require.NoError(t, err)
assert.NotContains(t, info.Firstname, "\x00", "firstname should have null byte removed")
assert.Equal(t, "Alice", info.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found, "expected WARN log entry with field=firstname")
if found {
assert.Equal(t, "WARN", rec["level"])
}
}
// Test: all fields clean → no WARN log entries emitted.
func TestLdapParseUserInfo_AllFieldsClean(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("alice", "alice@example.com", "Alice", "Smith", "+1 555-1234", "Engineering")
restore := testutil.CaptureWarnLogs(t)
info, err := auth.ParseUserInfo(raw)
records := restore()
require.NoError(t, err)
assert.Equal(t, domain.UserIdentifier("alice"), info.Identifier)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 0, warnCount, "expected no WARN log entries when all fields are clean")
}
// Test: identifier is "all" → returns ErrInvalidData.
func TestLdapParseUserInfo_IdentifierAll(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
_, err := auth.ParseUserInfo(raw)
_ = restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
}

View File

@@ -29,6 +29,9 @@ type PlainOauthAuthenticator struct {
userInfoLogging bool userInfoLogging bool
sensitiveInfoLogging bool sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
allowedUserGroups []string
usePKCE bool
pkceMethod string
} }
func newPlainOauthAuthenticator( func newPlainOauthAuthenticator(
@@ -60,6 +63,15 @@ func newPlainOauthAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE
provider.pkceMethod = cfg.PKCEMethod
if provider.pkceMethod == "" {
provider.pkceMethod = pkceMethodS256
}
if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain {
return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod)
}
return provider, nil return provider, nil
} }
@@ -73,6 +85,40 @@ func (p PlainOauthAuthenticator) GetAllowedDomains() []string {
return p.allowedDomains return p.allowedDomains
} }
func (p PlainOauthAuthenticator) GetAllowedUserGroups() []string {
return p.allowedUserGroups
}
func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) {
return "", false
}
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
func (p PlainOauthAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) {
if !p.usePKCE {
return nil, ""
}
verifier := oauth2.GenerateVerifier()
if p.pkceMethod == pkceMethodPlain {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", verifier),
oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain),
}, verifier
}
return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier
}
// PKCETokenOptions returns PKCE options for the token exchange.
func (p PlainOauthAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption {
if !p.usePKCE || verifier == "" {
return nil
}
return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)}
}
// RegistrationEnabled returns whether registration is enabled for the OAuth authenticator. // RegistrationEnabled returns whether registration is enabled for the OAuth authenticator.
func (p PlainOauthAuthenticator) RegistrationEnabled() bool { func (p PlainOauthAuthenticator) RegistrationEnabled() bool {
return p.registrationEnabled return p.registrationEnabled
@@ -145,5 +191,5 @@ func (p PlainOauthAuthenticator) GetUserInfo(
// ParseUserInfo parses the user information from the raw data. // ParseUserInfo parses the user information from the raw data.
func (p PlainOauthAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) { func (p PlainOauthAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(p.userInfoMapping, p.userAdminMapping, raw) return parseOauthUserInfo(p.userInfoMapping, p.userAdminMapping, raw, "oauth", p.name)
} }

View File

@@ -0,0 +1,61 @@
package auth
import (
"testing"
"golang.org/x/oauth2"
)
func TestPlainOauthAuthenticatorPKCES256Options(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if verifier == "" {
t.Fatal("expected verifier")
}
values := authCodeValues(t, options)
if values.Get("code_challenge") == "" {
t.Fatal("expected code_challenge")
}
if values.Get("code_challenge_method") != "S256" {
t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method"))
}
tokenOptions := authenticator.PKCETokenOptions(verifier)
if len(tokenOptions) != 1 {
t.Fatalf("expected one token option, got %d", len(tokenOptions))
}
}
func TestPlainOauthAuthenticatorPKCEPlainOptions(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "plain"}
options, verifier := authenticator.PKCEAuthCodeOptions()
values := authCodeValues(t, options)
if values.Get("code_challenge") != verifier {
t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge"))
}
if values.Get("code_challenge_method") != "plain" {
t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method"))
}
}
func TestPlainOauthAuthenticatorPKCEDisabled(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: false, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if len(options) != 0 {
t.Fatalf("expected no auth code options, got %d", len(options))
}
if verifier != "" {
t.Fatalf("expected empty verifier, got %q", verifier)
}
tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier())
if len(tokenOptions) != 0 {
t.Fatalf("expected no token options, got %d", len(tokenOptions))
}
}

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -26,6 +27,11 @@ type OidcAuthenticator struct {
userInfoLogging bool userInfoLogging bool
sensitiveInfoLogging bool sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
usePKCE bool
pkceMethod string
} }
func newOidcAuthenticator( func newOidcAuthenticator(
@@ -61,6 +67,25 @@ func newOidcAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession
provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE
provider.pkceMethod = cfg.PKCEMethod
if provider.pkceMethod == "" {
provider.pkceMethod = pkceMethodS256
}
if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain {
return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod)
}
var providerMetadata struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err = provider.provider.Claims(&providerMetadata); err != nil {
slog.Debug("OIDC: failed to parse provider metadata", "provider", cfg.ProviderName, "error", err)
} else {
provider.endSessionEndpoint = providerMetadata.EndSessionEndpoint
}
return provider, nil return provider, nil
} }
@@ -74,6 +99,64 @@ func (o OidcAuthenticator) GetAllowedDomains() []string {
return o.allowedDomains return o.allowedDomains
} }
func (o OidcAuthenticator) GetAllowedUserGroups() []string {
return o.allowedUserGroups
}
func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) {
if !o.logoutIdpSession {
return "", false
}
if o.endSessionEndpoint == "" {
slog.Debug("OIDC logout URL generation disabled: provider has no end_session_endpoint", "provider", o.name)
return "", false
}
logoutUrl, err := url.Parse(o.endSessionEndpoint)
if err != nil {
slog.Debug("OIDC logout URL generation failed, unable to parse end_session_endpoint url",
"provider", o.name, "error", err)
return "", false
}
params := logoutUrl.Query()
if idTokenHint != "" {
params.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectUri != "" {
params.Set("post_logout_redirect_uri", postLogoutRedirectUri)
}
logoutUrl.RawQuery = params.Encode()
return logoutUrl.String(), true
}
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
func (o OidcAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) {
if !o.usePKCE {
return nil, ""
}
verifier := oauth2.GenerateVerifier()
if o.pkceMethod == pkceMethodPlain {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", verifier),
oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain),
}, verifier
}
return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier
}
// PKCETokenOptions returns PKCE options for the token exchange.
func (o OidcAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption {
if !o.usePKCE || verifier == "" {
return nil
}
return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)}
}
// RegistrationEnabled returns whether registration is enabled for this authenticator. // RegistrationEnabled returns whether registration is enabled for this authenticator.
func (o OidcAuthenticator) RegistrationEnabled() bool { func (o OidcAuthenticator) RegistrationEnabled() bool {
return o.registrationEnabled return o.registrationEnabled
@@ -97,7 +180,7 @@ func (o OidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oa
return o.cfg.Exchange(ctx, code, opts...) return o.cfg.Exchange(ctx, code, opts...)
} }
// GetUserInfo retrieves the user info from the token. // GetUserInfo retrieves the user info from the token and the userinfo endpoint.
func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) ( func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (
map[string]any, map[string]any,
error, error,
@@ -135,6 +218,41 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
return nil, fmt.Errorf("failed to parse extra claims: %w", err) return nil, fmt.Errorf("failed to parse extra claims: %w", err)
} }
// Fetch additional user information from the userinfo endpoint
userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to fetch user info from endpoint", "provider", o.name, "error", err)
}
// Don't fail the entire flow if userinfo endpoint is unavailable;
// ID token claims may be sufficient
slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name)
} else {
// Parse claims from userinfo endpoint response
var userInfoFields map[string]any
if err = userInfo.Claims(&userInfoFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse userinfo claims", "provider", o.name, "error", err)
}
// Don't fail if we can't parse userinfo; continue with ID token claims
slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name)
} else {
// Merge userinfo fields into tokenFields, preferring ID token claims
for key, value := range userInfoFields {
if _, exists := tokenFields[key]; !exists {
tokenFields[key] = value
}
}
if o.userInfoLogging {
contents, _ := json.Marshal(userInfoFields)
slog.Debug("OIDC: user info from endpoint",
"source", o.name,
"info", string(contents))
}
}
}
if o.userInfoLogging { if o.userInfoLogging {
contents, _ := json.Marshal(tokenFields) contents, _ := json.Marshal(tokenFields)
slog.Debug("OIDC: user info debug", slog.Debug("OIDC: user info debug",
@@ -147,5 +265,5 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
// ParseUserInfo parses the user info. // ParseUserInfo parses the user info.
func (o OidcAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) { func (o OidcAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw) return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw, "oidc", o.name)
} }

View File

@@ -0,0 +1,79 @@
package auth
import (
"net/url"
"testing"
"golang.org/x/oauth2"
)
func authCodeValues(t *testing.T, options []oauth2.AuthCodeOption) url.Values {
t.Helper()
config := oauth2.Config{
ClientID: "client-id",
Endpoint: oauth2.Endpoint{AuthURL: "https://example.com/auth"},
RedirectURL: "https://wg.example.com/callback",
}
authCodeURL, err := url.Parse(config.AuthCodeURL("state", options...))
if err != nil {
t.Fatalf("failed to parse auth code URL: %v", err)
}
return authCodeURL.Query()
}
func TestOidcAuthenticatorPKCES256Options(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if verifier == "" {
t.Fatal("expected verifier")
}
values := authCodeValues(t, options)
if values.Get("code_challenge") == "" {
t.Fatal("expected code_challenge")
}
if values.Get("code_challenge_method") != "S256" {
t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method"))
}
tokenOptions := authenticator.PKCETokenOptions(verifier)
if len(tokenOptions) != 1 {
t.Fatalf("expected one token option, got %d", len(tokenOptions))
}
}
func TestOidcAuthenticatorPKCEPlainOptions(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "plain"}
options, verifier := authenticator.PKCEAuthCodeOptions()
values := authCodeValues(t, options)
if values.Get("code_challenge") != verifier {
t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge"))
}
if values.Get("code_challenge_method") != "plain" {
t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method"))
}
}
func TestOidcAuthenticatorPKCEDisabled(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: false, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if len(options) != 0 {
t.Fatalf("expected no auth code options, got %d", len(options))
}
if verifier != "" {
t.Fatalf("expected empty verifier, got %q", verifier)
}
tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier())
if len(tokenOptions) != 0 {
t.Fatalf("expected no token options, got %d", len(tokenOptions))
}
}

View File

@@ -13,9 +13,12 @@ func parseOauthUserInfo(
mapping config.OauthFields, mapping config.OauthFields,
adminMapping *config.OauthAdminMapping, adminMapping *config.OauthAdminMapping,
raw map[string]any, raw map[string]any,
providerType string,
providerName string,
) (*domain.AuthenticatorUserInfo, error) { ) (*domain.AuthenticatorUserInfo, error) {
var isAdmin bool var isAdmin bool
var adminInfoAvailable bool var adminInfoAvailable bool
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
// 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 != "" {
@@ -26,22 +29,10 @@ func parseOauthUserInfo(
} }
} }
// next try to parse the user's groups
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
re := adminMapping.GetAdminGroupRegex()
for _, group := range userGroups {
if re.MatchString(strings.TrimSpace(group)) {
isAdmin = true
break
}
}
}
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, ""),
UserGroups: userGroups,
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, ""),
@@ -50,6 +41,24 @@ func parseOauthUserInfo(
AdminInfoAvailable: adminInfoAvailable, AdminInfoAvailable: adminInfoAvailable,
} }
if err := userInfo.Sanitize(providerType, providerName); err != nil {
return nil, err
}
// check admin group match after sanitization
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
re := adminMapping.GetAdminGroupRegex()
for _, group := range userInfo.UserGroups {
if re.MatchString(group) {
isAdmin = true
break
}
}
userInfo.IsAdmin = isAdmin
userInfo.AdminInfoAvailable = adminInfoAvailable
}
return userInfo, nil return userInfo, nil
} }

View File

@@ -0,0 +1,148 @@
package auth
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeOauthFieldMapping returns a minimal OauthFields mapping for testing.
func makeOauthFieldMapping() config.OauthFields {
return config.OauthFields{
BaseFields: config.BaseFields{
UserIdentifier: "sub",
Email: "email",
Firstname: "given_name",
Lastname: "family_name",
Phone: "phone",
Department: "department",
},
}
}
// makeOauthRaw builds a minimal raw OAuth user info map.
func makeOauthRaw(sub, email, givenName, familyName, phone, department string) map[string]any {
return map[string]any{
"sub": sub,
"email": email,
"given_name": givenName,
"family_name": familyName,
"phone": phone,
"department": department,
}
}
// Test: email containing \r\n → output email is "",
// one WARN log entry with field: "email" and cleared indication.
func TestParseOauthUserInfo_CRLFInEmail(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
raw := makeOauthRaw("user123", "user\r\n@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email, "email should be cleared when it contains CR/LF")
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "email")
assert.True(t, found, "expected WARN log entry with field=email")
if found {
msg, _ := rec["msg"].(string)
assert.Contains(t, msg, "cleared", "expected 'cleared' in log message when email is cleared")
}
}
// Test: two fields modified (email cleared, firstname truncated) →
// two separate WARN log entries.
func TestParseOauthUserInfo_TwoFieldsModified(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
longFirstname := strings.Repeat("A", 200)
raw := makeOauthRaw("user123", "bad\r\nemail@example.com", longFirstname, "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email, "email should be cleared")
assert.Equal(t, 128, len([]rune(info.Firstname)), "firstname should be truncated to 128 runes")
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 2, warnCount, "expected exactly two WARN log entries (one per modified field)")
_, emailFound := testutil.FindWarnWithField(records, "email")
assert.True(t, emailFound, "expected WARN log entry with field=email")
_, firstnameFound := testutil.FindWarnWithField(records, "firstname")
assert.True(t, firstnameFound, "expected WARN log entry with field=firstname")
}
// Test: identifier "all" → returns ErrInvalidData.
func TestParseOauthUserInfo_IdentifierAll(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
raw := makeOauthRaw("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
_, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
_ = restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
}
func TestParseOauthUserInfo_DropsModifiedGroupBeforeAdminMatch(t *testing.T) {
mapping := makeOauthFieldMapping()
mapping.UserGroups = "groups"
adminMapping := &config.OauthAdminMapping{
AdminGroupRegex: "^wgportal-admins$",
}
raw := makeOauthRaw("user123", "user@example.com", "Alice", "Smith", "", "")
raw["groups"] = []any{"wgportal-\u200badmins"}
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oidc", "test-provider")
records := restore()
require.NoError(t, err)
require.NotNil(t, info)
assert.False(t, info.IsAdmin, "sanitization must not repair a modified group into an admin match")
assert.Empty(t, info.UserGroups)
rec, found := testutil.FindWarnWithField(records, "user_group")
assert.True(t, found, "expected WARN log entry with field=user_group")
if found {
assert.Equal(t, "oidc", rec["provider_type"])
}
}
func TestParseOauthUserInfo_AllowsWhitespaceOnlyGroupTrim(t *testing.T) {
mapping := makeOauthFieldMapping()
mapping.UserGroups = "groups"
adminMapping := &config.OauthAdminMapping{
AdminGroupRegex: "^wgportal-admins$",
}
raw := makeOauthRaw("user123", "user@example.com", "Alice", "Smith", "", "")
raw["groups"] = []any{" wgportal-admins "}
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oidc", "test-provider")
require.NoError(t, err)
require.NotNil(t, info)
assert.True(t, info.IsAdmin)
assert.Equal(t, []string{"wgportal-admins"}, info.UserGroups)
}

View File

@@ -43,7 +43,7 @@ func Test_parseOauthUserInfo_no_admin(t *testing.T) {
}) })
adminMapping := &config.OauthAdminMapping{} adminMapping := &config.OauthAdminMapping{}
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, info.IsAdmin) assert.False(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")
@@ -90,12 +90,13 @@ func Test_parseOauthUserInfo_admin_group(t *testing.T) {
AdminGroupRegex: "^wgportal-admins@mydomain.net$", AdminGroupRegex: "^wgportal-admins@mydomain.net$",
} }
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsAdmin) assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")
assert.Equal(t, info.Lastname, "") assert.Equal(t, info.Lastname, "")
assert.Equal(t, info.Email, "test@mydomain.net") assert.Equal(t, info.Email, "test@mydomain.net")
assert.Equal(t, info.UserGroups, []string{"abuse@mydomain.net", "postmaster@mydomain.net", "wgportal-admins@mydomain.net"})
} }
func Test_parseOauthUserInfo_admin_value(t *testing.T) { func Test_parseOauthUserInfo_admin_value(t *testing.T) {
@@ -131,7 +132,7 @@ func Test_parseOauthUserInfo_admin_value(t *testing.T) {
}) })
adminMapping := &config.OauthAdminMapping{} // test with default regex adminMapping := &config.OauthAdminMapping{} // test with default regex
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsAdmin) assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")
@@ -174,7 +175,7 @@ func Test_parseOauthUserInfo_admin_value_custom(t *testing.T) {
AdminValueRegex: "^1$", AdminValueRegex: "^1$",
} }
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsAdmin) assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")

View File

@@ -0,0 +1,90 @@
package auth
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// captureWarnLogsInline redirects the default slog logger to a buffer, calls fn,
// restores the original logger, and returns the captured log records.
func captureWarnLogsInline(fn func()) []map[string]any {
original := slog.Default()
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(handler))
fn()
slog.SetDefault(original)
var records []map[string]any
decoder := json.NewDecoder(&buf)
for decoder.More() {
var rec map[string]any
if err := decoder.Decode(&rec); err == nil {
records = append(records, rec)
}
}
return records
}
// Property 7: Sanitization change logging completeness
func TestPropertySanitizationChangeLoggingCompleteness(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
rapid.Check(t, func(t *rapid.T) {
sub := rapid.StringMatching(`[a-zA-Z0-9_@.-]{1,50}`).Draw(t, "sub")
email := rapid.String().Draw(t, "email")
firstname := rapid.String().Draw(t, "firstname")
lastname := rapid.String().Draw(t, "lastname")
phone := rapid.String().Draw(t, "phone")
department := rapid.String().Draw(t, "department")
if sub == "" {
sub = "testuser"
}
raw := makeOauthRaw(sub, email, firstname, lastname, phone, department)
// Count how many fields will actually change after sanitization
expectedChanges := 0
if domain.SanitizeIdentifier(sub, 256) != sub {
expectedChanges++
}
if domain.SanitizeEmail(email, 254) != email {
expectedChanges++
}
if domain.SanitizeString(firstname, 128) != firstname {
expectedChanges++
}
if domain.SanitizeString(lastname, 128) != lastname {
expectedChanges++
}
if domain.SanitizePhone(phone, 50) != phone {
expectedChanges++
}
if domain.SanitizeString(department, 128) != department {
expectedChanges++
}
var records []map[string]any
records = captureWarnLogsInline(func() {
_, _ = parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
})
actualWarnCount := testutil.CountWarnEntries(records)
require.Equal(t, expectedChanges, actualWarnCount,
"number of WARN log entries (%d) must equal number of fields changed by sanitization (%d)",
actualWarnCount, expectedChanges)
})
}

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

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

@@ -28,7 +28,7 @@ func convertRawLdapUser(
uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")) uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
return &domain.User{ user := &domain.User{
BaseModel: domain.BaseModel{ BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemLdapSyncer, CreatedBy: domain.CtxSystemLdapSyncer,
UpdatedBy: domain.CtxSystemLdapSyncer, UpdatedBy: domain.CtxSystemLdapSyncer,
@@ -49,10 +49,16 @@ func convertRawLdapUser(
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""), Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""), Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
Department: internal.MapDefaultString(rawUser, fields.Department, ""), Department: internal.MapDefaultString(rawUser, fields.Department, ""),
Notes: "", }
Password: "",
Disabled: nil, if err := user.SanitizeExternalData("ldap", providerName); err != nil {
}, nil return nil, err
}
// Update authentication identifier after sanitization
user.Authentications[0].UserIdentifier = user.Identifier
return user, nil
} }
func userChangedInLdap(dbUser, ldapUser *domain.User) bool { func userChangedInLdap(dbUser, ldapUser *domain.User) bool {

View File

@@ -0,0 +1,136 @@
package users
import (
"errors"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeTestLdapFields returns a minimal LdapFields config for testing.
func makeTestLdapFields() *config.LdapFields {
return &config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "uid",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "memberOf",
}
}
// makeTestAdminGroupDN returns a parsed DN for testing (a non-matching group).
func makeTestAdminGroupDN(t *testing.T) *ldap.DN {
t.Helper()
dn, err := ldap.ParseDN("cn=admins,dc=example,dc=com")
require.NoError(t, err)
return dn
}
// makeRawLdapUser builds a raw LDAP user map for convertRawLdapUser.
func makeRawLdapUser(uid, mail, givenName, sn, phone, department string) map[string]any {
return map[string]any{
"uid": uid,
"mail": mail,
"givenName": givenName,
"sn": sn,
"telephoneNumber": phone,
"department": department,
"memberOf": [][]byte{}, // no group memberships
}
}
// Test: identifier "all" → returns ErrInvalidData,
// one WARN log entry with field: "identifier" and cleared indication.
func TestConvertRawLdapUser_IdentifierAll(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
assert.Nil(t, user)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "identifier")
assert.True(t, found, "expected WARN log entry with field=identifier")
if found {
msg, _ := rec["msg"].(string)
assert.Contains(t, msg, "cleared", "expected 'cleared' in log message when identifier is cleared")
}
}
// Test: firstname contains \x00 → output firstname has null byte removed,
// one WARN log entry with field: "firstname".
func TestConvertRawLdapUser_NullByteInFirstname(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("alice", "alice@example.com", "Ali\x00ce", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.NoError(t, err)
require.NotNil(t, user)
assert.NotContains(t, user.Firstname, "\x00", "firstname should have null byte removed")
assert.Equal(t, "Alice", user.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found, "expected WARN log entry with field=firstname")
if found {
assert.Equal(t, "WARN", rec["level"])
}
}
// Test: all fields clean → no WARN log entries emitted.
func TestConvertRawLdapUser_AllFieldsClean(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("alice", "alice@example.com", "Alice", "Smith", "+1 555-1234", "Engineering")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, domain.UserIdentifier("alice"), user.Identifier)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 0, warnCount, "expected no WARN log entries when all fields are clean")
}
func TestLdapUserIdentifier_NormalizesSyncComparisons(t *testing.T) {
raw := map[string]any{"uid": " alice\x00 "}
got := ldapUserIdentifier(raw, "uid")
assert.Equal(t, domain.UserIdentifier("alice"), got)
}
func TestLdapUserIdentifier_RejectsReservedIdentifier(t *testing.T) {
raw := map[string]any{"uid": " all "}
got := ldapUserIdentifier(raw, "uid")
assert.Empty(t, got)
}

View File

@@ -90,6 +90,12 @@ func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.Ldap
} }
} }
// Update interface allowed users based on LDAP filters
err = m.updateInterfaceLdapFilters(ctx, conn, provider)
if err != nil {
return err
}
return nil return nil
} }
@@ -103,6 +109,11 @@ func (m Manager) updateLdapUsers(
for _, rawUser := range rawUsers { for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN) user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) { if err != nil && !errors.Is(err, domain.ErrNotFound) {
if errors.Is(err, domain.ErrInvalidData) {
slog.Warn("skipping LDAP user with invalid data after sanitization",
"raw-dn", rawUser["dn"], "error", err)
continue
}
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err) return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
} }
@@ -206,7 +217,7 @@ func (m Manager) disableMissingLdapUsers(
existsInLDAP := false existsInLDAP := false
for _, rawUser := range rawUsers { for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")) userId := ldapUserIdentifier(rawUser, fields.UserIdentifier)
if user.Identifier == userId { if user.Identifier == userId {
existsInLDAP = true existsInLDAP = true
break break
@@ -237,3 +248,68 @@ func (m Manager) disableMissingLdapUsers(
return nil return nil
} }
func (m Manager) updateInterfaceLdapFilters(
ctx context.Context,
conn *ldap.Conn,
provider *config.LdapProvider,
) error {
if len(provider.InterfaceFilter) == 0 {
return nil // nothing to do if no interfaces are configured for this provider
}
for ifaceName, groupFilter := range provider.InterfaceFilter {
ifaceId := domain.InterfaceIdentifier(ifaceName)
// Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
if err != nil {
slog.Error("failed to find users for interface filter",
"interface", ifaceId,
"provider", provider.ProviderName,
"error", err)
continue
}
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
for _, rawUser := range rawUsers {
userId := ldapUserIdentifier(rawUser, provider.FieldMap.UserIdentifier)
if userId != "" {
matchedUserIds = append(matchedUserIds, userId)
}
}
// Save the interface
err = m.interfaces.SaveInterface(ctx, ifaceId, func(i *domain.Interface) (*domain.Interface, error) {
if i.LdapAllowedUsers == nil {
i.LdapAllowedUsers = make(map[string][]domain.UserIdentifier)
}
i.LdapAllowedUsers[provider.ProviderName] = matchedUserIds
return i, nil
})
if err != nil {
slog.Error("failed to save interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
"error", err)
} else {
slog.Debug("updated interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
"matched_count", len(matchedUserIds))
}
}
return nil
}
func ldapUserIdentifier(rawUser map[string]any, field string) domain.UserIdentifier {
identifier := internal.MapDefaultString(rawUser, field, "")
identifier = domain.SanitizeIdentifier(identifier, 256)
if identifier == "" {
return ""
}
return domain.UserIdentifier(identifier)
}

View File

@@ -39,6 +39,11 @@ type PeerDatabaseRepo interface {
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
} }
type InterfaceDatabaseRepo interface {
// SaveInterface saves the interface with the given identifier.
SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(i *domain.Interface) (*domain.Interface, error)) error
}
type EventBus interface { type EventBus interface {
// Publish sends a message to the message bus. // Publish sends a message to the message bus.
Publish(topic string, args ...any) Publish(topic string, args ...any)
@@ -50,22 +55,27 @@ type EventBus interface {
type Manager struct { type Manager struct {
cfg *config.Config cfg *config.Config
bus EventBus bus EventBus
users UserDatabaseRepo users UserDatabaseRepo
peers PeerDatabaseRepo peers PeerDatabaseRepo
interfaces InterfaceDatabaseRepo
} }
// NewUserManager creates a new user manager instance. // NewUserManager creates a new user manager instance.
func NewUserManager(cfg *config.Config, bus EventBus, users UserDatabaseRepo, peers PeerDatabaseRepo) ( func NewUserManager(
*Manager, cfg *config.Config,
error, bus EventBus,
) { users UserDatabaseRepo,
peers PeerDatabaseRepo,
interfaces InterfaceDatabaseRepo,
) (*Manager, error) {
m := &Manager{ m := &Manager{
cfg: cfg, cfg: cfg,
bus: bus, bus: bus,
users: users, users: users,
peers: peers, peers: peers,
interfaces: interfaces,
} }
return m, nil return m, nil
} }
@@ -272,8 +282,9 @@ func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*
func (m Manager) validateModifications(ctx context.Context, old, new *domain.User) error { func (m Manager) validateModifications(ctx context.Context, old, new *domain.User) error {
currentUser := domain.GetUserInfo(ctx) currentUser := domain.GetUserInfo(ctx)
if currentUser.Id != new.Identifier && !currentUser.IsAdmin { adminErrors := m.validateAdminModifications(ctx, old, new)
return fmt.Errorf("insufficient permissions") if adminErrors != nil {
return adminErrors
} }
if err := old.EditAllowed(new); err != nil && currentUser.Id != domain.SystemAdminContextUserInfo().Id { if err := old.EditAllowed(new); err != nil && currentUser.Id != domain.SystemAdminContextUserInfo().Id {
@@ -303,6 +314,46 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
return nil return nil
} }
func (m Manager) validateAdminModifications(ctx context.Context, old, new *domain.User) error {
currentUser := domain.GetUserInfo(ctx)
if currentUser.IsAdmin {
if currentUser.Id == old.Identifier && !new.IsAdmin {
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
}
return nil // admins can do (almost) everything
}
// non-admins can only modify very their own profile data
if currentUser.Id != new.Identifier {
return fmt.Errorf("insufficient permissions: %w", domain.ErrInvalidData)
}
if new.IsAdmin {
return fmt.Errorf("cannot grant admin rights: %w", domain.ErrInvalidData)
}
if new.Notes != old.Notes {
return fmt.Errorf("cannot update notes: %w", domain.ErrInvalidData)
}
if old.Locked != new.Locked || old.LockedReason != new.LockedReason {
return fmt.Errorf("cannot change lock state: %w", domain.ErrInvalidData)
}
if old.Disabled != new.Disabled || old.DisabledReason != new.DisabledReason {
return fmt.Errorf("cannot change disabled state: %w", domain.ErrInvalidData)
}
if old.PersistLocalChanges != new.PersistLocalChanges {
return fmt.Errorf("cannot change disabled state: %w", domain.ErrInvalidData)
}
return nil
}
func (m Manager) validateCreation(ctx context.Context, new *domain.User) error { func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
currentUser := domain.GetUserInfo(ctx) currentUser := domain.GetUserInfo(ctx)
@@ -314,19 +365,7 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
return fmt.Errorf("invalid user identifier: %w", domain.ErrInvalidData) return fmt.Errorf("invalid user identifier: %w", domain.ErrInvalidData)
} }
if new.Identifier == "all" { // the 'all' user identifier collides with the rest api routes if domain.IsReservedUserIdentifier(new.Identifier) {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "new" { // the 'new' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "id" { // the 'id' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == domain.CtxSystemAdminId || new.Identifier == domain.CtxUnknownUserId {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData) return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
} }
@@ -374,6 +413,10 @@ func (m Manager) validateDeletion(ctx context.Context, del *domain.User) error {
func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error { func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error {
currentUser := domain.GetUserInfo(ctx) currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin && m.cfg.Advanced.ApiAdminOnly {
return fmt.Errorf("insufficient permissions to change API access: %w", domain.ErrNoPermission)
}
if currentUser.Id != user.Identifier { if currentUser.Id != user.Identifier {
return fmt.Errorf("cannot change API access of user: %w", domain.ErrNoPermission) return fmt.Errorf("cannot change API access of user: %w", domain.ErrNoPermission)
} }
@@ -478,6 +521,7 @@ func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, e
} }
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) { err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u, false)
return user, nil return user, nil
}) })
if err != nil { if err != nil {

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,18 +195,26 @@ 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 = c.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
p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
p.Endpoint = peer.Endpoint p.Endpoint = peer.Endpoint
p.LastHandshake = lastHandshake p.LastHandshake = lastHandshake
p.CalcConnected() p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval)
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 {
@@ -227,7 +249,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
} }
} }
func getSessionStartTime( func (c *StatisticsCollector) getSessionStartTime(
oldStats domain.PeerStatus, oldStats domain.PeerStatus,
newReceived, newTransmitted uint64, newReceived, newTransmitted uint64,
latestHandshake *time.Time, latestHandshake *time.Time,
@@ -236,7 +258,7 @@ func getSessionStartTime(
return nil // currently not connected return nil // currently not connected
} }
oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected oldestHandshakeTime := time.Now().Add(-1 * c.cfg.Backend.ReKeyTimeoutInterval) // if a handshake is older than the rekey interval + grace-period, the peer is no longer connected
switch { switch {
// old session was never initiated // old session was never initiated
case oldStats.BytesReceived == 0 && oldStats.BytesTransmitted == 0 && (newReceived > 0 || newTransmitted > 0): case oldStats.BytesReceived == 0 && oldStats.BytesTransmitted == 0 && (newReceived > 0 || newTransmitted > 0):
@@ -347,7 +369,7 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
p.LastPing = nil p.LastPing = nil
} }
p.UpdatedAt = time.Now() p.UpdatedAt = time.Now()
p.CalcConnected() p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval)
if wasConnected != p.IsConnected { if wasConnected != p.IsConnected {
connectionStateChanged = true connectionStateChanged = true

View File

@@ -5,10 +5,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
func Test_getSessionStartTime(t *testing.T) { func TestStatisticsCollector_getSessionStartTime(t *testing.T) {
now := time.Now() now := time.Now()
nowMinus1 := now.Add(-1 * time.Minute) nowMinus1 := now.Add(-1 * time.Minute)
nowMinus3 := now.Add(-3 * time.Minute) nowMinus3 := now.Add(-3 * time.Minute)
@@ -133,7 +134,14 @@ func Test_getSessionStartTime(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := getSessionStartTime(tt.args.oldStats, tt.args.newReceived, tt.args.newTransmitted, c := &StatisticsCollector{
cfg: &config.Config{
Backend: config.Backend{
ReKeyTimeoutInterval: 180 * time.Second,
},
},
}
if got := c.getSessionStartTime(tt.args.oldStats, tt.args.newReceived, tt.args.newTransmitted,
tt.args.lastHandshake); !reflect.DeepEqual(got, tt.want) { tt.args.lastHandshake); !reflect.DeepEqual(got, tt.want) {
t.Errorf("getSessionStartTime() = %v, want %v", got, tt.want) t.Errorf("getSessionStartTime() = %v, want %v", got, tt.want)
} }

View File

@@ -35,6 +35,8 @@ type InterfaceAndPeerDatabaseRepo interface {
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
GetAllUsers(ctx context.Context) ([]domain.User, error)
} }
type WgQuickController interface { type WgQuickController interface {
@@ -58,7 +60,8 @@ type Manager struct {
db InterfaceAndPeerDatabaseRepo db InterfaceAndPeerDatabaseRepo
wg *ControllerManager wg *ControllerManager
userLockMap *sync.Map userLockMap *sync.Map
interfaceLockMap *sync.Map
} }
func NewWireGuardManager( func NewWireGuardManager(
@@ -68,11 +71,12 @@ func NewWireGuardManager(
db InterfaceAndPeerDatabaseRepo, db InterfaceAndPeerDatabaseRepo,
) (*Manager, error) { ) (*Manager, error) {
m := &Manager{ m := &Manager{
cfg: cfg, cfg: cfg,
bus: bus, bus: bus,
wg: wg, wg: wg,
db: db, db: db,
userLockMap: &sync.Map{}, userLockMap: &sync.Map{},
interfaceLockMap: &sync.Map{},
} }
m.connectToMessageBus() m.connectToMessageBus()
@@ -92,10 +96,11 @@ func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicUserDisabled, m.handleUserDisabledEvent) _ = m.bus.Subscribe(app.TopicUserDisabled, m.handleUserDisabledEvent)
_ = m.bus.Subscribe(app.TopicUserEnabled, m.handleUserEnabledEvent) _ = m.bus.Subscribe(app.TopicUserEnabled, m.handleUserEnabledEvent)
_ = m.bus.Subscribe(app.TopicUserDeleted, m.handleUserDeletedEvent) _ = m.bus.Subscribe(app.TopicUserDeleted, m.handleUserDeletedEvent)
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreatedEvent)
} }
func (m Manager) handleUserCreationEvent(user domain.User) { func (m Manager) handleUserCreationEvent(user domain.User) {
if !m.cfg.Core.CreateDefaultPeerOnCreation { if !m.cfg.Core.CreateDefaultPeerOnUserCreation {
return return
} }
@@ -116,7 +121,7 @@ func (m Manager) handleUserCreationEvent(user domain.User) {
} }
func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) { func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) {
if !m.cfg.Core.CreateDefaultPeer { if !m.cfg.Core.CreateDefaultPeerOnLogin {
return return
} }
@@ -268,6 +273,31 @@ func (m Manager) handleUserDeletedEvent(user domain.User) {
} }
} }
// handleInterfaceCreatedEvent creates default peers for all existing users when a new interface is created.
// This ensures users that already exist (e.g. imported via a prior LDAP sync that had no interface available)
// also receive a default peer for the newly created interface.
func (m Manager) handleInterfaceCreatedEvent(iface domain.Interface) {
if !m.cfg.Core.CreateDefaultPeerOnUserCreation {
return
}
_, loaded := m.interfaceLockMap.LoadOrStore(iface.Identifier, "create")
if loaded {
return // another goroutine is already handling this interface
}
defer m.interfaceLockMap.Delete(iface.Identifier)
slog.Debug("handling new interface event", "interface", iface.Identifier)
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
err := m.CreateDefaultPeers(ctx, iface.Identifier)
if err != nil {
slog.Error("failed to create default peers on new interface",
"interface", iface.Identifier, "error", err)
}
}
func (m Manager) runExpiredPeersCheck(ctx context.Context) { func (m Manager) runExpiredPeersCheck(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo()) ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())

View File

@@ -16,6 +16,11 @@ import (
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
// GetInterface returns the interface for the given interface identifier.
func (m Manager) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
return m.db.GetInterface(ctx, id)
}
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier. // GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) ( func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface, *domain.Interface,
@@ -63,12 +68,17 @@ func (m Manager) GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interfa
// GetUserInterfaces returns all interfaces that are available for users to create new peers. // GetUserInterfaces returns all interfaces that are available for users to create new peers.
// If self-provisioning is disabled, this function will return an empty list. // If self-provisioning is disabled, this function will return an empty list.
// At the moment, there are no interfaces specific to single users, thus the user id is not used. func (m Manager) GetUserInterfaces(ctx context.Context, userId domain.UserIdentifier) ([]domain.Interface, error) {
func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier) ([]domain.Interface, error) {
if !m.cfg.Core.SelfProvisioningAllowed { if !m.cfg.Core.SelfProvisioningAllowed {
return nil, nil // self-provisioning is disabled - no interfaces for users return nil, nil // self-provisioning is disabled - no interfaces for users
} }
user, err := m.db.GetUser(ctx, userId)
if err != nil {
slog.Error("failed to load user for interface group verification", "user", userId, "error", err)
return nil, nil // fail closed
}
interfaces, err := m.db.GetAllInterfaces(ctx) interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to load all interfaces: %w", err) return nil, fmt.Errorf("unable to load all interfaces: %w", err)
@@ -83,6 +93,9 @@ func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier)
if iface.Type != domain.InterfaceTypeServer { if iface.Type != domain.InterfaceTypeServer {
continue // skip client interfaces continue // skip client interfaces
} }
if !user.IsAdmin && !iface.IsUserAllowed(userId, m.cfg) {
continue // user not allowed due to LDAP group filter
}
userInterfaces = append(userInterfaces, iface.PublicInfo()) userInterfaces = append(userInterfaces, iface.PublicInfo())
} }
@@ -374,7 +387,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, CreateDefaultPeer: m.cfg.DefaultPeerCreationEnabled(),
DriverType: "", DriverType: "",
Disabled: nil, Disabled: nil,
DisabledReason: "", DisabledReason: "",
@@ -880,16 +893,7 @@ func (m Manager) importInterface(
} }
} }
// try to predict the interface type based on the number of peers iface.Type = inferImportedInterfaceType(iface, peers)
switch len(peers) {
case 0:
iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface
case 1:
iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface
default: // multiple peers means this is a server interface
iface.Type = domain.InterfaceTypeServer
}
existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) { if err != nil && !errors.Is(err, domain.ErrNotFound) {
@@ -917,6 +921,20 @@ func (m Manager) importInterface(
return nil return nil
} }
func inferImportedInterfaceType(iface *domain.Interface, peers []domain.PhysicalPeer) domain.InterfaceType {
switch len(peers) {
case 0:
return domain.InterfaceTypeAny // no peers means this is an unknown interface
case 1:
if iface.ListenPort > 0 {
return domain.InterfaceTypeServer // a listening interface with one peer is commonly a site-to-site server
}
return domain.InterfaceTypeClient
default: // multiple peers means this is a server interface
return domain.InterfaceTypeServer
}
}
// extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers // 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 // 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) { func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) {

View File

@@ -6,9 +6,53 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
func TestInferImportedInterfaceType(t *testing.T) {
tests := []struct {
name string
listenPort int
peerCount int
expected domain.InterfaceType
}{
{
name: "no peers stays unknown",
listenPort: 51820,
peerCount: 0,
expected: domain.InterfaceTypeAny,
},
{
name: "single peer with listen port is server",
listenPort: 51820,
peerCount: 1,
expected: domain.InterfaceTypeServer,
},
{
name: "single peer without listen port stays client",
listenPort: 0,
peerCount: 1,
expected: domain.InterfaceTypeClient,
},
{
name: "multiple peers is server",
listenPort: 0,
peerCount: 2,
expected: domain.InterfaceTypeServer,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
iface := &domain.Interface{ListenPort: tt.listenPort}
peers := make([]domain.PhysicalPeer, tt.peerCount)
assert.Equal(t, tt.expected, inferImportedInterfaceType(iface, peers))
})
}
}
func TestImportPeer_AddressMapping(t *testing.T) { func TestImportPeer_AddressMapping(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -92,3 +136,119 @@ func TestImportPeer_AddressMapping(t *testing.T) {
}) })
} }
} }
func TestInterface_IsUserAllowed(t *testing.T) {
cfg := &config.Config{
Auth: config.Auth{
Ldap: []config.LdapProvider{
{
ProviderName: "ldap1",
InterfaceFilter: map[string]string{
"wg0": "(memberOf=CN=VPNUsers,...)",
},
},
},
},
}
tests := []struct {
name string
iface domain.Interface
userId domain.UserIdentifier
expect bool
}{
{
name: "Unrestricted interface",
iface: domain.Interface{
Identifier: "wg1",
},
userId: "user1",
expect: true,
},
{
name: "Restricted interface - user allowed",
iface: domain.Interface{
Identifier: "wg0",
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"user1"},
},
},
userId: "user1",
expect: true,
},
{
name: "Restricted interface - user allowed (at least one match)",
iface: domain.Interface{
Identifier: "wg0",
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"user2"},
"ldap2": {"user1"},
},
},
userId: "user1",
expect: true,
},
{
name: "Restricted interface - user NOT allowed",
iface: domain.Interface{
Identifier: "wg0",
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"user2"},
},
},
userId: "user1",
expect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expect, tt.iface.IsUserAllowed(tt.userId, cfg))
})
}
}
func TestManager_GetUserInterfaces_Filtering(t *testing.T) {
cfg := &config.Config{}
cfg.Core.SelfProvisioningAllowed = true
cfg.Auth.Ldap = []config.LdapProvider{
{
ProviderName: "ldap1",
InterfaceFilter: map[string]string{
"wg_restricted": "(some-filter)",
},
},
}
db := &mockDB{
interfaces: []domain.Interface{
{Identifier: "wg_public", Type: domain.InterfaceTypeServer},
{
Identifier: "wg_restricted",
Type: domain.InterfaceTypeServer,
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"allowed_user"},
},
},
},
}
m := Manager{
cfg: cfg,
db: db,
}
t.Run("Allowed user sees both", func(t *testing.T) {
ifaces, err := m.GetUserInterfaces(context.Background(), "allowed_user")
assert.NoError(t, err)
assert.Equal(t, 2, len(ifaces))
})
t.Run("Unallowed user sees only public", func(t *testing.T) {
ifaces, err := m.GetUserInterfaces(context.Background(), "other_user")
assert.NoError(t, err)
assert.Equal(t, 1, len(ifaces))
if len(ifaces) > 0 {
assert.Equal(t, domain.InterfaceIdentifier("wg_public"), ifaces[0].Identifier)
}
})
}

View File

@@ -15,6 +15,10 @@ import (
// CreateDefaultPeer creates a default peer for the given user on all server interfaces. // CreateDefaultPeer creates a default peer for the given user on all server interfaces.
func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdentifier) error { func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdentifier) error {
if !m.cfg.DefaultPeerCreationEnabled() {
return nil
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil { if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err return err
} }
@@ -24,39 +28,21 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
return fmt.Errorf("failed to fetch all interfaces: %w", err) return fmt.Errorf("failed to fetch all interfaces: %w", err)
} }
userPeers, err := m.db.GetUserPeers(context.Background(), userId) user, err := m.db.GetUser(ctx, userId)
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err) return fmt.Errorf("failed to fetch user: %w", err)
} }
var newPeers []domain.Peer var newPeers []domain.Peer
for _, iface := range existingInterfaces { for _, iface := range existingInterfaces {
if iface.Type != domain.InterfaceTypeServer { peer, err := m.prepareDefaultPeer(ctx, &iface, user)
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 {
return peer.InterfaceIdentifier == iface.Identifier
})
if peerAlreadyCreated {
continue // skip creation if a peer already exists for this interface
}
peer, err := m.PreparePeer(ctx, iface.Identifier)
if err != nil { if err != nil {
return fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err) return fmt.Errorf("failed to prepare default peer: %w", err)
} }
peer.UserIdentifier = userId if peer != nil {
peer.Notes = fmt.Sprintf("Default peer created for user %s", userId) newPeers = append(newPeers, *peer)
peer.AutomaticallyCreated = true }
peer.GenerateDisplayName("Default")
newPeers = append(newPeers, *peer)
} }
for i, peer := range newPeers { for i, peer := range newPeers {
@@ -67,9 +53,61 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
} }
} }
slog.InfoContext(ctx, "created default peers for user", slog.InfoContext(ctx, "created default peers for user", "user", userId, "count", len(newPeers))
"user", userId,
"count", len(newPeers)) return nil
}
// CreateDefaultPeers creates default peers for all existing users on the given interface.
func (m Manager) CreateDefaultPeers(ctx context.Context, interfaceId domain.InterfaceIdentifier) error {
if !m.cfg.DefaultPeerCreationEnabled() {
return nil
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
iface, err := m.db.GetInterface(ctx, interfaceId)
if err != nil {
return fmt.Errorf("failed to fetch interface %s: %w", interfaceId, err)
}
if !iface.CreateDefaultPeers() {
return nil
}
users, err := m.db.GetAllUsers(ctx)
if err != nil {
return fmt.Errorf("failed to fetch all users: %w", err)
}
var errs error
var peerCount int
for _, user := range users {
peer, err := m.prepareDefaultPeer(ctx, iface, &user)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to prepare default peer for user %s: %w",
user.Identifier, err))
continue
}
if peer == nil {
continue
}
_, err = m.CreatePeer(ctx, peer)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to create default peer for user %s: %w",
user.Identifier, err))
continue
}
peerCount++
}
if errs != nil {
return fmt.Errorf("failed to create default peers for interface %s: %w", interfaceId, errs)
}
slog.InfoContext(ctx, "created default peers for interface", "interface", interfaceId, "count", peerCount)
return nil return nil
} }
@@ -93,6 +131,10 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
currentUser := domain.GetUserInfo(ctx) currentUser := domain.GetUserInfo(ctx)
if err := m.checkInterfaceAccess(ctx, id); err != nil {
return nil, err
}
iface, err := m.db.GetInterface(ctx, id) iface, err := m.db.GetInterface(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to find interface %s: %w", id, err) return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
@@ -188,6 +230,9 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil { if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err return nil, err
} }
if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
return nil, err
}
} }
sessionUser := domain.GetUserInfo(ctx) sessionUser := domain.GetUserInfo(ctx)
@@ -304,6 +349,10 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
return nil, err return nil, err
} }
if err := m.checkInterfaceAccess(ctx, existingPeer.InterfaceIdentifier); err != nil {
return nil, err
}
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil { if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err) return nil, fmt.Errorf("update not allowed: %w", err)
} }
@@ -373,6 +422,10 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
return err return err
} }
if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
return err
}
if err := m.validatePeerDeletion(ctx, peer); err != nil { if err := m.validatePeerDeletion(ctx, peer); err != nil {
return fmt.Errorf("delete not allowed: %w", err) return fmt.Errorf("delete not allowed: %w", err)
} }
@@ -606,4 +659,57 @@ func (m Manager) validatePeerDeletion(ctx context.Context, _ *domain.Peer) error
return nil return nil
} }
func (m Manager) checkInterfaceAccess(ctx context.Context, id domain.InterfaceIdentifier) error {
user := domain.GetUserInfo(ctx)
if user.IsAdmin {
return nil
}
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
return fmt.Errorf("failed to get interface %s: %w", id, err)
}
if !iface.IsUserAllowed(user.Id, m.cfg) {
return fmt.Errorf("user %s is not allowed to access interface %s: %w", user.Id, id, domain.ErrNoPermission)
}
return nil
}
func (m Manager) prepareDefaultPeer(ctx context.Context, iface *domain.Interface, user *domain.User) (
*domain.Peer,
error,
) {
if !iface.CreateDefaultPeers() || !user.CreateDefaultPeers() {
return nil, nil
}
userPeers, err := m.db.GetUserPeers(ctx, user.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err)
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
// Ignore the AutomaticallyCreated flag on the peer.
// If a user already has a peer for a given interface, no default peer should be created.
return peer.InterfaceIdentifier == iface.Identifier
})
if peerAlreadyCreated {
return nil, nil // skip creation if a peer already exists for this interface
}
peer, err := m.PreparePeer(ctx, iface.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err)
}
peer.UserIdentifier = user.Identifier
peer.Notes = fmt.Sprintf("Default peer created for user %s", user.Identifier)
peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
return peer, nil
}
// endregion helper-functions // endregion helper-functions

View File

@@ -60,6 +60,8 @@ func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.Pin
type mockDB struct { type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface iface *domain.Interface
interfaces []domain.Interface
users []domain.User
} }
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) { func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
@@ -79,6 +81,9 @@ func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier
return nil, nil return nil, nil
} }
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
if f.interfaces != nil {
return f.interfaces, nil
}
if f.iface != nil { if f.iface != nil {
return []domain.Interface{*f.iface}, nil return []domain.Interface{*f.iface}, nil
} }
@@ -137,6 +142,15 @@ func (f *mockDB) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr)
) { ) {
return map[domain.Cidr][]domain.Cidr{}, nil return map[domain.Cidr][]domain.Cidr{}, nil
} }
func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
return &domain.User{
Identifier: id,
IsAdmin: false,
}, nil
}
func (f *mockDB) GetAllUsers(ctx context.Context) ([]domain.User, error) {
return f.users, nil
}
// --- Test --- // --- Test ---
@@ -201,7 +215,7 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) { func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
// Arrange // Arrange
cfg := &config.Config{} cfg := &config.Config{}
cfg.Core.CreateDefaultPeer = true cfg.Core.CreateDefaultPeerOnLogin = true
bus := &mockBus{} bus := &mockBus{}
ctrlMgr := &ControllerManager{ ctrlMgr := &ControllerManager{

View File

@@ -214,6 +214,10 @@ type LdapProvider struct {
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database. // If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
RegistrationEnabled bool `yaml:"registration_enabled"` RegistrationEnabled bool `yaml:"registration_enabled"`
// InterfaceFilter allows restricting interfaces using an LDAP filter.
// Map key is the interface identifier (e.g., "wg0"), value is the filter string.
InterfaceFilter map[string]string `yaml:"interface_filter"`
// If LogUserInfo is set to true, the user info retrieved from the LDAP provider will be logged in trace level. // If LogUserInfo is set to true, the user info retrieved from the LDAP provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"` LogUserInfo bool `yaml:"log_user_info"`
} }
@@ -254,6 +258,10 @@ type OpenIDConnectProvider struct {
// AllowedDomains defines the list of allowed domains // AllowedDomains defines the list of allowed domains
AllowedDomains []string `yaml:"allowed_domains"` AllowedDomains []string `yaml:"allowed_domains"`
// AllowedUserGroups defines the list of allowed user groups.
// If not empty, at least one group from the user's group claim must match.
AllowedUserGroups []string `yaml:"allowed_user_groups"`
// FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
FieldMap OauthFields `yaml:"field_map"` FieldMap OauthFields `yaml:"field_map"`
@@ -270,6 +278,19 @@ type OpenIDConnectProvider struct {
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level. // If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production! // This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"` LogSensitiveInfo bool `yaml:"log_sensitive_info"`
// UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow.
// If unset, PKCE is enabled by default.
UsePKCE *bool `yaml:"use_pkce"`
// PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain".
// If empty, "S256" is used.
PKCEMethod string `yaml:"pkce_method"`
// LogoutIdpSession controls whether the user's session at the OIDC provider is terminated on logout.
// If set to true (default), the user will be redirected to the IdP's end_session_endpoint after local logout.
// If set to false, only the local wg-portal session is invalidated.
LogoutIdpSession *bool `yaml:"logout_idp_session"`
} }
// OAuthProvider contains the configuration for the OAuth provider. // OAuthProvider contains the configuration for the OAuth provider.
@@ -299,6 +320,10 @@ type OAuthProvider struct {
// AllowedDomains defines the list of allowed domains // AllowedDomains defines the list of allowed domains
AllowedDomains []string `yaml:"allowed_domains"` AllowedDomains []string `yaml:"allowed_domains"`
// AllowedUserGroups defines the list of allowed user groups.
// If not empty, at least one group from the user's group claim must match.
AllowedUserGroups []string `yaml:"allowed_user_groups"`
// FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
FieldMap OauthFields `yaml:"field_map"` FieldMap OauthFields `yaml:"field_map"`
@@ -315,6 +340,14 @@ type OAuthProvider struct {
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level. // If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production! // This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"` LogSensitiveInfo bool `yaml:"log_sensitive_info"`
// UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow.
// If unset, PKCE is enabled by default.
UsePKCE *bool `yaml:"use_pkce"`
// PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain".
// If empty, "S256" is used.
PKCEMethod string `yaml:"pkce_method"`
} }
// WebauthnConfig contains the configuration for the WebAuthn authenticator. // WebauthnConfig contains the configuration for the WebAuthn authenticator.

View File

@@ -10,6 +10,8 @@ const LocalBackendName = "local"
type Backend struct { type Backend struct {
Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) Default string `yaml:"default"` // The default backend to use (defaults to the internal backend)
ReKeyTimeoutInterval time.Duration `yaml:"rekey_timeout_interval"` // Interval after which a connection is assumed dead
// Local Backend-specific configuration // Local Backend-specific configuration
IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0") IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
@@ -115,8 +117,8 @@ func (b *BackendMikrotik) GetApiTimeout() time.Duration {
type BackendPfsense struct { type BackendPfsense struct {
BackendBase `yaml:",inline"` // Embed the base fields 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") 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') 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 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) ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds)

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