Compare commits

..

119 Commits

Author SHA1 Message Date
Christoph Haas
18296673d7 Merge branch 'master' into stable 2025-05-13 20:25:27 +02:00
Christoph Haas
7df4e4b813 fix minor frontend glitches 2025-05-13 20:18:17 +02:00
dependabot[bot]
657c4307b3 chore(deps): bump gorm.io/gorm from 1.25.12 to 1.26.1 in the gorm group (#415)
Bumps the gorm group with 1 update: [gorm.io/gorm](https://github.com/go-gorm/gorm).


Updates `gorm.io/gorm` from 1.25.12 to 1.26.1
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.12...v1.26.1)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gorm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-13 20:13:29 +02:00
dependabot[bot]
b918fb6522 chore(deps): bump github.com/vishvananda/netlink in the patch group (#434)
Bumps the patch group with 1 update: [github.com/vishvananda/netlink](https://github.com/vishvananda/netlink).


Updates `github.com/vishvananda/netlink` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/vishvananda/netlink/releases)
- [Commits](https://github.com/vishvananda/netlink/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/vishvananda/netlink
  dependency-version: 1.3.1
  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>
2025-05-13 20:12:52 +02:00
Christoph Haas
78deede360 Separate tag-based and branch-based documentation deployment
Updated the workflow to deploy documentation with `latest` alias only on tagged refs, while regular deployments occur for branches without updating aliases. This ensures proper versioning and prevents unintended alias updates.
2025-05-13 20:01:33 +02:00
Christoph Haas
a8fb4365cf Separate tag-based and branch-based documentation deployment
Updated the workflow to deploy documentation with `latest` alias only on tagged refs, while regular deployments occur for branches without updating aliases. This ensures proper versioning and prevents unintended alias updates.
2025-05-13 19:34:19 +02:00
dependabot[bot]
0102588d23 chore(deps): bump golang.org/x/crypto in the golang group (#433)
Bumps the golang group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.37.0 to 0.38.0
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-12 22:53:05 +02:00
Christoph Haas
6a96925be7 add API endpoints to prepare fresh interfaces and peers (#432) 2025-05-09 16:19:36 +02:00
Rafael Alexandre
f018babca7 update portuguese translations (#430)
Signed-off-by: Rafael Alexandre <r.alexandre99@gmail.com>
2025-05-09 15:45:13 +02:00
Christoph Haas
c6253e7c15 clarify Docker image version tags, remove stable and legacy builds (#191) 2025-05-09 15:42:08 +02:00
dependabot[bot]
2a1d82251e chore(deps): bump the golang group with 2 updates (#429)
Bumps the golang group with 2 updates: [golang.org/x/oauth2](https://github.com/golang/oauth2) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/oauth2` from 0.29.0 to 0.30.0
- [Commits](https://github.com/golang/oauth2/compare/v0.29.0...v0.30.0)

Updates `golang.org/x/sys` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/sys/compare/v0.32.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 18:33:44 +02:00
Christoph Haas
99d6ce73ad update documentation for allowed_domains in oauth and oidc (#416) 2025-05-05 18:33:05 +02:00
Vladimir Dombrovski
3eb84f0ee9 Enable allowed_domains in oauth and oidc providers (#416)
* Enable allowed_domains in oauth and oidc providers

Signed-off-by: Vladimir DOMBROVSKI <vladimir.dombrovski@bso.co>

* Domain check code cleanup

* Run gofmt on domain validation code

---------

Signed-off-by: Vladimir DOMBROVSKI <vladimir.dombrovski@bso.co>
2025-05-05 18:26:19 +02:00
Christoph Haas
d8a57edef9 fix Docker image tagging 2025-05-05 18:18:56 +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
Christoph Haas
8271dd7c1f Merge branch 'prepare V2 release' 2025-05-04 20:19:19 +02:00
Christoph Haas
4ca37089bc fix browser warnings, update vite 2025-05-04 20:14:40 +02:00
Christoph Haas
8e5d5138c0 fix bootswatch 5.3.5 issue 2025-05-04 20:05:38 +02:00
Christoph Haas
c73286e11a improve german translations 2025-05-04 19:44:25 +02:00
Christoph Haas
b4aa6f8ef3 fix gorm error if no encryption is used (#427) 2025-05-04 17:42:13 +02:00
Christoph Haas
432c627f9b further improve documentation and examples (#423) 2025-05-04 14:48:34 +02:00
Christoph Haas
cd60761ea7 improve docs 2025-05-04 11:16:46 +02:00
Christoph Haas
2c8304417b prepare for v2 release 2025-05-04 11:00:12 +02:00
Christoph Haas
020ebb64e7 docs: add another listening-address example 2025-05-04 09:26:56 +02:00
Christoph Haas
923d4a6188 docs: add reverse-proxy example, improve docker examples, fix slow_query_threshold documentation; feat: allow config.yml and config.yaml as configuration files 2025-05-03 22:21:56 +02:00
Dominik Lakatoš
2b46dca770 generating WG keypair in browser using Web Crypto API (#422) 2025-05-03 07:58:41 +02:00
Christoph Haas
b9c4ca04f5 allow to encrypt keys in db, add browser-only key generator, add hints that private keys are stored on the server (#420) 2025-05-02 18:48:35 +02:00
Christoph Haas
dddf0c475b build v2 tags for release-candidate versions 2025-05-02 10:51:28 +02:00
Christoph Haas
fe60a5ab9b update documentation for Docker usage (#419) 2025-05-02 10:42:33 +02:00
Christoph Haas
e176e07f7d update documentation for Docker usage (#419), include wireguard-tools in Docker image 2025-05-02 10:29:04 +02:00
Christoph Haas
b06c03ef8e fix missing error check (#419) 2025-05-01 19:12:19 +02:00
Christoph Haas
6b0b78d749 docs: add note about running wireguard in Docker (#156) 2025-04-30 22:42:04 +02:00
Vladimir Dombrovski
62f3c8d4a1 Implement EditableKeys parameter (#417)
Signed-off-by: Vladimir DOMBROVSKI <vladimir.dombrovski@bso.co>
2025-04-30 22:05:40 +02:00
acc0mplish
fbcb22198c Added Korean translations (#414) 2025-04-24 14:54:45 +02:00
Rafael Alexandre
2c443a4a9b add portuguese translations (#412)
Signed-off-by: Rafael Alexandre <r.alexandre99@gmail.com>
2025-04-22 22:44:05 +02:00
Christoph
059234d416 never publish pointer payloads on message bus (#411) 2025-04-21 16:42:35 +02:00
Christoph
e2966d32ea fix user creation (#411) 2025-04-21 15:29:53 +02:00
Christoph
9354a1d9d3 add simple webhook feature for peer, interface and user events (#398) 2025-04-19 21:29:26 +02:00
Christoph
e75a32e4d0 improve docs regarding external_url (#406) 2025-04-19 18:01:02 +02:00
Christoph
1d94f6baaf change tagged-input-field component, allow to paste multiple values (#365) 2025-04-19 17:43:51 +02:00
Christoph
6681dfa96f generate interface and peer configuration filenames in backend only (#395) 2025-04-19 13:12:31 +02:00
Christoph
a60feb7fc9 fix incorrect documentation for ldap providers (#408) 2025-04-19 12:21:45 +02:00
Christoph
37904f96fb run initial LDAP sync on startup (#407) 2025-04-19 12:12:45 +02:00
Christoph
1e9ee25e49 chore: update dependencies 2025-04-19 12:07:09 +02:00
dependabot[bot]
30eac7c44a chore(deps): bump the golang group with 3 updates (#400)
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 golang group with 3 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/oauth2](https://github.com/golang/oauth2) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/crypto` from 0.36.0 to 0.37.0
- [Commits](https://github.com/golang/crypto/compare/v0.36.0...v0.37.0)

Updates `golang.org/x/oauth2` from 0.28.0 to 0.29.0
- [Commits](https://github.com/golang/oauth2/compare/v0.28.0...v0.29.0)

Updates `golang.org/x/sys` from 0.31.0 to 0.32.0
- [Commits](https://github.com/golang/sys/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 18:31:59 +02:00
dependabot[bot]
801ce76616 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.13.0 to 3.14.1 (#399)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.13.0 to 3.14.1.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.13.0...v3.14.1)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 18:31:22 +02:00
dependabot[bot]
5f9c3bab3e chore(deps): bump github.com/go-playground/validator/v10 (#396)
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.25.0 to 10.26.0.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.25.0...v10.26.0)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  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>
2025-04-03 06:46:31 +02:00
dependabot[bot]
e19f42b1eb chore(deps): bump github.com/go-pkgz/routegroup from 1.3.1 to 1.4.1 (#397)
Bumps [github.com/go-pkgz/routegroup](https://github.com/go-pkgz/routegroup) from 1.3.1 to 1.4.1.
- [Release notes](https://github.com/go-pkgz/routegroup/releases)
- [Commits](https://github.com/go-pkgz/routegroup/compare/v1.3.1...v1.4.1)

---
updated-dependencies:
- dependency-name: github.com/go-pkgz/routegroup
  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>
2025-04-03 06:33:48 +02:00
Christoph Haas
34fb373659 chore: update frontend dependencies 2025-03-30 23:21:29 +02:00
Christoph Haas
b938bc8c4c fix: fix peer audit event 2025-03-30 23:16:10 +02:00
Christoph Haas
87bf5da5bd fix: fix session handling (remove IdleTimeout) 2025-03-30 23:14:49 +02:00
Christoph Haas
3723e4cc75 fix: fix csrf token handling after login 2025-03-29 17:21:54 +01:00
Christoph Haas
6cbccf6d43 feat: add simple audit ui 2025-03-29 16:42:31 +01:00
Christoph Haas
a49cfa6343 chore: update dependencies 2025-03-23 23:12:47 +01:00
Christoph Haas
fe681c015c Merge branch 'master' into chore-code-cleanup
# Conflicts:
#	go.mod
#	go.sum
2025-03-23 23:10:34 +01:00
Christoph Haas
7d0da4e7ad chore: use interfaces for all other services 2025-03-23 23:09:47 +01:00
dependabot[bot]
3218bdd6fb chore(deps): bump the patch group across 1 directory with 2 updates (#391)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-03-17 19:04:11 +01:00
dependabot[bot]
12ccd6e32d chore(deps): bump the golang group with 3 updates (#389) 2025-03-17 18:57:42 +01:00
Christoph Haas
02ed7b19df chore: use interfaces for web related services 2025-03-09 21:48:38 +01:00
Christoph Haas
678b6c6456 Merge branch 'master' into chore-code-cleanup
# Conflicts:
#	go.mod
#	go.sum
2025-03-09 21:17:47 +01:00
Christoph Haas
0206952182 chore: replace gin with standard lib net/http 2025-03-09 21:16:42 +01:00
klmmr
53bae9d194 config: validate mail configuration certificates by default (#388)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Before this commit, the default was to not validate TLS certificates of
the SMTP server. This is perhaps a rather unexpected default and can be
considered insecure. This commit activates mail server TLS cert validation
by default.

This change might break some users' email configuration, if they did not
explicitly set the `mail.cert_validation` config variable. Nonetheless,
I think that the secure option should be the default option (e.g.,
to prevent man-in-the-middle attacks and breaching mail server login
credentials).

Signed-off-by: klmmr <35450576+klmmr@users.noreply.github.com>
2025-03-05 19:20:57 +01:00
Dmytro Bondar
f616a9f5f4 chore(deps): update frontend packages (#387)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-03-04 22:23:37 +01:00
Dmytro Bondar
bf5453c264 chore(deps): update Go version to 1.24 in Dockerfile and go.mod
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-03-04 08:48:57 +01:00
dependabot[bot]
fd631d3b9f chore(deps): bump github.com/a8m/envsubst in the patch group
Bumps the patch group with 1 update: [github.com/a8m/envsubst](https://github.com/a8m/envsubst).


Updates `github.com/a8m/envsubst` from 1.4.2 to 1.4.3
- [Release notes](https://github.com/a8m/envsubst/releases)
- [Commits](https://github.com/a8m/envsubst/compare/v1.4.2...v1.4.3)

---
updated-dependencies:
- dependency-name: github.com/a8m/envsubst
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-04 08:48:57 +01:00
dependabot[bot]
9680e8350c chore(deps): bump the golang group with 2 updates (#384)
Bumps the golang group with 2 updates: [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/oauth2](https://github.com/golang/oauth2).


Updates `golang.org/x/crypto` from 0.34.0 to 0.35.0
- [Commits](https://github.com/golang/crypto/compare/v0.34.0...v0.35.0)

Updates `golang.org/x/oauth2` from 0.26.0 to 0.27.0
- [Commits](https://github.com/golang/oauth2/compare/v0.26.0...v0.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 08:39:33 +01:00
Christoph Haas
7473132932 chore: replace logrus with standard lib log/slog 2025-03-02 08:51:13 +01:00
Christoph Haas
5c51573874 chore: update to yaml v3 2025-02-28 16:15:22 +01:00
Christoph Haas
fdb436b135 chore: get rid of static code warnings 2025-02-28 16:11:55 +01:00
Christoph Haas
e24acfa57d chore: cleanup code formatting 2025-02-28 08:37:55 +01:00
Dmytro Bondar
10332c7f9a feat(helm): add optional volumeName to persistence configuration #379 (#380)
Some checks failed
Chart / lint-test (push) Has been cancelled
Chart / publish (push) Has been cancelled
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-02-27 22:58:15 +01:00
Christoph Haas
f7d7038829 chore: update to Go 1.24, improve oauth admin mapping tests 2025-02-27 22:32:11 +01:00
Christoph Haas
66ccdc29e9 fix qr-code generation for large configurations (#374)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-02-26 22:59:11 +01:00
Christoph Haas
40b4538e78 implement checkall checkbox (#372) 2025-02-26 22:24:37 +01:00
Christoph Haas
986f6fdead fix peer creation for client interface (#371) 2025-02-26 22:02:53 +01:00
dependabot[bot]
dabdf111f9 chore(deps): bump github.com/prometheus/client_golang (#377)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.5 to 1.21.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.5...v1.21.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  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>
2025-02-26 21:57:06 +01:00
dependabot[bot]
b074af6dc5 chore(deps): bump golang.org/x/crypto in the golang group (#376)
Bumps the golang group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.33.0 to 0.34.0
- [Commits](https://github.com/golang/crypto/compare/v0.33.0...v0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-26 21:56:54 +01:00
klmmr
eeb0c87c68 ldap-sync: fix creation of only one user per LDAP sync (#375)
Before this fix, a too early `return` statement terminated the
`updateLdapUsers()` function, whenever one not already existing user was
created. Therefore, in each LDAP sync a maximum of one new user could be
created (i.e., it took x LDAP sync cycles until x new LDAP users are
registered in wg-portal). Depending on the LDAP `sync_interval` this can
take a long time and produces unecessary long waiting times until users
are available in wg-portal.

Removing the early return statement, and move the remainder of the
function into an `else` statement, so that all new users can be
added in a single LDAP sync.

Also adding a debug statement to better trace the behavior.

Signed-off-by: klmmr <35450576+klmmr@users.noreply.github.com>
2025-02-26 21:56:22 +01:00
dependabot[bot]
67f076effe chore(deps): bump github.com/yeqown/go-qrcode/v2 in the patch group (#370)
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 1 update: [github.com/yeqown/go-qrcode/v2](https://github.com/yeqown/go-qrcode).


Updates `github.com/yeqown/go-qrcode/v2` from 2.2.4 to 2.2.5
- [Release notes](https://github.com/yeqown/go-qrcode/releases)
- [Commits](https://github.com/yeqown/go-qrcode/compare/v2.2.4...v2.2.5)

---
updated-dependencies:
- dependency-name: github.com/yeqown/go-qrcode/v2
  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>
2025-02-17 19:15:27 +01:00
Christoph Haas
f6d7a851d1 frontend: fix locked user display (#367)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-02-17 08:18:36 +01:00
Christoph Haas
fc712ebf42 api: fix ExpiredAt format (#368) 2025-02-17 08:03:43 +01:00
Christoph Haas
43163273fa api: remove IsAdmin from required attributes (#366) 2025-02-17 07:43:31 +01:00
dependabot[bot]
5697c2b7f2 chore(deps): bump the golang group with 3 updates (#363)
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 golang group with 3 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/oauth2](https://github.com/golang/oauth2) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/crypto` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/crypto/compare/v0.32.0...v0.33.0)

Updates `golang.org/x/oauth2` from 0.25.0 to 0.26.0
- [Commits](https://github.com/golang/oauth2/compare/v0.25.0...v0.26.0)

Updates `golang.org/x/sys` from 0.29.0 to 0.30.0
- [Commits](https://github.com/golang/sys/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-10 18:19:02 +01:00
Christoph Haas
e983a7b8f3 automatic API access for default admin (#357)
Some checks failed
Chart / lint-test (push) Has been cancelled
Chart / publish (push) Has been cancelled
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-02-07 22:42:48 +01:00
Christoph Haas
c33eaba1c0 remove unsupported validator (#360) 2025-02-07 22:21:16 +01:00
Dmytro Bondar
3774257abb Added Ukrainian translations (#361)
Signed-off-by: Dmytro Bondar <git@bonddim.dev>
2025-02-07 22:04:26 +01:00
klmmr
588f09bdaa [DOCS] Fix example config wrt. admin_value_regex and admin_group_regex (#362)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-02-07 17:59:58 +01:00
JBSAN3
7557a6ef5a Add French in to translations (#359)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-02-06 15:06:39 +01:00
dependabot[bot]
3478645317 chore(deps): bump github.com/prometheus-community/pro-bing (#356)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-02-03 20:42:25 +01:00
Dmytro Bondar
a950dd76ba Added issue and pull request templates (#355)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-01-28 21:43:31 +01:00
dependabot[bot]
8c0ecec485 chore(deps): bump github.com/prometheus-community/pro-bing (#354) 2025-01-28 19:05:32 +01:00
Christoph Haas
d01d865b4d fix self provisioning feature (#272)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-01-26 11:35:24 +01:00
Christoph Haas
1b8cdc3417 automatically append listening port to endpoint address (#352) 2025-01-26 09:52:09 +01:00
Christoph Haas
d35889de73 remove external google fonts (#107)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-01-25 23:06:44 +01:00
Dmytro Bondar
0b18b5efd6 [chart] Fix default configurations (#350)
Some checks failed
Chart / lint-test (push) Has been cancelled
Chart / publish (push) Has been cancelled
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-01-24 12:48:36 +01:00
Dmytro Bondar
2cf2341e4c [chart] Update helm chart (#349)
Some checks are pending
Chart / lint-test (push) Waiting to run
Chart / publish (push) Waiting to run
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-01-23 13:42:51 +01:00
Dmytro Bondar
043d25a08f [docs] big bang update (#348)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
* [docs] big bang update

* Simplified polluted README.md by moving parts to the documentation
* Removed duplicates with `pymdownx.snippets` extension
* Enabled code copy
* Extended "Getting Started"
* Added "Monitoring" page
* Separated "Upgrade" page
* Added default config yaml to docs

Signed-off-by: Dmytro Bondar <git@bonddim.dev>

* Update sources.md

Co-authored-by: h44z <christoph.h@sprinternet.at>

---------

Signed-off-by: Dmytro Bondar <git@bonddim.dev>
Co-authored-by: h44z <christoph.h@sprinternet.at>
2025-01-23 08:06:55 +01:00
Christoph Haas
f6c8cd5ea8 allow LDAP users (and linked peers) to be automatically re-enabled (#345)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-01-21 18:03:30 +01:00
Christoph Haas
a04eaa4bfb fix user group parsing for OAuth login (#317) 2025-01-21 17:33:01 +01:00
Dmytro Bondar
7a0a2117f5 Remove Swagger Authorize button from published docs (#347)
* Remove Swagger *Authorize* button from published docs

* Ignore mkdocs output dir

* tidy mods
2025-01-21 12:31:28 +01:00
Dmytro Bondar
2cea2e477a Show version on frontend (#346) 2025-01-21 12:27:25 +01:00
Christoph Haas
c2658534b0 chore: publish more docker version tags, migrate to semver
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-01-18 19:09:43 +01:00
Christoph Haas
2030c59362 chore: publish more docker version tags, migrate to semver 2025-01-18 19:02:36 +01:00
Christoph Haas
e31c170f48 Revert "chore: publish more docker version tags, migrate to semver"
This reverts commit 075fd0171e.
2025-01-18 18:51:04 +01:00
Christoph Haas
49a987cbce Revert "chore: publish more docker version tags, migrate to semver"
This reverts commit 3526240faf.
2025-01-18 18:51:04 +01:00
Christoph Haas
3526240faf chore: publish more docker version tags, migrate to semver 2025-01-18 18:24:01 +01:00
Christoph Haas
075fd0171e chore: publish more docker version tags, migrate to semver 2025-01-18 18:10:51 +01:00
Christoph Haas
c73ce0288e fix disabling of missing ldap users (#344) and allow deletion of all user types 2025-01-18 17:39:18 +01:00
Christoph Haas
31c0daeba8 fix .gitignore
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-01-18 12:13:09 +01:00
Christoph Haas
662e9c0549 Improve admin privilege handling for OAuth. Update documentation. 2025-01-18 11:55:56 +01:00
Christoph Haas
6523a87dfb fix peer disable if ldap user is disabled (#343)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
2025-01-17 21:59:15 +01:00
Christoph Haas
7ccec5db8d add swagger doc to mkdocs/website 2025-01-17 21:47:54 +01: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
218 changed files with 17680 additions and 4173 deletions

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve WG-Portal
labels: bug
---
<!-- Tip: you can use code blocks
for better formatting of yaml config or logs
```yaml
# config.yaml
```
```console
logs here
``` -->
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Steps to reproduce**
<!--Steps to reproduce the bug should be clear and easily reproducible to help people
gain an understanding of the problem.-->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Additional context**
<!-- Add any other context about the problem here. -->
- Application version: v
- Install method: binary/docker/helm/sources
<!-- - OS: -->

View File

@@ -0,0 +1,18 @@
---
name: Feature request
about: Suggest an idea for this project
labels: 'enhancement'
---
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

18
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,18 @@
## Problem Statement
What is the problem you're trying to solve?
## Related Issue
Fixes #...
## Proposed Changes
How do you like to solve the issue and why?
## Checklist
- [ ] Commits are signed with `git commit --signoff`
- [ ] Changes have reasonable test coverage
- [ ] Tests pass with `make test`
- [ ] Helm docs are up-to-date with `make helm-docs`

View File

@@ -4,7 +4,7 @@ on:
pull_request:
branches: [master]
push:
branches: [master, stable]
branches: [master]
# Publish vX.X.X tags as releases.
tags: ["v*.*.*"]
@@ -59,12 +59,17 @@ jobs:
tags: |
type=ref,event=tag
type=ref,event=branch
type=ref,event=pr
# semver tags, without v prefix
type=semver,pattern={{version}}
# major and major.minor tags are not available for alpha or beta releases
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
# set latest tag for default branch
type=raw,value=latest,enable={{is_default_branch}}
# add v{{major}} tag, even for beta or release-canidate releases
type=match,pattern=(v\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
# add {{major}} tag, even for beta releases or release-canidate releases
type=match,pattern=v(\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
- name: Build and push Docker image
uses: docker/build-push-action@v6

View File

@@ -2,7 +2,11 @@ name: github-pages
on:
push:
branches: [master]
tags: ["v*"]
tags:
- 'v*'
- '!v*-alpha*'
- '!v*-beta*'
- '!v*-rc*'
permissions:
contents: write
@@ -20,10 +24,17 @@ jobs:
python-version: 3.x
- name: Install dependencies
run: pip install mike mkdocs-material[imaging] mkdocs-minify-plugin
run: pip install mike mkdocs-material[imaging] mkdocs-minify-plugin mkdocs-swagger-ui-tag
- name: Publish documentation
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
if: ${{ ! startsWith(github.ref, 'refs/tags/') }}
run: mike deploy --push ${{ github.ref_name }}
env:
GIT_COMMITTER_NAME: "github-actions[bot]"
GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish latest documentation
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
env:
GIT_COMMITTER_NAME: "github-actions[bot]"
GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"

5
.gitignore vendored
View File

@@ -32,11 +32,12 @@ ssh.key
.testCoverage.txt
wg_portal.db
sqlite.db
swagger.json
swagger.yaml
/config.yml
/config.yaml
/config/
venv/
.cache/
# ignore local frontend dist directory
internal/app/api/core/frontend-dist
# mkdocs output directory
site/

View File

@@ -20,7 +20,7 @@ RUN npm run build
######
# Build backend
######
FROM --platform=${BUILDPLATFORM} golang:1.23-alpine AS builder
FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder
# Set the working directory
WORKDIR /build
# Download dependencies
@@ -52,7 +52,7 @@ COPY --from=builder /build/dist/wg-portal /
######
FROM alpine:3.19
# Install OS-level dependencies
RUN apk add --no-cache bash curl iptables nftables openresolv
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
# Setup timezone
ENV TZ=UTC
# Copy binaries

View File

@@ -133,3 +133,9 @@ build-docker:
.PHONY: helm-docs
helm-docs:
docker run --rm --volume "${PWD}/deploy:/helm-docs" -u "$$(id -u)" jnorwood/helm-docs -s file
#< run-mkdocs: Run a local instance of MkDocs
.PHONY: run-mkdocs
run-mkdocs:
python -m venv venv; source venv/bin/activate; pip install mike cairosvg mkdocs-material mkdocs-minify-plugin mkdocs-swagger-ui-tag
venv/bin/mkdocs serve

271
README.md
View File

@@ -1,254 +1,67 @@
# WireGuard Portal (v2 - testing)
# WireGuard Portal v2
[![Build Status](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/h44z/wg-portal/actions)
[![Build Status](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml/badge.svg?event=push)](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
![GitHub last commit](https://img.shields.io/github/last-commit/h44z/wg-portal)
![GitHub last commit](https://img.shields.io/github/last-commit/h44z/wg-portal/master)
[![Go Report Card](https://goreportcard.com/badge/github.com/h44z/wg-portal)](https://goreportcard.com/report/github.com/h44z/wg-portal)
![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/h44z/wg-portal)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/h44z/wg-portal)
[![Docker Pulls](https://img.shields.io/docker/pulls/h44z/wg-portal.svg)](https://hub.docker.com/r/wgportal/wg-portal/)
> :warning: **IMPORTANT** Version 2 is currently under development and may contain bugs. It is currently not advised to use this version
in production. Use version [v1](https://github.com/h44z/wg-portal/tree/stable) instead.
Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to: https://hub.docker.com/r/wgportal/wg-portal.
Please update the Docker image from **h44z/wg-portal** to **wgportal/wg-portal**.
A simple, web based configuration portal for [WireGuard](https://wireguard.com).
## Introduction
<!-- Text from this line # is included in docs/documentation/overview.md -->
**WireGuard Portal** is a simple, web-based configuration portal for [WireGuard](https://wireguard.com) server management.
The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage existing VPN
interfaces. This allows for seamless activation or deactivation of new users, without disturbing existing VPN
interfaces. This allows for the seamless activation or deactivation of new users without disturbing existing VPN
connections.
The configuration portal supports using a database (SQLite, MySQL, MsSQL or Postgres), OAuth or LDAP (Active Directory or OpenLDAP) as a user source for authentication and profile data.
The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Postgres), OAuth or LDAP
(Active Directory or OpenLDAP) as a user source for authentication and profile data.
## Features
* Self-hosted - the whole application is a single binary
* Responsive web UI written in Vue.JS
* Automatically select IP from the network pool assigned to client
* QR-Code for convenient mobile client configuration
* Sent email to client with QR-code and client config
* Enable / Disable clients seamlessly
* Generation of wg-quick configuration file (`wgX.conf`) if required
* User authentication (database, OAuth or LDAP)
* IPv6 ready
* Docker ready
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* Peer Expiry Feature
* Handle route and DNS settings like wg-quick does
* Exposes Prometheus [metrics](#metrics)
* REST API for management and client deployment
![Screenshot](screenshot.png)
* Self-hosted - the whole application is a single binary
* Responsive multi-language web UI written in Vue.js
* Automatically selects IP from the network pool assigned to the client
* QR-Code for convenient mobile client configuration
* Sends email to the client with QR-code and client config
* Enable / Disable clients seamlessly
* Generation of wg-quick configuration file (`wgX.conf`) if required
* User authentication (database, OAuth, or LDAP)
* IPv6 ready
* Docker ready
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* Peer Expiry Feature
* Handles route and DNS settings like wg-quick does
* Exposes Prometheus metrics for monitoring and alerting
* REST API for management and client deployment
* Webhook for custom actions on peer, interface, or user updates
<!-- Text to this line # is included in docs/documentation/overview.md -->
![Screenshot](docs/assets/images/screenshot.png)
## Configuration
You can configure WireGuard Portal using a yaml configuration file.
The filepath of the yaml configuration file defaults to **config/config.yml** in the working directory of the executable.
It is possible to override the configuration filepath using the environment variable **WG_PORTAL_CONFIG**.
For example: `WG_PORTAL_CONFIG=/home/test/config.yml ./wg-portal-amd64`.
Also, environment variable substitution in config file is supported. Refer to [syntax](https://github.com/a8m/envsubst?tab=readme-ov-file#docs)
## Documentation
By default, WireGuard Portal uses a SQLite database. The database is stored in **data/sqlite.db** in the working directory of the executable.
### Configuration Options
The following configuration options are available:
| configuration key | parent key | default_value | description |
|----------------------------------|------------|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| admin_user | core | admin@wgportal.local | The administrator user. This user will be created as default admin if it does not yet exist. |
| admin_password | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. |
| editable_keys | core | true | Allow to edit key-pairs in the UI. |
| create_default_peer | core | false | If an LDAP user logs in for the first time and has no peers associated, a new WireGuard peer will be created for all server interfaces. |
| create_default_peer_on_creation | core | false | If an LDAP user is created (e.g. through LDAP sync), a new WireGuard peer will be created for all server interfaces. |
| re_enable_peer_after_user_enable | core | true | Re-enable all peers that were previously disabled due to a user disable action. |
| delete_peer_after_user_deleted | core | false | Delete all linked peers if a user gets disabled. Otherwise the peers only get disabled. |
| self_provisioning_allowed | core | false | Allow registered users to automatically create peers via their profile page. |
| import_existing | core | true | Import existing WireGuard interfaces and peers into WireGuard Portal. |
| restore_state | core | true | Restore the WireGuard interface state after WireGuard Portal has started. |
| log_level | advanced | info | The loglevel, can be one of: trace, debug, info, warn, error. |
| log_pretty | advanced | false | Uses pretty, colorized log messages. |
| log_json | advanced | false | Logs in JSON format. |
| start_listen_port | advanced | 51820 | The first port number that will be used as listening port for new interfaces. |
| start_cidr_v4 | advanced | 10.11.12.0/24 | The first IPv4 subnet that will be used for new interfaces. |
| start_cidr_v6 | advanced | fdfd:d3ad:c0de:1234::0/64 | The first IPv6 subnet that will be used for new interfaces. |
| use_ip_v6 | advanced | true | Enable IPv6 support. |
| config_storage_path | advanced | | If a wg-quick style configuration should be stored to the filesystem, specify a storage directory. |
| expiry_check_interval | advanced | 15m | The interval after which existing peers will be checked if they expired. |
| rule_prio_offset | advanced | 20000 | The default offset for ip route rule priorities. |
| route_table_offset | advanced | 20000 | The default offset for ip route table id's. |
| api_admin_only | advanced | true | This flag specifies if the public REST API is available to administrators only. The API Swagger documentation is available under /api/v1/doc.html |
| use_ping_checks | statistics | true | If enabled, peers will be pinged periodically to check if they are still connected. |
| ping_check_workers | statistics | 10 | Number of parallel ping checks that will be executed. |
| ping_unprivileged | statistics | false | If set to false, the ping checks will run without root permissions (BETA). |
| ping_check_interval | statistics | 1m | The interval time between two ping check runs. |
| data_collection_interval | statistics | 1m | The interval between the data collection cycles. |
| collect_interface_data | statistics | true | A flag to enable interface data collection like bytes sent and received. |
| collect_peer_data | statistics | true | A flag to enable peer data collection like bytes sent and received, last handshake and remote endpoint address. |
| collect_audit_data | statistics | true | If enabled, some events, like portal logins, will be logged to the database. |
| listening_address | statistics | :8787 | The listening address of the Prometheus metric server. |
| host | mail | 127.0.0.1 | The mail-server address. |
| port | mail | 25 | The mail-server SMTP port. |
| encryption | mail | none | SMTP encryption type, allowed values: none, tls, starttls. |
| cert_validation | mail | false | Validate the mail server certificate (if encryption tls is used). |
| username | mail | | The SMTP user name. |
| password | mail | | The SMTP password. |
| auth_type | mail | plain | SMTP authentication type, allowed values: plain, login, crammd5. |
| from | mail | Wireguard Portal <noreply@wireguard.local> | The address that is used to send mails. |
| link_only | mail | false | Only send links to WireGuard Portal instead of the full configuration. |
| oidc | auth | Empty Array - no providers configured | A list of OpenID Connect providers. See auth/oidc properties to setup a new provider. |
| oauth | auth | Empty Array - no providers configured | A list of plain OAuth providers. See auth/oauth properties to setup a new provider. |
| ldap | auth | Empty Array - no providers configured | A list of LDAP providers. See auth/ldap properties to setup a new provider. |
| provider_name | auth/oidc | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oidc | | The display name is shown at the login page (the login button). |
| base_url | auth/oidc | | The base_url is the URL identifier for the service. For example: "https://accounts.google.com". |
| client_id | auth/oidc | | The OAuth client id. |
| client_secret | auth/oidc | | The OAuth client secret. |
| extra_scopes | auth/oidc | | Extra scopes that should be used in the OpenID Connect authentication flow. |
| field_map | auth/oidc | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oidc | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| provider_name | auth/oauth | | A unique provider name. This name must be unique throughout all authentication providers (even other types). |
| display_name | auth/oauth | | The display name is shown at the login page (the login button). |
| client_id | auth/oauth | | The OAuth client id. |
| client_secret | auth/oauth | | The OAuth client secret. |
| auth_url | auth/oauth | | The URL for the authentication endpoint. |
| token_url | auth/oauth | | The URL for the token endpoint. |
| user_info_url | auth/oauth | | The URL for the user information endpoint. |
| scopes | auth/oauth | | OAuth scopes. |
| field_map | auth/oauth | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and is_admin. |
| registration_enabled | auth/oauth | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| url | auth/ldap | | The LDAP server url. For example: ldap://srv-ad01.company.local:389 |
| start_tls | auth/ldap | | Use STARTTLS to encrypt LDAP requests. |
| cert_validation | auth/ldap | | Validate the LDAP server certificate. |
| tls_certificate_path | auth/ldap | | A path to the TLS certificate. |
| tls_key_path | auth/ldap | | A path to the TLS key. |
| base_dn | auth/ldap | | The base DN for searching users. For example: DC=COMPANY,DC=LOCAL |
| bind_user | auth/ldap | | The bind user. For example: company\\ldap_wireguard |
| bind_pass | auth/ldap | | The bind password. |
| field_map | auth/ldap | | Mapping of user fields. Internal fields: user_identifier, email, firstname, lastname, phone, department and memberof. |
| login_filter | auth/ldap | | LDAP filters for users that should be allowed to log in. {{login_identifier}} will be replaced with the login username. |
| admin_group | auth/ldap | | Users in this group are marked as administrators. |
| disable_missing | auth/ldap | | If synchronization is enabled, missing LDAP users will be disabled in WireGuard Portal. |
| sync_filter | auth/ldap | | LDAP filters for users that should be synchronized to WireGuard Portal. |
| sync_interval | auth/ldap | | The time interval after which users will be synchronized from LDAP. Empty value or `0` disables synchronization. |
| registration_enabled | auth/ldap | | If registration is enabled, new user accounts will created in WireGuard Portal. |
| debug | database | false | Debug database statements (log each statement). |
| slow_query_threshold | database | | A threshold for slow database queries. If the threshold is exceeded, a warning message will be logged. |
| type | database | sqlite | The database type. Allowed values: sqlite, mssql, mysql or postgres. |
| dsn | database | data/sqlite.db | The database DSN. For example: user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local |
| request_logging | web | false | Log all HTTP requests. |
| external_url | web | http://localhost:8888 | The URL where a client can access WireGuard Portal. |
| listening_address | web | :8888 | The listening port of the web server. |
| session_identifier | web | wgPortalSession | The session identifier for the web frontend. |
| session_secret | web | very_secret | The session secret for the web frontend. |
| csrf_secret | web | extremely_secret | The CSRF secret. |
| site_title | web | WireGuard Portal | The title that is shown in the web frontend. |
| site_company_name | web | WireGuard Portal | The company name that is shown at the bottom of the web frontend. |
| cert_file | web | | (Optional) Path to the TLS certificate file |
| key_file | web | | (Optional) Path to the TLS certificate key file |
## Upgrading from V1
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
The configuration (config.yml) for WireGuard Portal must be updated and valid before starting the upgrade.
To upgrade from a previous SQLite database, start wg-portal like:
```shell
./wg-portal-amd64 -migrateFrom=old_wg_portal.db
```
You can also specify the database type using the parameter **-migrateFromType**, supported types: mysql, mssql, postgres or sqlite.
For example:
```shell
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom=user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
```
The upgrade will transform the old, existing database and store the values in the new database specified in config.yml.
Ensure that the new database does not contain any data!
## V2 TODOs
* Public REST API
* Translations
* Documentation
* Audit UI
## Building
To build a standalone application, use the Makefile provided in the repository.
Go version 1.22 or higher has to be installed to build WireGuard Portal.
If you want to re-compile the frontend, NodeJS 18 and NPM >= 9 is required.
```shell
# build the frontend
make frontend
# build the binary
make build
```
For the complete documentation visit [wgportal.org](https://wgportal.org).
## What is out of scope
* Automatic generation or application of any `iptables` or `nftables` rules.
* Support for operating systems other than linux.
* Automatic import of private keys of an existing WireGuard setup.
* Automatic generation or application of any `iptables` or `nftables` rules.
* Support for operating systems other than linux.
* Automatic import of private keys of an existing WireGuard setup.
## Application stack
* [wgctrl-go](https://github.com/WireGuard/wgctrl-go) and [netlink](https://github.com/vishvananda/netlink) for interface handling
* [Gin](https://github.com/gin-gonic/gin), HTTP web framework written in Go
* [Bootstrap](https://getbootstrap.com/), for the HTML templates
* [Vue.JS](https://vuejs.org/), for the frontend
## Metrics
Metrics are available if interface/peer statistic data collection is enabled.
Add following scrape job to your Prometheus config file:
```yaml
# prometheus.yaml
scrape_configs:
- job_name: "wg-portal"
scrape_interval: 60s
static_configs:
- targets: ["wg-portal:8787"]
```
Exposed metrics:
```console
# HELP wireguard_interface_info Interface info.
# TYPE wireguard_interface_info gauge
# HELP wireguard_interface_received_bytes_total Bytes received througth the interface.
# TYPE wireguard_interface_received_bytes_total gauge
# HELP wireguard_interface_sent_bytes_total Bytes sent through the interface.
# TYPE wireguard_interface_sent_bytes_total gauge
# HELP wireguard_peer_info Peer info.
# TYPE wireguard_peer_info gauge
# HELP wireguard_peer_received_bytes_total Bytes received from the peer.
# TYPE wireguard_peer_received_bytes_total gauge
# HELP wireguard_peer_sent_bytes_total Bytes sent to the peer.
# TYPE wireguard_peer_sent_bytes_total gauge
# HELP wireguard_peer_up Peer connection state (boolean: 1/0).
# TYPE wireguard_peer_up gauge
# HELP wireguard_peer_last_handshake_seconds Seconds from the last handshake with the peer.
# TYPE wireguard_peer_last_handshake_seconds gauge
```
* [wgctrl-go](https://github.com/WireGuard/wgctrl-go) and [netlink](https://github.com/vishvananda/netlink) for interface handling
* [Bootstrap](https://getbootstrap.com/), for the HTML templates
* [Vue.js](https://vuejs.org/), for the frontend
## License
* MIT License. [MIT](LICENSE.txt) or https://opensource.org/licenses/MIT
* MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT>
> [!IMPORTANT]
> Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).
> Please update the Docker image from **h44z/wg-portal** to **wgportal/wg-portal**.

View File

@@ -4,17 +4,17 @@ If you believe you've found a security issue in one of the supported versions of
## Supported Versions
| Version | Supported |
| ------- | -------------------- |
| v2.x | :white_check_mark: |
| v1.x | :white_check_mark: |
| Version | Supported |
|---------|--------------------|
| v2.x | :white_check_mark: |
| v1.x | :white_check_mark: |
## Reporting a Vulnerability
Please do not report security vulnerabilities through public GitHub issues.
Instead, we encourage you to submit a report through Github [private vulnerability reporting](https://github.com/h44z/wg-portal/security).
If you prefer to submit a report without logging in to Github, please email *info (at) wgportal.org*.
Instead, we encourage you to submit a report through GitHub [private vulnerability reporting](https://github.com/h44z/wg-portal/security).
If you prefer to submit a report without logging in to GitHub, please email *info (at) wgportal.org*.
We will respond as soon as possible, but as only two people currently maintain this project, we cannot guarantee specific response times.
We prefer all communications to be in English.

View File

@@ -7,11 +7,15 @@ import (
"path/filepath"
"strings"
"github.com/sirupsen/logrus"
"github.com/swaggo/swag"
"github.com/swaggo/swag/gen"
"gopkg.in/yaml.v3"
)
var apiRootPath = "/internal/app/api"
var apiDocPath = "core/assets/doc"
var apiMkDocPath = "/docs/documentation/rest-api"
// this replaces the call to: swag init --propertyStrategy pascalcase --parseDependency --parseInternal --generalInfo base.go
func main() {
wd, err := os.Getwd() // should be the project root
@@ -19,10 +23,9 @@ func main() {
panic(err)
}
apiBasePath := filepath.Join(wd, "/internal/app/api")
apiBasePath := filepath.Join(wd, apiRootPath)
apis := []string{"v0", "v1"}
hasError := false
for _, apiVersion := range apis {
apiPath := filepath.Join(apiBasePath, apiVersion, "handlers")
@@ -33,16 +36,20 @@ func main() {
err := generateApi(apiBasePath, apiPath, apiVersion)
if err != nil {
hasError = true
logrus.Errorf("failed to generate API docs for %s: %v", apiVersion, err)
log.Fatalf("failed to generate API docs for %s: %v", apiVersion, err)
}
// copy the latest version of the API docs for mkdocs
if apiVersion == apis[len(apis)-1] {
if err = copyDocForMkdocs(wd, apiBasePath, apiVersion); err != nil {
log.Printf("failed to copy API docs for mkdocs: %v", err)
} else {
log.Println("Copied API docs " + apiVersion + " for mkdocs")
}
}
log.Println("Generated swagger docs for API", apiVersion)
}
if hasError {
os.Exit(1)
}
}
func generateApi(basePath, apiPath, version string) error {
@@ -51,7 +58,7 @@ func generateApi(basePath, apiPath, version string) error {
Excludes: "",
MainAPIFile: "base.go",
PropNamingStrategy: swag.PascalCase,
OutputDir: filepath.Join(basePath, "core/assets/doc"),
OutputDir: filepath.Join(basePath, apiDocPath),
OutputTypes: []string{"json", "yaml"},
ParseVendor: false,
ParseDependency: 3,
@@ -68,3 +75,43 @@ func generateApi(basePath, apiPath, version string) error {
return nil
}
func copyDocForMkdocs(workingDir, basePath, version string) error {
srcPath := filepath.Join(basePath, apiDocPath, fmt.Sprintf("%s_swagger.yaml", version))
dstPath := filepath.Join(workingDir, apiMkDocPath, "swagger.yaml")
// copy the file
input, err := os.ReadFile(srcPath)
if err != nil {
return fmt.Errorf("error while reading swagger doc: %w", err)
}
output, err := removeAuthorizeButton(input)
if err != nil {
return fmt.Errorf("error while removing authorize button: %w", err)
}
err = os.WriteFile(dstPath, output, 0644)
if err != nil {
return fmt.Errorf("error while writing swagger doc: %w", err)
}
return nil
}
func removeAuthorizeButton(input []byte) ([]byte, error) {
var swagger map[string]any
err := yaml.Unmarshal(input, &swagger)
if err != nil {
return nil, fmt.Errorf("error while unmarshalling swagger file: %w", err)
}
delete(swagger, "securityDefinitions")
output, err := yaml.Marshal(&swagger)
if err != nil {
return nil, fmt.Errorf("error while marshalling swagger file: %w", err)
}
return output, nil
}

View File

@@ -2,12 +2,20 @@ package main
import (
"context"
"log/slog"
"os"
"strings"
"syscall"
"time"
"github.com/go-playground/validator/v10"
evbus "github.com/vardius/message-bus"
"gorm.io/gorm/schema"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/adapters"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/api/core"
backendV0 "github.com/h44z/wg-portal/internal/app/api/v0/backend"
handlersV0 "github.com/h44z/wg-portal/internal/app/api/v0/handlers"
backendV1 "github.com/h44z/wg-portal/internal/app/api/v1/backend"
handlersV1 "github.com/h44z/wg-portal/internal/app/api/v1/handlers"
@@ -17,29 +25,25 @@ import (
"github.com/h44z/wg-portal/internal/app/mail"
"github.com/h44z/wg-portal/internal/app/route"
"github.com/h44z/wg-portal/internal/app/users"
"github.com/h44z/wg-portal/internal/app/webhooks"
"github.com/h44z/wg-portal/internal/app/wireguard"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/adapters"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/sirupsen/logrus"
evbus "github.com/vardius/message-bus"
)
// main entry point for WireGuard Portal
func main() {
ctx := internal.SignalAwareContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
logrus.Infof("Starting WireGuard Portal V2...")
logrus.Infof("WireGuard Portal version: %s", internal.Version)
slog.Info("Starting WireGuard Portal V2...", "version", internal.Version)
cfg, err := config.GetConfig()
internal.AssertNoError(err)
setupLogging(cfg)
internal.SetupLogging(cfg.Advanced.LogLevel, cfg.Advanced.LogPretty, cfg.Advanced.LogJson)
cfg.LogStartupValues()
dbEncryptedSerializer := app.NewGormEncryptedStringSerializer(cfg.Database.EncryptionPassphrase)
schema.RegisterSerializer("encstr", dbEncryptedSerializer)
rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err)
@@ -57,31 +61,40 @@ func main() {
cfgFileSystem, err := adapters.NewFileSystemRepository(cfg.Advanced.ConfigStoragePath)
internal.AssertNoError(err)
shouldExit, err := app.HandleProgramArgs(cfg, rawDb)
shouldExit, err := app.HandleProgramArgs(rawDb)
switch {
case shouldExit && err == nil:
return
case shouldExit && err != nil:
logrus.Errorf("Failed to process program args: %v", err)
case shouldExit:
slog.Error("Failed to process program args", "error", err)
os.Exit(1)
case !shouldExit:
default:
internal.AssertNoError(err)
}
queueSize := 100
eventBus := evbus.New(queueSize)
auditManager := audit.NewManager(database)
auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database)
internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx)
userManager, err := users.NewUserManager(cfg, eventBus, database, database)
internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
internal.AssertNoError(err)
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
internal.AssertNoError(err)
wireGuardManager.StartBackgroundJobs(ctx)
statisticsCollector, err := wireguard.NewStatisticsCollector(cfg, eventBus, database, wireGuard, metricsServer)
internal.AssertNoError(err)
statisticsCollector.StartBackgroundJobs(ctx)
cfgFileManager, err := configfile.NewConfigFileManager(cfg, eventBus, database, database, cfgFileSystem)
internal.AssertNoError(err)
@@ -89,35 +102,65 @@ func main() {
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
internal.AssertNoError(err)
auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database)
internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx)
routeManager, err := route.NewRouteManager(cfg, eventBus, database)
internal.AssertNoError(err)
routeManager.StartBackgroundJobs(ctx)
backend, err := app.New(cfg, eventBus, authenticator, userManager, wireGuardManager,
statisticsCollector, cfgFileManager, mailManager)
webhookManager, err := webhooks.NewManager(cfg, eventBus)
internal.AssertNoError(err)
err = backend.Startup(ctx)
webhookManager.StartBackgroundJobs(ctx)
err = app.Initialize(cfg, wireGuardManager, userManager)
internal.AssertNoError(err)
apiFrontend := handlersV0.NewRestApi(cfg, backend)
validatorManager := validator.New()
// region API v0 (SPA frontend)
apiV0Session := handlersV0.NewSessionWrapper(cfg)
apiV0Auth := handlersV0.NewAuthenticationHandler(authenticator, apiV0Session)
apiV0BackendUsers := backendV0.NewUserService(cfg, userManager, wireGuardManager)
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth,
apiV0EndpointAudit,
apiV0EndpointUsers,
apiV0EndpointInterfaces,
apiV0EndpointPeers,
apiV0EndpointConfig,
apiV0EndpointTest,
)
// endregion API v0 (SPA frontend)
// region API v1 (User REST API)
apiV1Auth := handlersV1.NewAuthenticationHandler(userManager)
apiV1BackendUsers := backendV1.NewUserService(cfg, userManager)
apiV1BackendPeers := backendV1.NewPeerService(cfg, wireGuardManager, userManager)
apiV1BackendInterfaces := backendV1.NewInterfaceService(cfg, wireGuardManager)
apiV1BackendProvisioning := backendV1.NewProvisioningService(cfg, userManager, wireGuardManager, cfgFileManager)
apiV1BackendMetrics := backendV1.NewMetricsService(cfg, database, userManager, wireGuardManager)
apiV1EndpointUsers := handlersV1.NewUserEndpoint(apiV1BackendUsers)
apiV1EndpointPeers := handlersV1.NewPeerEndpoint(apiV1BackendPeers)
apiV1EndpointInterfaces := handlersV1.NewInterfaceEndpoint(apiV1BackendInterfaces)
apiV1EndpointProvisioning := handlersV1.NewProvisioningEndpoint(apiV1BackendProvisioning)
apiV1EndpointMetrics := handlersV1.NewMetricsEndpoint(apiV1BackendMetrics)
apiV1EndpointUsers := handlersV1.NewUserEndpoint(apiV1Auth, validatorManager, apiV1BackendUsers)
apiV1EndpointPeers := handlersV1.NewPeerEndpoint(apiV1Auth, validatorManager, apiV1BackendPeers)
apiV1EndpointInterfaces := handlersV1.NewInterfaceEndpoint(apiV1Auth, validatorManager, apiV1BackendInterfaces)
apiV1EndpointProvisioning := handlersV1.NewProvisioningEndpoint(apiV1Auth, validatorManager,
apiV1BackendProvisioning)
apiV1EndpointMetrics := handlersV1.NewMetricsEndpoint(apiV1Auth, validatorManager, apiV1BackendMetrics)
apiV1 := handlersV1.NewRestApi(
userManager,
apiV1EndpointUsers,
apiV1EndpointPeers,
apiV1EndpointInterfaces,
@@ -125,47 +168,22 @@ func main() {
apiV1EndpointMetrics,
)
// endregion API v1 (User REST API)
webSrv, err := core.NewServer(cfg, apiFrontend, apiV1)
internal.AssertNoError(err)
go metricsServer.Run(ctx)
go webSrv.Run(ctx, cfg.Web.ListeningAddress)
slog.Info("Application startup complete")
// wait until context gets cancelled
<-ctx.Done()
logrus.Infof("Stopping WireGuard Portal")
slog.Info("Stopping WireGuard Portal")
time.Sleep(5 * time.Second) // wait for (most) goroutines to finish gracefully
logrus.Infof("Stopped WireGuard Portal")
}
func setupLogging(cfg *config.Config) {
switch strings.ToLower(cfg.Advanced.LogLevel) {
case "trace":
logrus.SetLevel(logrus.TraceLevel)
case "debug":
logrus.SetLevel(logrus.DebugLevel)
case "info", "information":
logrus.SetLevel(logrus.InfoLevel)
case "warn", "warning":
logrus.SetLevel(logrus.WarnLevel)
case "error":
logrus.SetLevel(logrus.ErrorLevel)
default:
logrus.SetLevel(logrus.InfoLevel)
}
switch {
case cfg.Advanced.LogJson:
logrus.SetFormatter(&logrus.JSONFormatter{
PrettyPrint: cfg.Advanced.LogPretty,
})
case cfg.Advanced.LogPretty:
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
DisableColors: false,
})
}
slog.Info("Stopped WireGuard Portal")
}

View File

@@ -1,3 +1,5 @@
# More information about the configuration can be found in the documentation: https://wgportal.org/master/documentation/overview/
advanced:
log_level: trace
@@ -11,18 +13,22 @@ web:
external_url: http://localhost:8888
request_logging: true
webhook:
url: ""
authentication: ""
timeout: 10s
auth:
ldap:
- id: ldap1
provider_name: company ldap
display_name: Login with</br>LDAP
url: ldap://ldap.yourcompany.local:389
bind_user: ldap_wireguard@yourcompany.local
bind_pass: super_Secret_PASSWORD
base_dn: DC=YOURCOMPANY,DC=LOCAL
login_filter: (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
admin_group: CN=WireGuardAdmins,OU=it,DC=YOURCOMPANY,DC=LOCAL
synchronize: false
sync_interval: 0 # sync disabled
sync_filter: (&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
registration_enabled: true
oidc:
@@ -63,5 +69,28 @@ auth:
email: email
firstname: name
user_identifier: sub
is_admin: roles
registration_enabled: true
is_admin: this-attribute-must-be-true
registration_enabled: true
- id: google_plain_oauth_with_groups
provider_name: google4
display_name: Login with</br>Google4
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
auth_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
user_info_url: https://openidconnect.googleapis.com/v1/userinfo
scopes:
- openid
- email
- profile
- i-want-some-groups
field_map:
email: email
firstname: name
user_identifier: sub
user_groups: groups
admin_mapping:
admin_value_regex: ^true$
admin_group_regex: ^admin-group-name$
registration_enabled: true
log_user_info: true

View File

@@ -2,10 +2,10 @@ apiVersion: v2
name: wg-portal
description: WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
# Version is set to ensure compatibility with the chart's Ingress resource.
kubeVersion: '>=1.19.0'
kubeVersion: ">=1.19.0"
type: application
home: https://wgportal.org
icon: https://wgportal.org/assets/images/logo.svg
icon: https://wgportal.org/latest/assets/images/logo.svg
sources:
- https://github.com/h44z/wg-portal
@@ -16,10 +16,10 @@ annotations:
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.5.0
version: 0.7.1
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: latest
appVersion: "v2"

View File

@@ -1,6 +1,6 @@
# wg-portal
![Version: 0.5.0](https://img.shields.io/badge/Version-0.5.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: latest](https://img.shields.io/badge/AppVersion-latest-informational?style=flat-square)
![Version: 0.7.1](https://img.shields.io/badge/Version-0.7.1-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
@@ -32,13 +32,13 @@ The [Values](#values) section lists the parameters that can be configured during
| nameOverride | string | `""` | Partially override resource names (adds suffix) |
| fullnameOverride | string | `""` | Fully override resource names |
| extraDeploy | list | `[]` | Array of extra objects to deploy with the release |
| config.advanced | tpl/object | `{}` | Advanced configuration options. |
| config.auth | tpl/object | `{}` | Auth configuration options. |
| config.core | tpl/object | `{}` | Core configuration options.<br> If external admins in `auth` are not defined and there are no `admin_user` and `admin_password` defined here, the default credentials will be generated. |
| config.database | tpl/object | `{}` | Database configuration options |
| config.mail | tpl/object | `{}` | Mail configuration options |
| config.statistics | tpl/object | `{}` | Statistics configuration options |
| config.web | tpl/object | `{}` | Web configuration options.<br> `listening_address` will be set automatically from `service.web.port`. `external_url` is required to enable ingress and certificate resources. |
| config.advanced | tpl/object | `{}` | [Advanced configuration](https://wgportal.org/latest/documentation/configuration/overview/#advanced) options. |
| config.auth | tpl/object | `{}` | [Auth configuration](https://wgportal.org/latest/documentation/configuration/overview/#auth) options. |
| config.core | tpl/object | `{}` | [Core configuration](https://wgportal.org/latest/documentation/configuration/overview/#core) options.<br> If external admins in `auth` are defined and there are no `admin_user` and `admin_password` defined here, the default admin account will be disabled. |
| config.database | tpl/object | `{}` | [Database configuration](https://wgportal.org/latest/documentation/configuration/overview/#database) options |
| config.mail | tpl/object | `{}` | [Mail configuration](https://wgportal.org/latest/documentation/configuration/overview/#mail) options |
| config.statistics | tpl/object | `{}` | [Statistics configuration](https://wgportal.org/latest/documentation/configuration/overview/#statistics) options |
| 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. |
| workloadType | string | `"Deployment"` | Workload type - `Deployment` or `StatefulSet` |
| strategy | object | `{"type":"RollingUpdate"}` | Update strategy for the workload Valid values are: `RollingUpdate` or `Recreate` for Deployment, `RollingUpdate` or `OnDelete` for StatefulSet |
@@ -73,6 +73,7 @@ The [Values](#values) section lists the parameters that can be configured during
| service.web.annotations | object | `{}` | Annotations for the web service |
| service.web.type | string | `"ClusterIP"` | Web service type |
| 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.wireguard.annotations | object | `{}` | Annotations for the WireGuard service |
| 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. |
@@ -81,7 +82,7 @@ The [Values](#values) section lists the parameters that can be configured during
| ingress.className | string | `""` | Ingress class name |
| ingress.annotations | object | `{}` | Ingress annotations |
| 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 |
| certificate.enabled | bool | `false` | Specifies whether a certificate resource should be created. If enabled, certificate will be used for the web. |
| certificate.issuer.name | string | `""` | Certificate issuer name |
| certificate.issuer.kind | string | `""` | Certificate issuer kind (ClusterIssuer or Issuer) |
| certificate.issuer.group | string | `"cert-manager.io"` | Certificate issuer group |
@@ -101,6 +102,7 @@ The [Values](#values) section lists the parameters that can be configured during
| persistence.storageClass | string | `""` | Persistent Volume storage class. If undefined (the default) cluster's default provisioner will be used. |
| persistence.accessMode | string | `"ReadWriteOnce"` | Persistent Volume Access Mode |
| persistence.size | string | `"1Gi"` | Persistent Volume size |
| persistence.volumeName | string | `""` | Persistent Volume Name (optional) |
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
| serviceAccount.annotations | object | `{}` | Service account annotations |
| serviceAccount.automount | bool | `false` | Automatically mount a ServiceAccount's API credentials |

View File

@@ -62,9 +62,9 @@ Create the name of the service account to use
{{- end }}
{{/*
Define default admin credentials
Disables default admin credentials
If external auth is enabled and has admin group mappings,
the admin_user and admin_password values are not used.
the admin_user will be set to blank (disabled).
*/}}
{{- define "wg-portal.admin" -}}
{{- $externalAdmin := false -}}
@@ -80,9 +80,8 @@ the admin_user and admin_password values are not used.
{{- end -}}
{{- end -}}
{{- end -}}
{{- if not $externalAdmin -}}
admin_user: admin@wgportal.local
admin_password: {{ printf "%s/%s" .Release.Name .Release.Namespace | b64enc }}
{{- if $externalAdmin -}}
admin_user: ""
{{- end -}}
{{- end -}}
@@ -90,13 +89,17 @@ admin_password: {{ printf "%s/%s" .Release.Name .Release.Namespace | b64enc }}
Define PersistentVolumeClaim spec
*/}}
{{- define "wg-portal.pvc" -}}
accessModes: [{{ .Values.persistence.accessMode }}]
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . }}
{{- end }}
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . }}
{{- end }}
{{- with .Values.persistence.volumeName }}
volumeName: {{ . }}
{{- end }}
{{- end -}}
{{/*

View File

@@ -51,3 +51,16 @@ spec:
{{- end }}
selector: {{- include "wg-portal.selectorLabels" .context | nindent 4 }}
{{- end -}}
{{/*
Define the service port template for the web port
*/}}
{{- define "wg-portal.service.webPort" -}}
name: web
port: {{ .Values.service.web.port }}
protocol: TCP
targetPort: web
{{- if semverCompare ">=1.20-0" .Capabilities.KubeVersion.Version }}
appProtocol: {{ ternary "https" .Values.service.web.appProtocol .Values.certificate.enabled }}
{{- end -}}
{{- end -}}

View File

@@ -1,3 +1,11 @@
{{- $advanced := dict "start_listen_port" (.Values.service.wireguard.ports | sortAlpha | first | int) -}}
{{- $statistics := dict "listening_address" (printf ":%v" .Values.service.metrics.port) -}}
{{- $web:= dict "listening_address" (printf ":%v" .Values.service.web.port) -}}
{{- if and .Values.certificate.enabled (include "wg-portal.hostname" .) }}
{{- $_ := set $web "cert_file" "/app/certs/tls.crt" }}
{{- $_ := set $web "key_file" "/app/certs/tls.key" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
@@ -5,11 +13,9 @@ metadata:
labels: {{- include "wg-portal.labels" . | nindent 4 }}
stringData:
config.yml: |
advanced:
start_listen_port: {{ .Values.service.wireguard.ports | sortAlpha | first }}
{{- with .Values.config.advanced }}
{{- tpl (toYaml (omit . "start_listen_port")) $ | nindent 6 }}
{{- end }}
{{- with mustMerge $advanced .Values.config.advanced }}
advanced: {{- tpl (toYaml .) $ | nindent 6 }}
{{- end }}
{{- with .Values.config.auth }}
auth: {{- tpl (toYaml .) $ | nindent 6 }}
@@ -27,14 +33,10 @@ stringData:
mail: {{- tpl (toYaml .) $ | nindent 6 }}
{{- end }}
statistics:
listening_address: :{{ .Values.service.metrics.port }}
{{- with .Values.config.statistics }}
{{- tpl (toYaml (omit . "listening_address")) $ | nindent 6 }}
{{- end }}
{{- with mustMerge $statistics .Values.config.statistics }}
statistics: {{- tpl (toYaml .) $ | nindent 6 }}
{{- end }}
web:
listening_address: :{{ .Values.service.web.port }}
{{- with .Values.config.web }}
{{- tpl (toYaml (omit . "listening_address")) $ | nindent 6 }}
{{- end }}
{{- with mustMerge $web .Values.config.web }}
web: {{- tpl (toYaml .) $ | nindent 6 }}
{{- end }}

View File

@@ -1,4 +1,4 @@
{{- $portsWeb := list (dict "name" "web" "port" .Values.service.web.port "protocol" "TCP" "targetPort" "web") -}}
{{- $portsWeb := list (include "wg-portal.service.webPort" . | fromYaml) -}}
{{- $ports := list -}}
{{- range $idx, $port := .Values.service.wireguard.ports -}}
{{- $name := printf "wg%d" $idx -}}

View File

@@ -3,37 +3,36 @@
# Declare variables to be passed into your templates.
# -- Partially override resource names (adds suffix)
nameOverride: ''
nameOverride: ""
# -- Fully override resource names
fullnameOverride: ''
fullnameOverride: ""
# -- Array of extra objects to deploy with the release
extraDeploy: []
# https://github.com/h44z/wg-portal/blob/master/README.md#configuration-options
config:
# -- (tpl/object) Advanced configuration options.
# -- (tpl/object) [Advanced configuration](https://wgportal.org/latest/documentation/configuration/overview/#advanced) options.
advanced: {}
# -- (tpl/object) Auth configuration options.
# -- (tpl/object) [Auth configuration](https://wgportal.org/latest/documentation/configuration/overview/#auth) options.
auth: {}
# -- (tpl/object) Core configuration options.<br>
# If external admins in `auth` are not defined and
# -- (tpl/object) [Core configuration](https://wgportal.org/latest/documentation/configuration/overview/#core) options.<br>
# If external admins in `auth` are defined and
# there are no `admin_user` and `admin_password` defined here,
# the default credentials will be generated.
# the default admin account will be disabled.
core: {}
# -- (tpl/object) Database configuration options
# -- (tpl/object) [Database configuration](https://wgportal.org/latest/documentation/configuration/overview/#database) options
database: {}
# -- (tpl/object) Mail configuration options
# -- (tpl/object) [Mail configuration](https://wgportal.org/latest/documentation/configuration/overview/#mail) options
mail: {}
# -- (tpl/object) Statistics configuration options
# -- (tpl/object) [Statistics configuration](https://wgportal.org/latest/documentation/configuration/overview/#statistics) options
statistics: {}
# -- (tpl/object) Web configuration options.<br>
# -- (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.
web: {}
# -- The number of old ReplicaSets to retain to allow rollback.
# @default -- `10`
revisionHistoryLimit: ''
revisionHistoryLimit: ""
# -- Workload type - `Deployment` or `StatefulSet`
workloadType: Deployment
# -- Update strategy for the workload
@@ -49,7 +48,7 @@ image:
# -- Image pull policy
pullPolicy: IfNotPresent
# -- Overrides the image tag whose default is the chart appVersion
tag: ''
tag: ""
# -- Image pull secrets
imagePullSecrets: []
@@ -73,14 +72,14 @@ sidecarContainers: []
# -- Set DNS policy for the pod.
# Valid values are `ClusterFirstWithHostNet`, `ClusterFirst`, `Default` or `None`.
# @default -- `"ClusterFirst"`
dnsPolicy: ''
dnsPolicy: ""
# -- Restart policy for all containers within the pod.
# Valid values are `Always`, `OnFailure` or `Never`.
# @default -- `"Always"`
restartPolicy: ''
restartPolicy: ""
# -- Use the host's network namespace.
# @default -- `false`.
hostNetwork: ''
hostNetwork: ""
# -- Resources requests and limits
resources: {}
# -- Overwrite pod command
@@ -123,6 +122,8 @@ service:
# -- Web service port
# Used for the web interface listener
port: 8888
# -- Web service appProtocol. Will be auto set to `https` if certificate is enabled.
appProtocol: http
wireguard:
# -- Annotations for the WireGuard service
annotations: {}
@@ -141,7 +142,7 @@ ingress:
# -- Specifies whether an ingress resource should be created
enabled: false
# -- Ingress class name
className: ''
className: ""
# -- Ingress annotations
annotations: {}
# -- Ingress TLS configuration.
@@ -149,21 +150,22 @@ ingress:
tls: false
certificate:
# -- Specifies whether a certificate resource should be created
# -- Specifies whether a certificate resource should be created.
# If enabled, certificate will be used for the web.
enabled: false
issuer:
# -- Certificate issuer name
name: ''
name: ""
# -- Certificate issuer kind (ClusterIssuer or Issuer)
kind: ''
kind: ""
# -- Certificate issuer group
group: cert-manager.io
# -- Optional. [Documentation](https://cert-manager.io/docs/usage/certificate/#creating-certificate-resources)
duration: ''
duration: ""
# -- Optional. [Documentation](https://cert-manager.io/docs/usage/certificate/#creating-certificate-resources)
renewBefore: ''
renewBefore: ""
# -- Optional. [Documentation](https://cert-manager.io/docs/usage/certificate/#creating-certificate-resources)
commonName: ''
commonName: ""
# -- Optional. [Documentation](https://cert-manager.io/docs/usage/certificate/#creating-certificate-resources)
emailAddresses: []
# -- Optional. [Documentation](https://cert-manager.io/docs/usage/certificate/#creating-certificate-resources)
@@ -188,11 +190,13 @@ persistence:
annotations: {}
# -- Persistent Volume storage class.
# If undefined (the default) cluster's default provisioner will be used.
storageClass: ''
storageClass: ""
# -- Persistent Volume Access Mode
accessMode: ReadWriteOnce
# -- Persistent Volume size
size: 1Gi
# -- Persistent Volume Name (optional)
volumeName: ""
serviceAccount:
# -- Specifies whether a service account should be created
@@ -203,7 +207,7 @@ serviceAccount:
automount: false
# -- The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ''
name: ""
monitoring:
# -- Enable Prometheus monitoring.
@@ -220,15 +224,15 @@ monitoring:
annotations: {}
# -- Interval at which metrics should be scraped. If not specified `config.statistics.data_collection_interval` interval is used.
# @default -- `1m`
interval: ''
interval: ""
# -- Relabelings to samples before ingestion.
metricRelabelings: []
# -- Relabelings to samples before scraping.
relabelings: []
# -- Timeout after which the scrape is ended If not specified, the Prometheus global scrape interval is used.
scrapeTimeout: ''
scrapeTimeout: ""
# -- The label to use to retrieve the job name from.
jobLabel: ''
jobLabel: ""
# -- Transfers labels on the Kubernetes Pod onto the target.
podTargetLabels: {}
@@ -241,4 +245,4 @@ monitoring:
labels: {}
# -- Dashboard ConfigMap namespace
# Overrides the namespace for the dashboard ConfigMap.
namespace: ''
namespace: ""

View File

@@ -1,7 +1,7 @@
---
services:
wg-portal:
image: wgportal/wg-portal:latest
image: wgportal/wg-portal:v2
container_name: wg-portal
restart: unless-stopped
logging:
@@ -10,8 +10,10 @@ services:
max-file: "3"
cap_add:
- NET_ADMIN
# Use host network mode for WireGuard and the UI. Ensure that access to the UI is properly secured.
network_mode: "host"
volumes:
# left side is the host path, right side is the container path
- /etc/wireguard:/etc/wireguard
- ./data:/app/data
- ./config:/app/config

BIN
docs/assets/images/dashboard.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

View File

@@ -0,0 +1,193 @@
Below are some sample YAML configurations demonstrating how to override some default values.
## Basic
```yaml
core:
admin_user: test@example.com
admin_password: password
admin_api_token: super-s3cr3t-api-token-or-a-UUID
import_existing: false
create_default_peer: true
self_provisioning_allowed: true
web:
site_title: My WireGuard Server
site_company_name: My Company
listening_address: :8080
external_url: https://my.external-domain.com
csrf_secret: super-s3cr3t-csrf
session_secret: super-s3cr3t-session
request_logging: true
advanced:
log_level: trace
log_pretty: true
log_json: false
config_storage_path: /etc/wireguard
expiry_check_interval: 5m
database:
debug: true
type: sqlite
dsn: data/sqlite.db
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
```
## LDAP Authentication and Synchronization
```yaml
# ... (basic configuration)
auth:
ldap:
# a sample LDAP provider with user sync enabled
- id: ldap
provider_name: Active Directory
url: ldap://srv-ad1.company.local:389
bind_user: ldap_wireguard@company.local
bind_pass: super-s3cr3t-ldap
base_dn: DC=COMPANY,DC=LOCAL
login_filter: (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
sync_interval: 15m
sync_filter: (&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
disable_missing: true
field_map:
user_identifier: sAMAccountName
email: mail
firstname: givenName
lastname: sn
phone: telephoneNumber
department: department
memberof: memberOf
admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL
registration_enabled: true
log_user_info: true
```
## OpenID Connect (OIDC) Authentication
```yaml
# ... (basic configuration)
auth:
oidc:
# A sample Entra ID provider with environment variable substitution.
# Only users with an @outlook.com email address are allowed to register or login.
- id: azure
provider_name: azure
display_name: Login with</br>Entra ID
registration_enabled: true
base_url: "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0"
client_id: "${AZURE_CLIENT_ID}"
client_secret: "${AZURE_CLIENT_SECRET}"
allowed_domains:
- "outlook.com"
extra_scopes:
- profile
- email
# a sample provider where users with the attribute `wg_admin` set to `true` are considered as admins
- id: oidc-with-admin-attribute
provider_name: google
display_name: Login with</br>Google
base_url: https://accounts.google.com
client_id: the-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
extra_scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
field_map:
user_identifier: sub
email: email
firstname: given_name
lastname: family_name
phone: phone_number
department: department
is_admin: wg_admin
admin_mapping:
admin_value_regex: ^true$
registration_enabled: true
log_user_info: true
# a sample provider where users in the group `the-admin-group` are considered as admins
- id: oidc-with-admin-group
provider_name: google2
display_name: Login with</br>Google2
base_url: https://accounts.google.com
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
extra_scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
field_map:
user_identifier: sub
email: email
firstname: given_name
lastname: family_name
phone: phone_number
department: department
user_groups: groups
admin_mapping:
admin_group_regex: ^the-admin-group$
registration_enabled: true
log_user_info: true
```
## Plain OAuth2 Authentication
```yaml
# ... (basic configuration)
auth:
oauth:
# a sample provider where users with the attribute `this-attribute-must-be-true` set to `true` or `True`
# are considered as admins
- id: google_plain_oauth-with-admin-attribute
provider_name: google3
display_name: Login with</br>Google3
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
auth_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
user_info_url: https://openidconnect.googleapis.com/v1/userinfo
scopes:
- openid
- email
- profile
field_map:
user_identifier: sub
email: email
firstname: name
is_admin: this-attribute-must-be-true
admin_mapping:
admin_value_regex: ^(True|true)$
registration_enabled: true
# a sample provider where either users with the attribute `this-attribute-must-be-true` set to `true` or
# users in the group `admin-group-name` are considered as admins
- id: google_plain_oauth_with_groups
provider_name: google4
display_name: Login with</br>Google4
client_id: another-client-id-1234.apps.googleusercontent.com
client_secret: A_CLIENT_SECRET
auth_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
user_info_url: https://openidconnect.googleapis.com/v1/userinfo
scopes:
- openid
- email
- profile
- i-want-some-groups
field_map:
email: email
firstname: name
user_identifier: sub
is_admin: this-attribute-must-be-true
user_groups: groups
admin_mapping:
admin_value_regex: ^true$
admin_group_regex: ^admin-group-name$
registration_enabled: true
log_user_info: true
```

View File

@@ -0,0 +1,663 @@
This page provides an overview of **all available configuration options** for WireGuard Portal.
You can supply these configurations in a **YAML** file when starting the Portal.
The path of the configuration file defaults to `config/config.yaml` (or `config/config.yml`) in the working directory of the executable.
It is possible to override the configuration filepath using the environment variable `WG_PORTAL_CONFIG`.
For example: `WG_PORTAL_CONFIG=/etc/wg-portal/config.yaml ./wg-portal`.
Also, environment variable substitution in the config file is supported. Refer to the [syntax](https://github.com/a8m/envsubst?tab=readme-ov-file#docs).
Configuration examples are available on the [Examples](./examples.md) page.
<details>
<summary>Default configuration</summary>
```yaml
core:
admin_user: admin@wgportal.local
admin_password: wgportal
admin_api_token: ""
editable_keys: true
create_default_peer: false
create_default_peer_on_creation: false
re_enable_peer_after_user_enable: true
delete_peer_after_user_deleted: false
self_provisioning_allowed: false
import_existing: true
restore_state: true
advanced:
log_level: info
log_pretty: false
log_json: false
start_listen_port: 51820
start_cidr_v4: 10.11.12.0/24
start_cidr_v6: fdfd:d3ad:c0de:1234::0/64
use_ip_v6: true
config_storage_path: ""
expiry_check_interval: 15m
rule_prio_offset: 20000
route_table_offset: 20000
api_admin_only: true
database:
debug: false
slow_query_threshold: "0"
type: sqlite
dsn: data/sqlite.db
encryption_passphrase: ""
statistics:
use_ping_checks: true
ping_check_workers: 10
ping_unprivileged: false
ping_check_interval: 1m
data_collection_interval: 1m
collect_interface_data: true
collect_peer_data: true
collect_audit_data: true
listening_address: :8787
mail:
host: 127.0.0.1
port: 25
encryption: none
cert_validation: true
username: ""
password: ""
auth_type: plain
from: Wireguard Portal <noreply@wireguard.local>
link_only: false
auth:
oidc: []
oauth: []
ldap: []
web:
listening_address: :8888
external_url: http://localhost:8888
site_company_name: WireGuard Portal
site_title: WireGuard Portal
session_identifier: wgPortalSession
session_secret: very_secret
csrf_secret: extremely_secret
request_logging: false
expose_host_info: false
cert_file: ""
key_File: ""
webhook:
url: ""
authentication: ""
timeout: 10s
```
</details>
Below you will find sections like
[`core`](#core),
[`advanced`](#advanced),
[`database`](#database),
[`statistics`](#statistics),
[`mail`](#mail),
[`auth`](#auth),
[`web`](#web) and
[`webhook`](#webhook).
Each section describes the individual configuration keys, their default values, and a brief explanation of their purpose.
---
## Core
These are the primary configuration options that control fundamental WireGuard Portal behavior.
More advanced options are found in the subsequent `Advanced` section.
### `admin_user`
- **Default:** `admin@wgportal.local`
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
### `admin_password`
- **Default:** `wgportal`
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
### `admin_api_token`
- **Default:** *(empty)*
- **Description:** An API token for the admin user. If a token is provided, the REST API can be accessed using this token. If empty, the API is initially disabled for the admin user.
### `editable_keys`
- **Default:** `true`
- **Description:** Allow editing of WireGuard key-pairs directly in the UI.
### `create_default_peer`
- **Default:** `false`
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
### `create_default_peer_on_creation`
- **Default:** `false`
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces.
### `re_enable_peer_after_user_enable`
- **Default:** `true`
- **Description:** Re-enable all peers that were previously disabled if the associated user is re-enabled.
### `delete_peer_after_user_deleted`
- **Default:** `false`
- **Description:** If a user is deleted, remove all linked peers. Otherwise, peers remain but are disabled.
### `self_provisioning_allowed`
- **Default:** `false`
- **Description:** Allow registered (non-admin) users to self-provision peers from their profile page.
### `import_existing`
- **Default:** `true`
- **Description:** On startup, import existing WireGuard interfaces and peers into WireGuard Portal.
### `restore_state`
- **Default:** `true`
- **Description:** Restore the WireGuard interface states (up/down) that existed before WireGuard Portal started.
---
## Advanced
Additional or more specialized configuration options for logging and interface creation details.
### `log_level`
- **Default:** `info`
- **Description:** The log level used by the application. Valid options are: `trace`, `debug`, `info`, `warn`, `error`.
### `log_pretty`
- **Default:** `false`
- **Description:** If `true`, log messages are colorized and formatted for readability (pretty-print).
### `log_json`
- **Default:** `false`
- **Description:** If `true`, log messages are structured in JSON format.
### `start_listen_port`
- **Default:** `51820`
- **Description:** The first port to use when automatically creating new WireGuard interfaces.
### `start_cidr_v4`
- **Default:** `10.11.12.0/24`
- **Description:** The initial IPv4 subnet to use when automatically creating new WireGuard interfaces.
### `start_cidr_v6`
- **Default:** `fdfd:d3ad:c0de:1234::0/64`
- **Description:** The initial IPv6 subnet to use when automatically creating new WireGuard interfaces.
### `use_ip_v6`
- **Default:** `true`
- **Description:** Enable or disable IPv6 support.
### `config_storage_path`
- **Default:** *(empty)*
- **Description:** Path to a directory where `wg-quick` style configuration files will be stored (if you need local filesystem configs).
### `expiry_check_interval`
- **Default:** `15m`
- **Description:** Interval after which existing peers are checked if they are expired. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
### `rule_prio_offset`
- **Default:** `20000`
- **Description:** Offset for IP route rule priorities when configuring routing.
### `route_table_offset`
- **Default:** `20000`
- **Description:** Offset for IP route table IDs when configuring routing.
### `api_admin_only`
- **Default:** `true`
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
---
## Database
Configuration for the underlying database used by WireGuard Portal.
Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
If sensitive values (like private keys) should be stored in an encrypted format, set the `encryption_passphrase` option.
### `debug`
- **Default:** `false`
- **Description:** If `true`, logs all database statements (verbose).
### `slow_query_threshold`
- **Default:** "0"
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration). The value must be a string.
### `type`
- **Default:** `sqlite`
- **Description:** The database type. Valid options: `sqlite`, `mssql`, `mysql`, `postgres`.
### `dsn`
- **Default:** `data/sqlite.db`
- **Description:** The Data Source Name (DSN) for connecting to the database.
For example:
```text
user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
```
### `encryption_passphrase`
- **Default:** *(empty)*
- **Description:** Passphrase for encrypting sensitive values such as private keys in the database. Encryption is only applied if this passphrase is set.
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
New or updated records will be encrypted; existing data remains in plaintext until its next modified.
---
## Statistics
Controls how WireGuard Portal collects and reports usage statistics, including ping checks and Prometheus metrics.
### `use_ping_checks`
- **Default:** `true`
- **Description:** Enable periodic ping checks to verify that peers remain responsive.
### `ping_check_workers`
- **Default:** `10`
- **Description:** Number of parallel worker processes for ping checks.
### `ping_unprivileged`
- **Default:** `false`
- **Description:** If `false`, ping checks run without root privileges. This is currently considered BETA.
### `ping_check_interval`
- **Default:** `1m`
- **Description:** Interval between consecutive ping checks for all peers. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
### `data_collection_interval`
- **Default:** `1m`
- **Description:** Interval between data collection cycles (bytes sent/received, handshake times, etc.). Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
### `collect_interface_data`
- **Default:** `true`
- **Description:** If `true`, collects interface-level data (bytes in/out) for monitoring and statistics.
### `collect_peer_data`
- **Default:** `true`
- **Description:** If `true`, collects peer-level data (bytes, last handshake, endpoint, etc.).
### `collect_audit_data`
- **Default:** `true`
- **Description:** If `true`, logs certain portal events (such as user logins) to the database.
### `listening_address`
- **Default:** `:8787`
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8787`).
---
## Mail
Options for configuring email notifications or sending peer configurations via email.
### `host`
- **Default:** `127.0.0.1`
- **Description:** Hostname or IP of the SMTP server.
### `port`
- **Default:** `25`
- **Description:** Port number for the SMTP server.
### `encryption`
- **Default:** `none`
- **Description:** SMTP encryption type. Valid values: `none`, `tls`, `starttls`.
### `cert_validation`
- **Default:** `true`
- **Description:** If `true`, validate the SMTP server certificate (relevant if `encryption` = `tls`).
### `username`
- **Default:** *(empty)*
- **Description:** Optional SMTP username for authentication.
### `password`
- **Default:** *(empty)*
- **Description:** Optional SMTP password for authentication.
### `auth_type`
- **Default:** `plain`
- **Description:** SMTP authentication type. Valid values: `plain`, `login`, `crammd5`.
### `from`
- **Default:** `Wireguard Portal <noreply@wireguard.local>`
- **Description:** The default "From" address when sending emails.
### `link_only`
- **Default:** `false`
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
---
## Auth
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), and **LDAP** (`ldap`).
Each can have multiple providers configured. Below are the relevant keys.
---
### OIDC
The `oidc` array contains a list of OpenID Connect providers.
Below are the properties for each OIDC provider entry inside `auth.oidc`:
#### `provider_name`
- **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers.
#### `display_name`
- **Default:** *(empty)*
- **Description:** A user-friendly name shown on the login page (e.g., "Login with Google").
#### `base_url`
- **Default:** *(empty)*
- **Description:** The OIDC providers base URL (e.g., `https://accounts.google.com`).
#### `client_id`
- **Default:** *(empty)*
- **Description:** The OAuth client ID from the OIDC provider.
#### `client_secret`
- **Default:** *(empty)*
- **Description:** The OAuth client secret from the OIDC provider.
#### `extra_scopes`
- **Default:** *(empty)*
- **Description:** A list of additional OIDC scopes (e.g., `profile`, `email`).
#### `allowed_domains`
- **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.
#### `field_map`
- **Default:** *(empty)*
- **Description:** Maps OIDC claims to WireGuard Portal user fields.
- Available fields: `user_identifier`, `email`, `firstname`, `lastname`, `phone`, `department`, `is_admin`, `user_groups`.
| **Field** | **Typical OIDC Claim** | **Explanation** |
|-------------------|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `user_identifier` | `sub` or `preferred_username` | A unique identifier for the user. Often the OIDC `sub` claim is used because its guaranteed to be unique for the user within the IdP. Some providers also support `preferred_username` if its unique. |
| `email` | `email` | The users email address as provided by the IdP. Not always verified, depending on IdP settings. |
| `firstname` | `given_name` | The users first name, typically provided by the IdP in the `given_name` claim. |
| `lastname` | `family_name` | The users last (family) name, typically provided by the IdP in the `family_name` claim. |
| `phone` | `phone_number` | The users phone number. This may require additional scopes/permissions from the IdP to access. |
| `department` | Custom claim (e.g., `department`) | If the IdP can provide organizational data, it may store it in a custom claim. Adjust accordingly (e.g., `department`, `org`, or another attribute). |
| `is_admin` | Custom claim or derived role | If the IdP returns a role or admin flag, you can map that to `is_admin`. Often this is managed through custom claims or group membership. |
| `user_groups` | `groups` or another custom claim | A list of group memberships for the user. Some IdPs provide `groups` out of the box; others require custom claims or directory lookups. |
#### `admin_mapping`
- **Default:** *(empty)*
- **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_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled`
- **Default:** *(empty)*
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
#### `log_user_info`
- **Default:** *(empty)*
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
---
### OAuth
The `oauth` array contains a list of plain OAuth2 providers.
Below are the properties for each OAuth provider entry inside `auth.oauth`:
#### `provider_name`
- **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers.
#### `display_name`
- **Default:** *(empty)*
- **Description:** A user-friendly name shown on the login page.
#### `client_id`
- **Default:** *(empty)*
- **Description:** The OAuth client ID for the provider.
#### `client_secret`
- **Default:** *(empty)*
- **Description:** The OAuth client secret for the provider.
#### `auth_url`
- **Default:** *(empty)*
- **Description:** URL of the authentication endpoint.
#### `token_url`
- **Default:** *(empty)*
- **Description:** URL of the token endpoint.
#### `user_info_url`
- **Default:** *(empty)*
- **Description:** URL of the user information endpoint.
#### `scopes`
- **Default:** *(empty)*
- **Description:** A list of OAuth scopes.
#### `allowed_domains`
- **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.
#### `field_map`
- **Default:** *(empty)*
- **Description:** Maps OAuth attributes to WireGuard Portal fields.
- Available fields: `user_identifier`, `email`, `firstname`, `lastname`, `phone`, `department`, `is_admin`, `user_groups`.
| **Field** | **Typical Claim** | **Explanation** |
|-------------------|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `user_identifier` | `sub` or `preferred_username` | A unique identifier for the user. Often the OIDC `sub` claim is used because its guaranteed to be unique for the user within the IdP. Some providers also support `preferred_username` if its unique. |
| `email` | `email` | The users email address as provided by the IdP. Not always verified, depending on IdP settings. |
| `firstname` | `given_name` | The users first name, typically provided by the IdP in the `given_name` claim. |
| `lastname` | `family_name` | The users last (family) name, typically provided by the IdP in the `family_name` claim. |
| `phone` | `phone_number` | The users phone number. This may require additional scopes/permissions from the IdP to access. |
| `department` | Custom claim (e.g., `department`) | If the IdP can provide organizational data, it may store it in a custom claim. Adjust accordingly (e.g., `department`, `org`, or another attribute). |
| `is_admin` | Custom claim or derived role | If the IdP returns a role or admin flag, you can map that to `is_admin`. Often this is managed through custom claims or group membership. |
| `user_groups` | `groups` or another custom claim | A list of group memberships for the user. Some IdPs provide `groups` out of the box; others require custom claims or directory lookups. |
#### `admin_mapping`
- **Default:** *(empty)*
- **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_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled`
- **Default:** *(empty)*
- **Description:** If `true`, new users are created automatically on successful login.
#### `log_user_info`
- **Default:** *(empty)*
- **Description:** If `true`, logs user info at the trace level upon login.
---
### LDAP
The `ldap` array contains a list of LDAP authentication providers.
Below are the properties for each LDAP provider entry inside `auth.ldap`:
#### `provider_name`
- **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers.
#### `url`
- **Default:** *(empty)*
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
#### `start_tls`
- **Default:** *(empty)*
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
#### `cert_validation`
- **Default:** *(empty)*
- **Description:** If `true`, validate the LDAP servers TLS certificate.
#### `tls_certificate_path`
- **Default:** *(empty)*
- **Description:** Path to a TLS certificate if needed for LDAP connections.
#### `tls_key_path`
- **Default:** *(empty)*
- **Description:** Path to the corresponding TLS certificate key.
#### `base_dn`
- **Default:** *(empty)*
- **Description:** The base DN for user searches (e.g., `DC=COMPANY,DC=LOCAL`).
#### `bind_user`
- **Default:** *(empty)*
- **Description:** The bind user for LDAP (e.g., `company\\ldap_wireguard` or `ldap_wireguard@company.local`).
#### `bind_pass`
- **Default:** *(empty)*
- **Description:** The bind password for LDAP authentication.
#### `field_map`
- **Default:** *(empty)*
- **Description:** Maps LDAP attributes to WireGuard Portal fields.
- Available fields: `user_identifier`, `email`, `firstname`, `lastname`, `phone`, `department`, `memberof`.
| **WireGuard Portal Field** | **Typical LDAP Attribute** | **Short Description** |
|----------------------------|----------------------------|--------------------------------------------------------------|
| user_identifier | sAMAccountName / uid | Uniquely identifies the user within the LDAP directory. |
| email | mail / userPrincipalName | Stores the user's primary email address. |
| firstname | givenName | Contains the user's first (given) name. |
| lastname | sn | Contains the user's last (surname) name. |
| phone | telephoneNumber / mobile | Holds the user's phone or mobile number. |
| department | departmentNumber / ou | Specifies the department or organizational unit of the user. |
| memberof | memberOf | Lists the groups and roles to which the user belongs. |
#### `login_filter`
- **Default:** *(empty)*
- **Description:** An LDAP filter to restrict which users can log in. Use `{{login_identifier}}` to insert the username.
For example:
```text
(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
```
#### `admin_group`
- **Default:** *(empty)*
- **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal.
For example:
```text
CN=WireGuardAdmins,OU=Some-OU,DC=YOURDOMAIN,DC=LOCAL
```
#### `sync_interval`
- **Default:** *(empty)*
- **Description:** How frequently (in duration, e.g. `30m`) to synchronize users from LDAP. Empty or `0` disables sync. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
Only users that match the `sync_filter` are synchronized, if `disable_missing` is `true`, users not found in LDAP are disabled.
#### `sync_filter`
- **Default:** *(empty)*
- **Description:** An LDAP filter to select which users get synchronized into WireGuard Portal.
For example:
```text
(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
```
#### `disable_missing`
- **Default:** *(empty)*
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
#### `auto_re_enable`
- **Default:** *(empty)*
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
#### `registration_enabled`
- **Default:** *(empty)*
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
#### `log_user_info`
- **Default:** *(empty)*
- **Description:** If `true`, logs LDAP user data at the trace level upon login.
---
## Web
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
It is important to specify a valid `external_url` for the web server, especially if you are using a reverse proxy.
Without a valid `external_url`, the login process may fail due to CSRF protection.
### `listening_address`
- **Default:** `:8888`
- **Description:** The listening address and port for the web server (e.g., `:8888` to bind on all interfaces or `127.0.0.1:8888` to bind only on the loopback interface).
Ensure that access to WireGuard Portal is protected against unauthorized access, especially if binding to all interfaces.
### `external_url`
- **Default:** `http://localhost:8888`
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
### `site_company_name`
- **Default:** `WireGuard Portal`
- **Description:** The company name that is shown at the bottom of the web frontend.
### `site_title`
- **Default:** `WireGuard Portal`
- **Description:** The title that is shown in the web frontend.
### `session_identifier`
- **Default:** `wgPortalSession`
- **Description:** The session identifier for the web frontend.
### `session_secret`
- **Default:** `very_secret`
- **Description:** The session secret for the web frontend.
### `csrf_secret`
- **Default:** `extremely_secret`
- **Description:** The CSRF secret.
### `request_logging`
- **Default:** `false`
- **Description:** Log all HTTP requests.
### `expose_host_info`
- **Default:** `false`
- **Description:** Expose the hostname and version of the WireGuard Portal server in an HTTP header. This is useful for debugging but may expose sensitive information.
### `cert_file`
- **Default:** *(empty)*
- **Description:** (Optional) Path to the TLS certificate file.
### `key_file`
- **Default:** *(empty)*
- **Description:** (Optional) Path to the TLS certificate key file.
---
## Webhook
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
A JSON object is sent in a POST request to the webhook URL with the following structure:
```json
{
"event": "peer_created",
"entity": "peer",
"identifier": "the-peer-identifier",
"payload": {
// The payload of the event, e.g. peer data.
// Check the API documentation for the exact structure.
}
}
```
### `url`
- **Default:** *(empty)*
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.
### `authentication`
- **Default:** *(empty)*
- **Description:** The Authorization header for the webhook endpoint. The value is send as-is in the header. For example: `Bearer <token>`.
### `timeout`
- **Default:** `10s`
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.

View File

@@ -0,0 +1,42 @@
Starting from v2, each [release](https://github.com/h44z/wg-portal/releases) includes compiled binaries for supported platforms.
These binary versions can be manually downloaded and installed.
## Download
Make sure that you download the correct binary for your architecture. The available binaries are:
- `wg-portal_linux_amd64` - Linux x86_64
- `wg-portal_linux_arm64` - Linux ARM 64-bit
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit
With `curl`:
```shell
curl -L -o wg-portal https://github.com/h44z/wg-portal/releases/download/${WG_PORTAL_VERSION}/wg-portal_linux_amd64
```
With `wget`:
```shell
wget -O wg-portal https://github.com/h44z/wg-portal/releases/download/${WG_PORTAL_VERSION}/wg-portal_linux_amd64
```
with `gh cli`:
```shell
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
```
## Install
```shell
sudo mkdir -p /opt/wg-portal
sudo install wg-portal /opt/wg-portal/
```
## Unreleased versions (master branch builds)
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).

View File

@@ -1,11 +0,0 @@
To build a standalone application, use the Makefile provided in the repository.
Go version **1.22** or higher has to be installed to build WireGuard Portal.
If you want to re-compile the frontend, NodeJS **18** and NPM >= **9** is required.
```shell
# build the frontend (optional)
make frontend
# build the binary
make build
```

View File

@@ -1,81 +1,161 @@
## Image Usage
The preferred way to start WireGuard Portal as Docker container is to use Docker Compose.
The WireGuard Portal Docker image is available on both [Docker Hub](https://hub.docker.com/r/wgportal/wg-portal) and [GitHub Container Registry](https://github.com/h44z/wg-portal/pkgs/container/wg-portal).
It is built on the official Alpine Linux base image and comes pre-packaged with all necessary WireGuard dependencies.
A sample docker-compose.yml:
This container allows you to establish WireGuard VPN connections without relying on a host system that supports WireGuard or using the `linuxserver/wireguard` Docker image.
The recommended method for deploying WireGuard Portal is via Docker Compose for ease of configuration and management.
A sample docker-compose.yml (managing WireGuard interfaces directly on the host) is provided below:
```yaml
version: '3.6'
services:
wg-portal:
image: wgportal/wg-portal:v2
restart: unless-stopped
cap_add:
- NET_ADMIN
network_mode: "host"
ports:
- "8888:8888"
volumes:
- /etc/wireguard:/etc/wireguard
- ./data:/app/data
- ./config:/app/config
--8<-- "docker-compose.yml::19"
```
By default, the webserver is listening on port **8888**.
By default, the webserver for the UI is listening on port **8888** on all available interfaces.
Volumes for `/app/data` and `/app/config` should be used ensure data persistence across container restarts.
## WireGuard Interface Handling
WireGuard Portal supports managing WireGuard interfaces through three distinct deployment methods, providing flexibility based on your system architecture and operational preferences:
- **Directly on the host system**:
WireGuard Portal can control WireGuard interfaces natively on the host, without using containers.
This setup is ideal for environments where direct access to system networking is preferred.
To use this method, you need to set the network mode to `host` in your docker-compose.yml file.
```yaml
services:
wg-portal:
...
network_mode: "host"
...
```
> :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.
- **Within the WireGuard Portal Docker container**:
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.
```yaml
services:
wg-portal:
image: wgportal/wg-portal:v2
container_name: wg-portal
...
cap_add:
- NET_ADMIN
ports:
# host port : container port
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
- "51820:51820/udp"
# Web UI port
- "8888:8888/tcp"
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
volumes:
# host path : container path
- ./wg/data:/app/data
- ./wg/config:/app/config
```
- **Via a separate Docker container**:
WireGuard Portal can interface with and control WireGuard running in another Docker container, such as the [linuxserver/wireguard](https://docs.linuxserver.io/images/docker-wireguard/) image.
This method is useful in setups that already use `linuxserver/wireguard` or where you want to isolate the VPN backend from the portal frontend.
For this, you need to set the network mode to `service:wireguard` in your docker-compose.yml file, `wireguard` is the service name of your WireGuard container.
```yaml
services:
wg-portal:
image: wgportal/wg-portal:v2
container_name: wg-portal
...
cap_add:
- NET_ADMIN
network_mode: "service:wireguard" # So we ensure to stay on the same network as the wireguard container.
volumes:
# host path : container path
- ./wg/etc:/etc/wireguard
- ./wg/data:/app/data
- ./wg/config:/app/config
wireguard:
image: lscr.io/linuxserver/wireguard:latest
container_name: wireguard
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
# host port : container port
- "51820:51820/udp" # WireGuard port, needs to match the port in wg-portal interface config
- "8888:8888/tcp" # Noticed that the port of the web UI is exposed in the wireguard container.
volumes:
- ./wg/etc:/config/wg_confs # We share the configuration (wgx.conf) between wg-portal and wireguard
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
```
As the `linuxserver/wireguard` image uses _wg-quick_ to manage the interfaces, you need to have at least the following configuration set for WireGuard Portal:
```yaml
core:
# The WireGuard container uses wg-quick to manage the WireGuard interfaces - this conflicts with WireGuard Portal during startup.
# To avoid this, we need to set the restore_state option to false so that wg-quick can create the interfaces.
restore_state: false
# Usually, there are no existing interfaces in the WireGuard container, so we can set this to false.
import_existing: false
advanced:
# WireGuard Portal needs to export the WireGuard configuration as wg-quick config files so that the WireGuard container can use them.
config_storage_path: /etc/wireguard/
```
## Image Versioning
All images are hosted on Docker Hub at [https://hub.docker.com/r/wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).
All images are hosted on Docker Hub at [https://hub.docker.com/r/wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal) or in the [GitHub Container Registry](https://github.com/h44z/wg-portal/pkgs/container/wg-portal).
Version **2** is the current stable release. Version **1** has moved to legacy status and is no longer recommended.
There are three types of tags in the repository:
#### Semantic versioned tags
For example, `1.0.19`.
These are official releases of WireGuard Portal. They correspond to the GitHub tags that we make, and you can see the release notes for them here: [https://github.com/h44z/wg-portal/releases](https://github.com/h44z/wg-portal/releases).
For example, `2.0.0-rc.1` or `v2.0.0-rc.1`.
Once these tags show up in this repository, they will never change.
These are official releases of WireGuard Portal. For production deployments of WireGuard Portal, we strongly recommend using one of these versioned tags instead of the latest or canary tags.
For production deployments of WireGuard Portal, we strongly recommend using one of these tags, e.g. **wgportal/wg-portal:1.0.19**, instead of the latest or canary tags.
There are different types of these tags:
If you only want to stay at the same major or major+minor version, use either `v[MAJOR]` or `[MAJOR].[MINOR]` tags. For example `v1` or `1.0`.
- Major version tags: `v2` or `2`. These tags always refer to the latest image for WireGuard Portal version **2**.
- Minor version tags: `v2.x` or `2.0`. These tags always refer to the latest image for WireGuard Portal version **2.x**.
- Specific version tags (patch version): `v2.0.0` or `2.0.0`. These tags denote a very specific release. They correspond to the GitHub tags that we make, and you can see the release notes for them here: [https://github.com/h44z/wg-portal/releases](https://github.com/h44z/wg-portal/releases). Once these tags for a specific version show up in the Docker repository, they will never change.
Version **1** is currently **stable**, version **2** is in **development**.
#### The `latest` tag
#### latest
This is the most recent build to master! It changes a lot and is very unstable.
The lastest tag is the latest stable release of WireGuard Portal. For version **2**, this is the same as the `v2` tag.
We recommend that you don't use it except for development purposes.
#### The `master` tag
#### Branch tags
For each commit in the master and the stable branch, a corresponding Docker image is build. These images use the `master` or `stable` tags.
This is the most recent build to the main branch! It changes a lot and is very unstable.
We recommend that you don't use it except for development purposes or to test the latest features.
## Configuration
You can configure WireGuard Portal using a yaml configuration file.
The filepath of the yaml configuration file defaults to `/app/config/config.yml`.
You can configure WireGuard Portal using a YAML configuration file.
The filepath of the YAML configuration file defaults to `/app/config/config.yaml`.
It is possible to override the configuration filepath using the environment variable **WG_PORTAL_CONFIG**.
By default, WireGuard Portal uses a SQLite database. The database is stored in `/app/data/sqlite.db`.
By default, WireGuard Portal uses an SQLite database. The database is stored in `/app/data/sqlite.db`.
You should mount those directories as a volume:
- /app/data
- /app/config
### Configuration Options
All available YAML configuration options are available [here](https://github.com/h44z/wg-portal#configuration).
- `/app/data`
- `/app/config`
A very basic example:
A detailed description of the configuration options can be found [here](../configuration/overview.md).
If you want to access configuration files in wg-quick format, you can mount the `/etc/wireguard` directory inside the container to a location of your choice.
Also enable the `config_storage_path` option in the configuration file:
```yaml
core:
admin_user: test@wg-portal.local
admin_password: secret
web:
external_url: http://localhost:8888
request_logging: true
advanced:
config_storage_path: /etc/wireguard
```

View File

@@ -0,0 +1 @@
--8<-- "./deploy/helm/README.md:16"

View File

@@ -0,0 +1,98 @@
## Reverse Proxy for HTTPS
For production deployments, always serve the WireGuard Portal over HTTPS. You have two options to secure your connection:
### Reverse Proxy
Let a frontend proxy handle HTTPS for you. This also frees you from managing certificates manually and is therefore the preferred option.
You can use Nginx, Traefik, Caddy or any other proxy.
Below is an example using a Docker Compose stack with [Traefik](https://traefik.io/traefik/).
It exposes the WireGuard Portal on `https://wg.domain.com` and redirects initial HTTP traffic to HTTPS.
```yaml
services:
reverse-proxy:
image: traefik:v3.3
restart: unless-stopped
command:
#- '--log.level=DEBUG'
- '--providers.docker.endpoint=unix:///var/run/docker.sock'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:80'
- '--entrypoints.websecure.address=:443'
- '--entrypoints.websecure.http3'
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true'
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web'
- '--certificatesresolvers.letsencryptresolver.acme.email=your.email@domain.com'
- '--certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json'
#- '--certificatesresolvers.letsencryptresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory' # just for testing
ports:
- 80:80 # for HTTP
- 443:443/tcp # for HTTPS
- 443:443/udp # for HTTP/3
volumes:
- acme-certs:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- 'traefik.enable=true'
# HTTP Catchall for redirecting HTTP -> HTTPS
- 'traefik.http.routers.dashboard-catchall.rule=Host(`wg.domain.com`) && PathPrefix(`/`)'
- 'traefik.http.routers.dashboard-catchall.entrypoints=web'
- 'traefik.http.routers.dashboard-catchall.middlewares=redirect-to-https'
- 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'
wg-portal:
image: wgportal/wg-portal:v2
container_name: wg-portal
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
cap_add:
- NET_ADMIN
ports:
# host port : container port
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
- "51820:51820/udp"
# Web UI port (only available on localhost, Traefik will handle the HTTPS)
- "127.0.0.1:8888:8888/tcp"
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
volumes:
# host path : container path
- ./wg/data:/app/data
- ./wg/config:/app/config
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.wgportal.rule=Host(`wg.domain.com`)'
- 'traefik.http.routers.wgportal.entrypoints=websecure'
- 'traefik.http.routers.wgportal.tls.certresolver=letsencryptresolver'
- 'traefik.http.routers.wgportal.service=wgportal'
- 'traefik.http.services.wgportal.loadbalancer.server.port=8888'
volumes:
acme-certs:
```
The WireGuard Portal configuration must be updated accordingly so that the correct external URL is set for the web interface:
```yaml
web:
external_url: https://wg.domain.com
```
### Built-in TLS
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
In your `config.yaml`, under the `web` section, point to your certificate and key files:
```yaml
web:
cert_file: /path/to/your/fullchain.pem
key_file: /path/to/your/privkey.pem
```
The web server will then use these files to serve HTTPS traffic directly instead of HTTP.

View File

@@ -0,0 +1,26 @@
To build the application from source files, use the Makefile provided in the repository.
## Requirements
- [Git](https://git-scm.com/downloads)
- [Make](https://www.gnu.org/software/make/)
- [Go](https://go.dev/dl/): `>=1.24.0`
- [Node.js with npm](https://nodejs.org/en/download): `node>=18, npm>=9`
## Build
```shell
# Get source code
git clone https://github.com/h44z/wg-portal -b ${WG_PORTAL_VERSION:-master} --depth 1
cd wg-portal
# Build the frontend
make frontend
# Build the backend
make build
```
## Install
Compiled binary will be available in `./dist` directory.
For installation instructions, check the [Binaries](./binaries.md) section.

View File

@@ -1,25 +0,0 @@
For production deployments of WireGuard Portal, we strongly recommend using version 1.
If you want to use version 2, please be aware that it is still in beta and not feature complete.
## Upgrade from v1 to v2
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
The configuration (config.yml) for WireGuard Portal must be updated and valid before starting the upgrade.
To upgrade from a previous SQLite database, start wg-portal like:
```shell
./wg-portal-amd64 -migrateFrom=old_wg_portal.db
```
You can also specify the database type using the parameter **-migrateFromType**, supported types: mysql, mssql, postgres or sqlite.
For example:
```shell
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom=user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
```
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yml** configuration file.
Ensure that the new database does not contain any data!

View File

@@ -0,0 +1,32 @@
By default, WG-Portal exposes Prometheus metrics on port `8787` if interface/peer statistic data collection is enabled.
## Exposed Metrics
| Metric | Type | Description |
|--------------------------------------------|-------|------------------------------------------------|
| `wireguard_interface_received_bytes_total` | gauge | Bytes received through the interface. |
| `wireguard_interface_sent_bytes_total` | gauge | Bytes sent through the interface. |
| `wireguard_peer_last_handshake_seconds` | gauge | Seconds from the last handshake with the peer. |
| `wireguard_peer_received_bytes_total` | gauge | Bytes received from the peer. |
| `wireguard_peer_sent_bytes_total` | gauge | Bytes sent to the peer. |
| `wireguard_peer_up` | gauge | Peer connection state (boolean: 1/0). |
## Prometheus Config
Add the following scrape job to your Prometheus config file:
```yaml
# prometheus.yaml
scrape_configs:
- job_name: wg-portal
scrape_interval: 60s
static_configs:
- targets:
- localhost:8787 # Change localhost to IP Address or hostname with WG-Portal
```
# Grafana Dashboard
You may import [`dashboard.json`](https://github.com/h44z/wg-portal/blob/master/deploy/helm/files/dashboard.json) into your Grafana instance.
![Dashboard](../../assets/images/dashboard.png)

View File

@@ -1,29 +1 @@
**WireGuard Portal** is a simple, web based configuration portal for [WireGuard](https://wireguard.com).
The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) library to manage existing VPN
interfaces. This allows for seamless activation or deactivation of new users, without disturbing existing VPN
connections.
The configuration portal supports using a database (SQLite, MySQL, MsSQL or Postgres), OAuth or LDAP
(Active Directory or OpenLDAP) as a user source for authentication and profile data.
## Features
* Self-hosted - the whole application is a single binary
* Responsive web UI written in Vue.JS
* Automatically select IP from the network pool assigned to client
* QR-Code for convenient mobile client configuration
* Sent email to client with QR-code and client config
* Enable / Disable clients seamlessly
* Generation of wg-quick configuration file (`wgX.conf`) if required
* User authentication (database, OAuth or LDAP)
* IPv6 ready
* Docker ready
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* Peer Expiry Feature
* Handle route and DNS settings like wg-quick does
* ~~REST API for management and client deployment~~ (coming soon)
## Quick-Start
The easiest way to get started is to use the provided [Docker image](./getting-started/docker.md).
--8<-- "README.md:12:41"

View File

@@ -0,0 +1 @@
<swagger-ui src="./swagger.yaml"/>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
Major upgrades between different versions may require special procedures, which are described in the following sections.
## Upgrade from v1 to v2
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
The configuration (config.yaml) for WireGuard Portal must be updated and valid before starting the upgrade.
To upgrade from a previous SQLite database, start wg-portal like:
```shell
./wg-portal-amd64 -migrateFrom=old_wg_portal.db
```
You can also specify the database type using the parameter **-migrateFromType**.
Supported database types: `mysql`, `mssql`, `postgres` or `sqlite`.
For example:
```shell
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom='user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local'
```
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yaml** configuration file.
Ensure that the new database does not contain any data!
If you are using Docker, you can adapt the docker-compose.yml file to start the upgrade process:
```yaml
services:
wg-portal:
image: wgportal/wg-portal:v2
# ... other settings
restart: no
command: ["-migrateFrom=/app/data/old_wg_portal.db"]
```

View File

@@ -24,7 +24,7 @@
<div id="toasts"></div>
<!-- main application -->
<div id="app"></div>
<div id="app" class="d-flex flex-column flex-grow-1"></div>
<!-- vue teleport will add modals and dialogs here -->
<div id="modals"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -8,25 +8,27 @@
"preview": "vite preview --port 5050"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@kyvg/vue3-notification": "^3.1.3",
"@fontsource/nunito-sans": "^5.2.5",
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.2",
"bootswatch": "^5.3.2",
"flag-icons": "^7.1.0",
"ip-address": "^9.0.5",
"is-cidr": "^5.0.3",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",
"flag-icons": "^7.3.2",
"ip-address": "^10.0.1",
"is-cidr": "^5.1.1",
"is-ip": "^5.0.1",
"pinia": "^2.1.7",
"prismjs": "^1.29.0",
"vue": "^3.3.13",
"vue-i18n": "^9.14.2",
"pinia": "^3.0.2",
"prismjs": "^1.30.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.3",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.2.5",
"vue3-tags-input": "^1.0.12"
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.10"
"@vitejs/plugin-vue": "^5.2.3",
"sass-embedded": "^1.86.3",
"vite": "6.3.4"
}
}

View File

@@ -4,6 +4,7 @@ import { computed, getCurrentInstance, onMounted, ref } from "vue";
import { authStore } from "./stores/auth";
import { securityStore } from "./stores/security";
import { settingsStore } from "@/stores/settings";
import { Notifications } from "@kyvg/vue3-notification";
const appGlobal = getCurrentInstance().appContext.config.globalProperties
const auth = authStore()
@@ -45,13 +46,15 @@ const languageFlag = computed(() => {
if (!appGlobal.$i18n.availableLocales.includes(lang)) {
lang = appGlobal.$i18n.fallbackLocale;
}
if (lang === "en") {
lang = "us";
}
if (lang === "zh") {
lang = "cn";
}
return "fi-" + lang;
const langMap = {
en: "us",
pt: "pt",
uk: "ua",
zh: "cn",
ko: "kr",
};
return "fi-" + (langMap[lang] || lang);
})
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
@@ -82,6 +85,9 @@ const currentYear = ref(new Date().getFullYear())
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
<RouterLink :to="{ name: 'users' }" class="nav-link">{{ $t('menu.users') }}</RouterLink>
</li>
<li class="nav-item">
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
</li>
</ul>
<div class="navbar-nav d-flex justify-content-end">
@@ -91,6 +97,7 @@ const currentYear = ref(new Date().getFullYear())
<div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
</div>
@@ -117,17 +124,23 @@ const currentYear = ref(new Date().getFullYear())
<button aria-expanded="false" aria-haspopup="true" class="btn btn btn-secondary pe-0"
data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('en')"><span class="fi fi-us"></span> English</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('en')"><span class="fi fi-us"></span> English</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('fr')"><span class="fi fi-fr"></span> Français</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ko')"><span class="fi fi-kr"></span> 한국어</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('pt')"><span class="fi fi-pt"></span> Português</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ru')"><span class="fi fi-ru"></span> Русский</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
</div>
</div>
</div>
</div>
</div>
</div>
</footer></template>
</footer>
</template>
<style></style>

View File

@@ -15,3 +15,85 @@ a.disabled {
.desc::after {
content: " ↓";
}
/* style the background and the text color of the input ... */
.vue-tags-input {
max-width: 100% !important;
background-color: #f7f7f9 !important;
padding: 0 0;
}
.vue-tags-input .ti-input {
padding: 0 0;
border: none !important;
transition: border-bottom 200ms ease;
}
.vue-tags-input .ti-new-tag-input {
background: transparent;
color: var(--bs-body-color);
padding: 0.75rem 1.5rem !important;
}
/* style the placeholders color across all browser */
.vue-tags-input ::-webkit-input-placeholder {
color: var(--bs-secondary-color);
}
.vue-tags-input .ti-input::placeholder {
color: var(--bs-secondary-color);
}
.vue-tags-input ::-moz-placeholder {
color: var(--bs-secondary-color);
}
.vue-tags-input :-ms-input-placeholder {
color: var(--bs-secondary-color);
}
.vue-tags-input :-moz-placeholder {
color: var(--bs-secondary-color);
}
/* default styles for all the tags */
.vue-tags-input .ti-tag {
position: relative;
background: #ffffff;
border: 2px solid var(--bs-body-color);
margin: 6px;
color: var(--bs-body-color);
}
/* the styles if a tag is invalid */
.vue-tags-input .ti-tag.ti-invalid {
background-color: #e88a74;
}
/* if the user input is invalid, the input color should be red */
.vue-tags-input .ti-new-tag-input.ti-invalid {
color: #e88a74;
}
/* if a tag or the user input is a duplicate, it should be crossed out */
.vue-tags-input .ti-duplicate span,
.vue-tags-input .ti-new-tag-input.ti-duplicate {
text-decoration: line-through;
}
/* if the user presses backspace, the complete tag should be crossed out, to mark it for deletion */
.vue-tags-input .ti-tag:after {
transition: transform .2s;
position: absolute;
content: '';
height: 2px;
width: 108%;
left: -4%;
top: calc(50% - 1px);
background-color: #000;
transform: scaleX(0);
}
.vue-tags-input .ti-deletion-mark:after {
transform: scaleX(1);
}

View File

@@ -0,0 +1,20 @@
// disable external web fonts
$web-font-path: false;
@import "bootswatch/dist/lux/variables";
@import "bootstrap/scss/bootstrap";
@import "bootswatch/dist/lux/bootswatch";
// fix strange border width bug in bootswatch 5.3
:root {
--bs-border-width: 1px;
}
// for future use, once bootswatch supports @use
/*
@use "bootswatch/dist/lux/_variables.scss" as lux-vars with (
$web-font-path: false
);
@use "bootstrap/scss/bootstrap" as bs;
@use "bootswatch/dist/lux/_bootswatch.scss" as lux-theme;
*/

View File

@@ -4,7 +4,7 @@ import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from 'vue3-tags-input';
import { VueTagsInput } from '@vojtechlanka/vue-tags-input';
import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
@@ -38,6 +38,15 @@ const title = computed(() => {
return t("modals.interface-edit.headline-new")
})
const currentTags = ref({
Addresses: "",
Dns: "",
DnsSearch: "",
PeerDefNetwork: "",
PeerDefAllowedIPs: "",
PeerDefDns: "",
PeerDefDnsSearch: ""
})
const formData = ref(freshInterface())
// functions
@@ -137,94 +146,94 @@ function close() {
function handleChangeAddresses(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
if(isCidr(tag.text) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Addresses = tags
formData.value.Addresses = tags.map(tag => tag.text)
}
}
function handleChangeDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
if(!isIP(tag.text)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Dns = tags
formData.value.Dns = tags.map(tag => tag.text)
}
}
function handleChangeDnsSearch(tags) {
formData.value.DnsSearch = tags
formData.value.DnsSearch = tags.map(tag => tag.text)
}
function handleChangePeerDefNetwork(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
if(isCidr(tag.text) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefNetwork = tags
formData.value.PeerDefNetwork = tags.map(tag => tag.text)
}
}
function handleChangePeerDefAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
if(isCidr(tag.text) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefAllowedIPs = tags
formData.value.PeerDefAllowedIPs = tags.map(tag => tag.text)
}
}
function handleChangePeerDefDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
if(!isIP(tag.text)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefDns = tags
formData.value.PeerDefDns = tags.map(tag => tag.text)
}
}
function handleChangePeerDefDnsSearch(tags) {
formData.value.PeerDefDnsSearch = tags
formData.value.PeerDefDnsSearch = tags.map(tag => tag.text)
}
async function save() {
@@ -322,22 +331,26 @@ async function del() {
<legend class="mt-4">{{ $t('modals.interface-edit.header-crypto') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.private-key.label') }}</label>
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="email">
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="text">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.public-key.label') }}</label>
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="email">
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="text">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-network') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Addresses"
:placeholder="$t('modals.interface-edit.ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAddresses"/>
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
:tags="formData.Addresses.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.ip.placeholder')"
:validation="validateCIDR()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeAddresses"/>
</div>
<div v-if="formData.Mode==='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.listen-port.label') }}</label>
@@ -345,19 +358,27 @@ async function del() {
</div>
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangeDns"/>
<vue-tags-input class="form-control" v-model="currentTags.Dns"
:tags="formData.Dns.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:validation="validateIP()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeDns"/>
</div>
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangeDnsSearch"/>
<vue-tags-input class="form-control" v-model="currentTags.DnsSearch"
:tags="formData.DnsSearch.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:validation="validateDomain()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
@@ -420,36 +441,52 @@ async function del() {
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.networks.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefNetwork"
:placeholder="$t('modals.interface-edit.defaults.networks.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangePeerDefNetwork"/>
<vue-tags-input class="form-control" v-model="currentTags.PeerDefNetwork"
:tags="formData.PeerDefNetwork.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.defaults.networks.placeholder')"
:validation="validateCIDR()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangePeerDefNetwork"/>
<small class="form-text text-muted">{{ $t('modals.interface-edit.defaults.networks.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefAllowedIPs"
:placeholder="$t('modals.interface-edit.defaults.allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangePeerDefAllowedIPs"/>
<vue-tags-input class="form-control" v-model="currentTags.PeerDefAllowedIPs"
:tags="formData.PeerDefAllowedIPs.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.defaults.allowed-ip.placeholder')"
:validation="validateCIDR()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangePeerDefAllowedIPs"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefDns"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangePeerDefDns"/>
<vue-tags-input class="form-control" v-model="currentTags.PeerDefDns"
:tags="formData.PeerDefDns.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:validation="validateIP()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangePeerDefDns"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefDnsSearch"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangePeerDefDnsSearch"/>
<vue-tags-input class="form-control" v-model="currentTags.PeerDefDnsSearch"
:tags="formData.PeerDefDnsSearch.map(str => ({ text: str }))"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:validation="validateDomain()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangePeerDefDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">

View File

@@ -5,7 +5,7 @@ import { interfaceStore } from "@/stores/interfaces";
import { computed, ref, watch } from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
import { VueTagsInput } from '@vojtechlanka/vue-tags-input';
import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import { isIP } from 'is-ip';
@@ -65,6 +65,13 @@ const title = computed(() => {
}
})
const currentTags = ref({
Addresses: "",
AllowedIPs: "",
ExtraAllowedIPs: "",
Dns: "",
DnsSearch: ""
})
const formData = ref(freshPeer())
// functions
@@ -193,73 +200,73 @@ function close() {
function handleChangeAddresses(tags) {
let validInput = true
tags.forEach(tag => {
if (isCidr(tag) === 0) {
if (isCidr(tag.text) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if (validInput) {
formData.value.Addresses = tags
formData.value.Addresses = tags.map(tag => tag.text)
}
}
function handleChangeAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if (isCidr(tag) === 0) {
if (isCidr(tag.text) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if (validInput) {
formData.value.AllowedIPs.Value = tags
formData.value.AllowedIPs.Value = tags.map(tag => tag.text)
}
}
function handleChangeExtraAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if (isCidr(tag) === 0) {
if (isCidr(tag.text) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if (validInput) {
formData.value.ExtraAllowedIPs = tags
formData.value.ExtraAllowedIPs = tags.map(tag => tag.text)
}
}
function handleChangeDns(tags) {
let validInput = true
tags.forEach(tag => {
if (!isIP(tag)) {
if (!isIP(tag.text)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
text: tag.text + " is not a valid IP address",
type: 'error',
})
}
})
if (validInput) {
formData.value.Dns.Value = tags
formData.value.Dns.Value = tags.map(tag => tag.text)
}
}
function handleChangeDnsSearch(tags) {
formData.value.DnsSearch.Value = tags
formData.value.DnsSearch.Value = tags.map(tag => tag.text)
}
async function save() {
@@ -316,17 +323,18 @@ async function del() {
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
<div class="form-group" v-if="selectedInterface.Mode === 'server'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
v-model="formData.PrivateKey">
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
v-model="formData.PublicKey">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
v-model="formData.PresharedKey">
</div>
<div class="form-group" v-if="formData.Mode === 'client'">
@@ -344,34 +352,64 @@ async function del() {
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Addresses"
:placeholder="$t('modals.peer-edit.ip.placeholder')" :add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR" @on-tags-changed="handleChangeAddresses" />
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
:tags="formData.Addresses.map(str => ({ text: str }))"
:placeholder="$t('modals.peer-edit.ip.placeholder')"
:validation="validateCIDR()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeAddresses" />
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.AllowedIPs.Value"
:placeholder="$t('modals.peer-edit.allowed-ip.placeholder')" :add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR" @on-tags-changed="handleChangeAllowedIPs" />
<vue-tags-input class="form-control" v-model="currentTags.AllowedIPs"
:tags="formData.AllowedIPs.Value.map(str => ({ text: str }))"
:placeholder="$t('modals.peer-edit.allowed-ip.placeholder')"
:validation="validateCIDR()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeAllowedIPs" />
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.extra-allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.ExtraAllowedIPs"
:placeholder="$t('modals.peer-edit.extra-allowed-ip.placeholder')" :add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR" @on-tags-changed="handleChangeExtraAllowedIPs" />
<vue-tags-input class="form-control" v-model="currentTags.ExtraAllowedIPs"
:tags="formData.ExtraAllowedIPs.map(str => ({ text: str }))"
:placeholder="$t('modals.peer-edit.extra-allowed-ip.placeholder')"
:validation="validateCIDR()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeExtraAllowedIPs" />
<small class="form-text text-muted">{{ $t('modals.peer-edit.extra-allowed-ip.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns.Value"
:placeholder="$t('modals.peer-edit.dns.placeholder')" :add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP" @on-tags-changed="handleChangeDns" />
<vue-tags-input class="form-control" v-model="currentTags.Dns"
:tags="formData.Dns.Value.map(str => ({ text: str }))"
:placeholder="$t('modals.peer-edit.dns.placeholder')"
:validation="validateIP()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeDns" />
</div>
<div hidden class="form-group">
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch.Value"
:placeholder="$t('modals.peer-edit.dns-search.label')" :add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain" @on-tags-changed="handleChangeDnsSearch" />
<vue-tags-input class="form-control" v-model="currentTags.DnsSearch"
:tags="formData.DnsSearch.Value.map(str => ({ text: str }))"
:placeholder="$t('modals.peer-edit.dns-search.label')"
:validation="validateDomain()"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeDnsSearch" />
</div>
<div class="row">
<div class="form-group col-md-6">

View File

@@ -5,7 +5,7 @@ import {interfaceStore} from "@/stores/interfaces";
import {computed, ref} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
import { VueTagsInput } from '@vojtechlanka/vue-tags-input';
import { freshInterface } from '@/helpers/models';
const { t } = useI18n()
@@ -36,6 +36,7 @@ function freshForm() {
}
}
const currentTag = ref("")
const formData = ref(freshForm())
const title = computed(() => {
@@ -55,7 +56,7 @@ function close() {
}
function handleChangeUserIdentifiers(tags) {
formData.value.Identifiers = tags
formData.value.Identifiers = tags.map(tag => tag.text)
}
async function save() {
@@ -89,10 +90,14 @@ async function save() {
<fieldset>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.identifiers.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Identifiers"
:placeholder="$t('modals.peer-multi-create.identifiers.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
@on-tags-changed="handleChangeUserIdentifiers"/>
<vue-tags-input class="form-control" v-model="currentTag"
:tags="formData.Identifiers.map(str => ({ text: str }))"
:placeholder="$t('modals.peer-multi-create.identifiers.placeholder')"
:add-on-key="[13, 188, 32, 9]"
:save-on-key="[13, 188, 32, 9]"
:allow-edit-tags="true"
:separators="[',', ';', ' ']"
@tags-changed="handleChangeUserIdentifiers"/>
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.identifiers.description') }}</small>
</div>
<div class="form-group">

View File

@@ -89,19 +89,11 @@ watch(() => props.visible, async (newValue, oldValue) => {
function download() {
// credit: https://www.bitdegree.org/learn/javascript-download
let filename = 'WireGuard-Tunnel.conf'
if (selectedPeer.value.DisplayName) {
filename = selectedPeer.value.DisplayName
.replace(/ /g, "_")
.replace(/[^a-zA-Z0-9-_]/g, "")
.substring(0, 16)
+ ".conf"
}
let text = configString.value
let element = document.createElement('a')
element.setAttribute('href', 'data:application/octet-stream;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.setAttribute('download', selectedPeer.value.Filename)
element.style.display = 'none'
document.body.appendChild(element)

View File

@@ -51,6 +51,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Notes = selectedUser.value.Notes
formData.value.Password = ""
formData.value.Disabled = selectedUser.value.Disabled
formData.value.Locked = selectedUser.value.Locked
}
}
}
@@ -165,7 +166,7 @@ async function del() {
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'&&formData.Source==='db'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>

View File

@@ -0,0 +1,295 @@
<script setup>
import Modal from "./Modal.vue";
import { peerStore } from "@/stores/peers";
import { computed, ref, watch } from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import { freshPeer, freshInterface } from '@/helpers/models';
import { profileStore } from "@/stores/profile";
const { t } = useI18n()
const peers = peerStore()
const profile = profileStore()
const props = defineProps({
peerId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedPeer = computed(() => {
let p = peers.Find(props.peerId)
if (!p) {
if (!!props.peerId || props.peerId.length) {
p = profile.peers.find((p) => p.Identifier === props.peerId)
} else {
p = freshPeer() // dummy peer to avoid 'undefined' exceptions
}
}
return p
})
const selectedInterface = computed(() => {
let iId = profile.selectedInterfaceId;
let i = freshInterface() // dummy interface to avoid 'undefined' exceptions
if (iId) {
i = profile.interfaces.find((i) => i.Identifier === iId)
}
return i
})
const title = computed(() => {
if (!props.visible) {
return ""
}
if (selectedPeer.value) {
return t("modals.peer-edit.headline-edit-peer") + " " + selectedPeer.value.Identifier
}
return t("modals.peer-edit.headline-new-peer")
})
const formData = ref(freshPeer())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
if (!selectedPeer.value) {
await peers.PreparePeer(selectedInterface.value.Identifier)
formData.value.Identifier = peers.Prepared.Identifier
formData.value.DisplayName = peers.Prepared.DisplayName
formData.value.UserIdentifier = peers.Prepared.UserIdentifier
formData.value.InterfaceIdentifier = peers.Prepared.InterfaceIdentifier
formData.value.Disabled = peers.Prepared.Disabled
formData.value.ExpiresAt = peers.Prepared.ExpiresAt
formData.value.Notes = peers.Prepared.Notes
formData.value.Endpoint = peers.Prepared.Endpoint
formData.value.EndpointPublicKey = peers.Prepared.EndpointPublicKey
formData.value.AllowedIPs = peers.Prepared.AllowedIPs
formData.value.ExtraAllowedIPs = peers.Prepared.ExtraAllowedIPs
formData.value.PresharedKey = peers.Prepared.PresharedKey
formData.value.PersistentKeepalive = peers.Prepared.PersistentKeepalive
formData.value.PrivateKey = peers.Prepared.PrivateKey
formData.value.PublicKey = peers.Prepared.PublicKey
formData.value.Mode = peers.Prepared.Mode
formData.value.Addresses = peers.Prepared.Addresses
formData.value.CheckAliveAddress = peers.Prepared.CheckAliveAddress
formData.value.Dns = peers.Prepared.Dns
formData.value.DnsSearch = peers.Prepared.DnsSearch
formData.value.Mtu = peers.Prepared.Mtu
formData.value.FirewallMark = peers.Prepared.FirewallMark
formData.value.RoutingTable = peers.Prepared.RoutingTable
formData.value.PreUp = peers.Prepared.PreUp
formData.value.PostUp = peers.Prepared.PostUp
formData.value.PreDown = peers.Prepared.PreDown
formData.value.PostDown = peers.Prepared.PostDown
} else { // fill existing data
formData.value.Identifier = selectedPeer.value.Identifier
formData.value.DisplayName = selectedPeer.value.DisplayName
formData.value.UserIdentifier = selectedPeer.value.UserIdentifier
formData.value.InterfaceIdentifier = selectedPeer.value.InterfaceIdentifier
formData.value.Disabled = selectedPeer.value.Disabled
formData.value.ExpiresAt = selectedPeer.value.ExpiresAt
formData.value.Notes = selectedPeer.value.Notes
formData.value.Endpoint = selectedPeer.value.Endpoint
formData.value.EndpointPublicKey = selectedPeer.value.EndpointPublicKey
formData.value.AllowedIPs = selectedPeer.value.AllowedIPs
formData.value.ExtraAllowedIPs = selectedPeer.value.ExtraAllowedIPs
formData.value.PresharedKey = selectedPeer.value.PresharedKey
formData.value.PersistentKeepalive = selectedPeer.value.PersistentKeepalive
formData.value.PrivateKey = selectedPeer.value.PrivateKey
formData.value.PublicKey = selectedPeer.value.PublicKey
formData.value.Mode = selectedPeer.value.Mode
formData.value.Addresses = selectedPeer.value.Addresses
formData.value.CheckAliveAddress = selectedPeer.value.CheckAliveAddress
formData.value.Dns = selectedPeer.value.Dns
formData.value.DnsSearch = selectedPeer.value.DnsSearch
formData.value.Mtu = selectedPeer.value.Mtu
formData.value.FirewallMark = selectedPeer.value.FirewallMark
formData.value.RoutingTable = selectedPeer.value.RoutingTable
formData.value.PreUp = selectedPeer.value.PreUp
formData.value.PostUp = selectedPeer.value.PostUp
formData.value.PreDown = selectedPeer.value.PreDown
formData.value.PostDown = selectedPeer.value.PostDown
if (!formData.value.Endpoint.Overridable ||
!formData.value.EndpointPublicKey.Overridable ||
!formData.value.AllowedIPs.Overridable ||
!formData.value.PersistentKeepalive.Overridable ||
!formData.value.Dns.Overridable ||
!formData.value.DnsSearch.Overridable ||
!formData.value.Mtu.Overridable ||
!formData.value.FirewallMark.Overridable ||
!formData.value.RoutingTable.Overridable ||
!formData.value.PreUp.Overridable ||
!formData.value.PostUp.Overridable ||
!formData.value.PreDown.Overridable ||
!formData.value.PostDown.Overridable) {
formData.value.IgnoreGlobalSettings = true
}
}
}
}
)
watch(() => formData.value.Disabled, async (newValue, oldValue) => {
if (oldValue && !newValue && formData.value.ExpiresAt) {
formData.value.ExpiresAt = "" // reset expiry date
}
}
)
function close() {
formData.value = freshPeer()
emit('close')
}
async function save() {
try {
if (props.peerId !== '#NEW#') {
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
} else {
await peers.CreatePeer(selectedInterface.value.Identifier, formData.value)
}
close()
} catch (e) {
// console.log(e)
notify({
title: "Failed to save peer!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await peers.DeletePeer(selectedPeer.value.Identifier)
close()
} catch (e) {
// console.log(e)
notify({
title: "Failed to delete peer!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-general') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.display-name.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.display-name.placeholder')"
v-model="formData.DisplayName">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
v-model="formData.PrivateKey">
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
v-model="formData.PublicKey">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
v-model="formData.PresharedKey">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-network') }}</legend>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.peer-edit.keep-alive.label') }}</label>
<input type="number" class="form-control" :placeholder="$t('modals.peer-edit.keep-alive.label')"
v-model="formData.PersistentKeepalive.Value">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.peer-edit.mtu.label') }}</label>
<input type="number" class="form-control" :placeholder="$t('modals.peer-edit.mtu.label')"
v-model="formData.Mtu.Value">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.pre-up.label') }}</label>
<textarea v-model="formData.PreUp.Value" class="form-control" rows="2"
:placeholder="$t('modals.peer-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.post-up.label') }}</label>
<textarea v-model="formData.PostUp.Value" class="form-control" rows="2"
:placeholder="$t('modals.peer-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.pre-down.label') }}</label>
<textarea v-model="formData.PreDown.Value" class="form-control" rows="2"
:placeholder="$t('modals.peer-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.post-down.label') }}</label>
<textarea v-model="formData.PostDown.Value" class="form-control" rows="2"
:placeholder="$t('modals.peer-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-state') }}</legend>
<div class="row">
<div class="form-group col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.Disabled">
<label class="form-check-label">{{ $t('modals.peer-edit.disabled.label') }}</label>
</div>
</div>
<div class="form-group col-md-6">
<label class="form-label">{{ $t('modals.peer-edit.expires-at.label') }}</label>
<input type="date" pattern="\d{4}-\d{2}-\d{2}" class="form-control" min="2023-01-01"
v-model="formData.ExpiresAt">
</div>
</div>
</fieldset>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
$t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style></style>

View File

@@ -42,7 +42,8 @@ export function freshInterface() {
PeerDefPostDown: "",
TotalPeers: 0,
EnabledPeers: 0
EnabledPeers: 0,
Filename: ""
}
}
@@ -120,8 +121,11 @@ export function freshPeer() {
Overridable: true,
},
// Internal value
IgnoreGlobalSettings: false
Filename: "",
// Internal values
IgnoreGlobalSettings: false,
IsSelected: false
}
}
@@ -148,7 +152,10 @@ export function freshUser() {
ApiEnabled: false,
PeerCount: 0
PeerCount: 0,
// Internal values
IsSelected: false
}
}

View File

@@ -1,14 +1,26 @@
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
export function validateCIDR(value) {
return isCidr(value) !== 0
export function validateCIDR() {
return [{
classes: 'invalid-cidr',
rule: ({ text }) => isCidr(text) === 0,
disableAdd: true,
}]
}
export function validateIP(value) {
return isIP(value)
export function validateIP() {
return [{
classes: 'invalid-ip',
rule: ({ text }) => !isIP(text),
disableAdd: true,
}]
}
export function validateDomain(value) {
return true
export function validateDomain() {
return [{
classes: 'invalid-domain',
rule: tag => tag.text.length < 3,
disableAdd: true,
}]
}

View File

@@ -1,9 +1,14 @@
// src/lang/index.js
import de from './translations/de.json';
import ru from './translations/ru.json';
import en from './translations/en.json';
import fr from './translations/fr.json';
import ko from './translations/ko.json';
import pt from './translations/pt.json';
import ru from './translations/ru.json';
import uk from './translations/uk.json';
import vi from './translations/vi.json';
import zh from './translations/zh.json';
import {createI18n} from "vue-i18n";
// Create i18n instance with options
@@ -19,10 +24,14 @@ const i18n = createI18n({
fallbackLocale: "en", // set fallback locale
messages: {
"de": de,
"ru": ru,
"en": en,
"fr": fr,
"ko": ko,
"pt": pt,
"ru": ru,
"uk": uk,
"vi": vi,
"zh": zh
"zh": zh,
}
});

View File

@@ -26,7 +26,7 @@
"placeholder": "Bitte geben Sie Ihren Benutzernamen ein"
},
"password": {
"label": "Kennwort",
"label": "Passwort",
"placeholder": "Bitte geben Sie Ihr Passwort ein"
},
"button": "Anmelden"
@@ -38,8 +38,10 @@
"lang": "Sprache ändern",
"profile": "Mein Profil",
"settings": "Einstellungen",
"audit": "Event Protokoll",
"login": "Anmelden",
"logout": "Abmelden"
"logout": "Abmelden",
"keygen": "Schlüsselgenerator"
},
"home": {
"headline": "WireGuard® VPN Portal",
@@ -79,77 +81,77 @@
},
"interfaces": {
"headline": "Schnittstellenverwaltung",
"headline-peers": "Current VPN Peers",
"headline-endpoints": "Current Endpoints",
"headline-peers": "Aktuelle VPN-Peers",
"headline-endpoints": "Aktuelle Endpunkte",
"no-interface": {
"default-selection": "No Interface available",
"headline": "No interfaces found...",
"abstract": "Click the plus button above to create a new WireGuard interface."
"default-selection": "Keine Schnittstelle verfügbar",
"headline": "Keine Schnittstellen gefunden...",
"abstract": "Klicken Sie auf die Plus-Schaltfläche oben, um eine neue WireGuard-Schnittstelle zu erstellen."
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers available for the selected WireGuard interface."
"headline": "Keine Peers verfügbar",
"abstract": "Derzeit sind keine Peers für die ausgewählte WireGuard-Schnittstelle verfügbar."
},
"table-heading": {
"name": "Name",
"user": "User",
"user": "Benutzer",
"ip": "IP's",
"endpoint": "Endpoint",
"endpoint": "Endpunkt",
"status": "Status"
},
"interface": {
"headline": "Interface status for",
"mode": "mode",
"key": "Public Key",
"endpoint": "Public Endpoint",
"port": "Listening Port",
"peers": "Enabled Peers",
"total-peers": "Total Peers",
"endpoints": "Enabled Endpoints",
"total-endpoints": "Total Endpoints",
"ip": "IP Address",
"default-allowed-ip": "Default allowed IPs",
"dns": "DNS Servers",
"headline": "Schnittstellenstatus für",
"mode": "Modus",
"key": "Öffentlicher Schlüssel",
"endpoint": "Öffentlicher Endpunkt",
"port": "Port",
"peers": "Aktive Peers",
"total-peers": "Gesamtanzahl Peers",
"endpoints": "Aktive Endpunkte",
"total-endpoints": "Gesamtanzahl Endpunkte",
"ip": "IP-Adresse",
"default-allowed-ip": "Standard Erlaubte-IPs",
"dns": "DNS-Server",
"mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval",
"button-show-config": "Show configuration",
"button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick",
"button-edit": "Edit interface"
"default-keep-alive": "Standard Keepalive-Intervall",
"button-show-config": "Konfiguration anzeigen",
"button-download-config": "Konfiguration herunterladen",
"button-store-config": "Konfiguration für wg-quick speichern",
"button-edit": "Schnittstelle bearbeiten"
},
"button-add-interface": "Add Interface",
"button-add-peer": "Add Peer",
"button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer",
"peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at",
"peer-connected": "Connected",
"peer-not-connected": "Not Connected",
"peer-handshake": "Last handshake:"
"button-add-interface": "Schnittstelle hinzufügen",
"button-add-peer": "Peer hinzufügen",
"button-add-peers": "Mehrere Peers hinzufügen",
"button-show-peer": "Peer anzeigen",
"button-edit-peer": "Peer bearbeiten",
"peer-disabled": "Peer ist deaktiviert, Grund:",
"peer-expiring": "Peer läuft ab am",
"peer-connected": "Verbunden",
"peer-not-connected": "Nicht verbunden",
"peer-handshake": "Letzter Handshake:"
},
"users": {
"headline": "Benutzerverwaltung",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"source": "Source",
"firstname": "Vorname",
"lastname": "Nachname",
"source": "Quelle",
"peers": "Peers",
"admin": "Admin"
},
"no-user": {
"headline": "No users available",
"abstract": "Currently, there are no users registered with WireGuard Portal."
"headline": "Keine Benutzer verfügbar",
"abstract": "Derzeit sind keine Benutzer im WireGuard-Portal registriert."
},
"button-add-user": "Add User",
"button-show-user": "Show User",
"button-edit-user": "Edit User",
"user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges",
"no-admin": "User has no administrator privileges"
"button-add-user": "Benutzer hinzufügen",
"button-show-user": "Benutzer anzeigen",
"button-edit-user": "Benutzer bearbeiten",
"user-disabled": "Benutzer ist deaktiviert, Grund:",
"user-locked": "Konto ist gesperrt, Grund:",
"admin": "Benutzer hat Administratorrechte",
"no-admin": "Benutzer hat keine Administratorrechte"
},
"profile": {
"headline": "Meine VPN-Konfigurationen",
@@ -157,16 +159,16 @@
"name": "Name",
"ip": "IP's",
"stats": "Status",
"interface": "Server Interface"
"interface": "Server-Schnittstelle"
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers associated with your user profile."
"headline": "Keine Peers verfügbar",
"abstract": "Derzeit sind keine Peers mit Ihrem Benutzerprofil verknüpft."
},
"peer-connected": "Connected",
"button-add-peer": "Add Peer",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
"peer-connected": "Verbunden",
"button-add-peer": "Peer hinzufügen",
"button-show-peer": "Peer anzeigen",
"button-edit-peer": "Peer bearbeiten"
},
"settings": {
"headline": "Einstellungen",
@@ -188,325 +190,362 @@
"api-link": "API Dokumentation"
}
},
"audit": {
"headline": "Eventprotokoll",
"abstract": "Hier finden Sie das Eventprotokoll aller im WireGuard-Portal vorgenommenen Aktionen.",
"no-entries": {
"headline": "Keine Protokolleinträge verfügbar",
"abstract": "Derzeit sind keine Eventprotokolle aufgezeichnet."
},
"entries-headline": "Protokolleinträge",
"table-heading": {
"id": "#",
"time": "Zeit",
"user": "Benutzer",
"severity": "Schweregrad",
"origin": "Ursprung",
"message": "Nachricht"
}
},
"keygen": {
"headline": "WireGuard Key Generator",
"abstract": "Hier können Sie WireGuard Schlüsselpaare generieren. Die Schlüssel werden lokal auf Ihrem Computer generiert und niemals an den Server gesendet.",
"headline-keypair": "Neues Schlüsselpaar",
"headline-preshared-key": "Neuer Pre-Shared Key",
"button-generate": "Erzeugen",
"private-key": {
"label": "Privater Schlüssel",
"placeholder": "Der private Schlüssel"
},
"public-key": {
"label": "Öffentlicher Schlüssel",
"placeholder": "Der öffentliche Schlüssel"
},
"preshared-key": {
"label": "Pre-Shared Key",
"placeholder": "Der geteilte Schlüssel"
}
},
"modals": {
"user-view": {
"headline": "User Account:",
"tab-user": "Information",
"headline": "Benutzerkonto:",
"tab-user": "Informationen",
"tab-peers": "Peers",
"headline-info": "User Information:",
"headline-notes": "Notes:",
"headline-info": "Benutzerinformationen:",
"headline-notes": "Notizen:",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"phone": "Phone number",
"department": "Department",
"disabled": "Account Disabled",
"locked": "Account Locked",
"no-peers": "User has no associated peers.",
"firstname": "Vorname",
"lastname": "Nachname",
"phone": "Telefonnummer",
"department": "Abteilung",
"api-enabled": "API-Zugriff",
"disabled": "Konto deaktiviert",
"locked": "Konto gesperrt",
"no-peers": "Benutzer hat keine zugeordneten Peers.",
"peers": {
"name": "Name",
"interface": "Interface",
"interface": "Schnittstelle",
"ip": "IP's"
}
},
"user-edit": {
"headline-edit": "Edit user:",
"headline-new": "New user",
"header-general": "General",
"header-personal": "User Information",
"header-notes": "Notes",
"header-state": "State",
"headline-edit": "Benutzer bearbeiten:",
"headline-new": "Neuer Benutzer",
"header-general": "Allgemein",
"header-personal": "Benutzerinformationen",
"header-notes": "Notizen",
"header-state": "Status",
"identifier": {
"label": "Identifier",
"placeholder": "The unique user identifier"
"label": "Kennung",
"placeholder": "Die eindeutige Benutzerkennung"
},
"source": {
"label": "Source",
"placeholder": "The user source"
"label": "Quelle",
"placeholder": "Die Benutzerquelle"
},
"password": {
"label": "Password",
"placeholder": "A super secret password",
"description": "Leave this field blank to keep current password."
"label": "Passwort",
"placeholder": "Ein super geheimes Passwort",
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten."
},
"email": {
"label": "Email",
"placeholder": "The email address"
"label": "E-Mail",
"placeholder": "Die E-Mail-Adresse"
},
"phone": {
"label": "Phone",
"placeholder": "The phone number"
"label": "Telefon",
"placeholder": "Die Telefonnummer"
},
"department": {
"label": "Department",
"placeholder": "The department"
"label": "Abteilung",
"placeholder": "Die Abteilung"
},
"firstname": {
"label": "Firstname",
"placeholder": "Firstname"
"label": "Vorname",
"placeholder": "Vorname"
},
"lastname": {
"label": "Lastname",
"placeholder": "Lastname"
"label": "Nachname",
"placeholder": "Nachname"
},
"notes": {
"label": "Notes",
"label": "Notizen",
"placeholder": ""
},
"disabled": {
"label": "Disabled (no WireGuard connection and no login possible)"
"label": "Deaktiviert (keine WireGuard-Verbindung und kein Login möglich)"
},
"locked": {
"label": "Locked (no login possible, WireGuard connections still work)"
"label": "Gesperrt (kein Login möglich, WireGuard-Verbindungen funktionieren weiterhin)"
},
"admin": {
"label": "Is Admin"
"label": "Ist Administrator"
}
},
"interface-view": {
"headline": "Config for Interface:"
"headline": "Konfiguration für Schnittstelle:"
},
"interface-edit": {
"headline-edit": "Edit Interface:",
"headline-new": "New Interface",
"tab-interface": "Interface",
"tab-peerdef": "Peer Defaults",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Interface Hooks",
"headline-edit": "Schnittstelle bearbeiten:",
"headline-new": "Neue Schnittstelle",
"tab-interface": "Schnittstelle",
"tab-peerdef": "Peer-Standardeinstellungen",
"header-general": "Allgemein",
"header-network": "Netzwerk",
"header-crypto": "Kryptografie",
"header-hooks": "Schnittstellen-Hooks",
"header-peer-hooks": "Hooks",
"header-state": "State",
"header-state": "Status",
"identifier": {
"label": "Identifier",
"placeholder": "The unique interface identifier"
"label": "Kennung",
"placeholder": "Die eindeutige Schnittstellenkennung"
},
"mode": {
"label": "Interface Mode",
"server": "Server Mode",
"client": "Client Mode",
"any": "Unknown Mode"
"label": "Schnittstellenmodus",
"server": "Server-Modus",
"client": "Client-Modus",
"any": "Unbekannter Modus"
},
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the interface"
"label": "Anzeigename",
"placeholder": "Der beschreibende Name für die Schnittstelle"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
"label": "Privater Schlüssel",
"placeholder": "Der private Schlüssel"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
"label": "Öffentlicher Schlüssel",
"placeholder": "Der öffentliche Schlüssel"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
"label": "IP-Adressen",
"placeholder": "IP-Adressen (CIDR-Format)"
},
"listen-port": {
"label": "Listen Port",
"placeholder": "The listening port"
"label": "Port",
"placeholder": "Der Port der WireGuard Schnittstelle"
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
"label": "DNS-Server",
"placeholder": "Die zu verwendenden DNS-Server"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
"label": "DNS-Suchdomänen",
"placeholder": "DNS-Suchpräfixe"
},
"mtu": {
"label": "MTU",
"placeholder": "The interface MTU (0 = keep default)"
"placeholder": "Die Schnittstellen-MTU (0 = Standard beibehalten)"
},
"firewall-mark": {
"label": "Firewall Mark",
"placeholder": "Firewall mark that is applied to outgoing traffic. (0 = automatic)"
"label": "Firewall-Markierung",
"placeholder": "Firewall-Markierung, die auf ausgehenden Datenverkehr angewendet wird. (0 = automatisch)"
},
"routing-table": {
"label": "Routing Table",
"placeholder": "The routing table ID",
"description": "Special cases: off = do not manage routes, 0 = automatic"
"label": "Routing-Tabelle",
"placeholder": "Die Routing-Tabellen-ID",
"description": "Spezialfälle: off = Routen nicht verwalten, 0 = automatisch"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"disabled": {
"label": "Interface Disabled"
"label": "Schnittstelle deaktiviert"
},
"save-config": {
"label": "Automatically save wg-quick config"
"label": "wg-quick Konfiguration automatisch speichern"
},
"defaults": {
"endpoint": {
"label": "Endpoint Address",
"placeholder": "Endpoint Address",
"description": "The endpoint address that peers will connect to."
"label": "Endpunktadresse",
"placeholder": "Endpunktadresse",
"description": "Die Endpunktadresse, mit der sich Peers verbinden. (z.B. wg.example.com oder wg.example.com:51820)"
},
"networks": {
"label": "IP Networks",
"placeholder": "Network Addresses",
"description": "Peers will get IP addresses from those subnets."
"label": "IP-Netzwerke",
"placeholder": "Netzwerkadressen",
"description": "Peers erhalten IP-Adressen aus diesen Subnetzen."
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Default Allowed IP Addresses"
"label": "Erlaubte IP-Adressen",
"placeholder": "Erlaubte IP-Adressen für Peers"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
"placeholder": "Die Client-MTU (0 = Standard beibehalten)"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
"label": "Keepalive-Intervall",
"placeholder": "Persistentes Keepalive (0 = Standard)"
}
},
"button-apply-defaults": "Apply Peer Defaults"
"button-apply-defaults": "Peer-Standardeinstellungen anwenden"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Peer Information",
"section-status": "Current Status",
"section-config": "Configuration",
"identifier": "Identifier",
"ip": "IP Addresses",
"user": "Associated User",
"notes": "Notes",
"expiry-status": "Expires At",
"disabled-status": "Disabled At",
"traffic": "Traffic",
"connection-status": "Connection Stats",
"upload": "Uploaded Bytes (from Server to Peer)",
"download": "Downloaded Bytes (from Peer to Server)",
"pingable": "Is Pingable",
"handshake": "Last Handshake",
"connected-since": "Connected since",
"endpoint": "Endpoint",
"button-download": "Download configuration",
"button-email": "Send configuration via E-Mail"
"headline-endpoint": "Endpunkt:",
"section-info": "Peer-Informationen",
"section-status": "Aktueller Status",
"section-config": "Konfiguration",
"identifier": "Kennung",
"ip": "IP-Adressen",
"user": "Zugeordneter Benutzer",
"notes": "Notizen",
"expiry-status": "Läuft ab am",
"disabled-status": "Deaktiviert am",
"traffic": "Datenverkehr",
"connection-status": "Verbindungsstatistiken",
"upload": "Hochgeladene Bytes (vom Server zum Peer)",
"download": "Heruntergeladene Bytes (vom Peer zum Server)",
"pingable": "Pingbar",
"handshake": "Letzter Handshake",
"connected-since": "Verbunden seit",
"endpoint": "Endpunkt",
"button-download": "Konfiguration herunterladen",
"button-email": "Konfiguration per E-Mail senden"
},
"peer-edit": {
"headline-edit-peer": "Edit peer:",
"headline-edit-endpoint": "Edit endpoint:",
"headline-new-peer": "Create peer",
"headline-new-endpoint": "Create endpoint",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Hooks (Executed on Peer)",
"header-state": "State",
"headline-edit-peer": "Peer bearbeiten:",
"headline-edit-endpoint": "Endpunkt bearbeiten:",
"headline-new-peer": "Peer erstellen",
"headline-new-endpoint": "Endpunkt erstellen",
"header-general": "Allgemein",
"header-network": "Netzwerk",
"header-crypto": "Kryptografie",
"header-hooks": "Hooks (beim Peer ausgeführt)",
"header-state": "Status",
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the peer"
"label": "Anzeigename",
"placeholder": "Der beschreibende Name für den Peer"
},
"linked-user": {
"label": "Linked User",
"placeholder": "The user account which owns this peer"
"label": "Verknüpfter Benutzer",
"placeholder": "Das Benutzerkonto, dem dieser Peer gehört"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
"label": "Privater Schlüssel",
"placeholder": "Der private Schlüssel",
"help": "Der private Schlüssel wird sicher auf dem Server gespeichert. Wenn der Benutzer bereits eine Kopie besitzt, kann dieses Feld entfallen. Der Server funktioniert auch ausschließlich mit dem öffentlichen Schlüssel des Peers."
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
"label": "Öffentlicher Schlüssel",
"placeholder": "Der öffentliche Schlüssel"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "Optional pre-shared key"
"label": "Pre-Shared Key",
"placeholder": "Optionaler geteilter Schlüssel"
},
"endpoint-public-key": {
"label": "Endpoint public Key",
"placeholder": "The public key of the remote endpoint"
"label": "Öffentlicher Endpunktschlüssel",
"placeholder": "Der öffentliche Schlüssel des entfernten Endpunkts"
},
"endpoint": {
"label": "Endpoint Address",
"placeholder": "The address of the remote endpoint"
"label": "Endpunktadresse",
"placeholder": "Die Adresse des entfernten Endpunkts"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
"label": "IP-Adressen",
"placeholder": "IP-Adressen (CIDR-Format)"
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Allowed IP Addresses (CIDR format)"
"label": "Erlaubte IP-Adressen",
"placeholder": "Erlaubte IP-Adressen (CIDR-Format)"
},
"extra-allowed-ip": {
"label": "Extra allowed IP Addresses",
"placeholder": "Extra allowed IP's (Server Sided)",
"description": "Those IP's will be added on the remote WireGuard interface as allowed IP's."
"label": "Zusätzliche erlaubte IP-Adressen",
"placeholder": "Zusätzliche erlaubte IP's (Server-seitig)",
"description": "Diese IPs werden an der entfernten WireGuard-Schnittstelle als erlaubte IPs hinzugefügt."
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
"label": "DNS-Server",
"placeholder": "Die zu verwendenden DNS-Server"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
"label": "DNS-Suchdomänen",
"placeholder": "DNS-Suchpräfixe"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
"label": "Keepalive-Intervall",
"placeholder": "Persistentes Keepalive (0 = Standard)"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
"placeholder": "Die Client-MTU (0 = Standard beibehalten)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
},
"disabled": {
"label": "Peer Disabled"
"label": "Peer deaktiviert"
},
"ignore-global": {
"label": "Ignore global settings"
"label": "Globale Einstellungen ignorieren"
},
"expires-at": {
"label": "Expiry date"
"label": "Ablaufdatum"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",
"headline-endpoint": "Create multiple endpoints",
"headline-peer": "Mehrere Peers erstellen",
"headline-endpoint": "Mehrere Endpunkte erstellen",
"identifiers": {
"label": "User Identifiers",
"placeholder": "User Identifiers",
"description": "A user identifier (the username) for which a peer should be created."
"label": "Benutzerkennungen",
"placeholder": "Benutzerkennungen",
"description": "Eine Benutzerkennung (der Benutzername), für die ein Peer erstellt werden soll."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Display Name Prefix",
"placeholder": "The prefix",
"description": "A prefix that is added to the peers display name."
"headline-endpoint": "Endpunkt:",
"label": "Anzeigename-Präfix",
"placeholder": "Das Präfix",
"description": "Ein Präfix, das dem Anzeigenamen des Peers hinzugefügt wird."
}
}
}

View File

@@ -38,8 +38,10 @@
"lang": "Toggle Language",
"profile": "My Profile",
"settings": "Settings",
"audit": "Audit Log",
"login": "Login",
"logout": "Logout"
"logout": "Logout",
"keygen": "Key Generator"
},
"home": {
"headline": "WireGuard® VPN Portal",
@@ -188,6 +190,42 @@
"api-link": "API Documentation"
}
},
"audit": {
"headline": "Audit Log",
"abstract": "Here you can find the audit log of all actions performed in the WireGuard Portal.",
"no-entries": {
"headline": "No log entries available",
"abstract": "Currently, there are no audit logs recorded."
},
"entries-headline": "Log Entries",
"table-heading": {
"id": "#",
"time": "Time",
"user": "User",
"severity": "Severity",
"origin": "Origin",
"message": "Message"
}
},
"keygen": {
"headline": "WireGuard Key Generator",
"abstract": "Generate a new WireGuard keys. The keys are generated in your local browser and are never sent to the server.",
"headline-keypair": "New Key Pair",
"headline-preshared-key": "New Preshared Key",
"button-generate": "Generate",
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "The pre-shared key"
}
},
"modals": {
"user-view": {
"headline": "User Account:",
@@ -355,7 +393,7 @@
"endpoint": {
"label": "Endpoint Address",
"placeholder": "Endpoint Address",
"description": "The endpoint address that peers will connect to."
"description": "The endpoint address that peers will connect to. (e.g. wg.example.com or wg.example.com:51820)"
},
"networks": {
"label": "IP Networks",
@@ -421,7 +459,8 @@
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
"placeholder": "The private key",
"help": "The private key is stored securely on the server. If the user already holds a copy, you may omit this field. The server still functions exclusively with the peers public key."
},
"public-key": {
"label": "Public Key",

View File

@@ -0,0 +1,515 @@
{
"languages": {
"fr": "Français"
},
"general": {
"pagination": {
"size": "Nombre d'éléments",
"all": "Tous (lent)"
},
"search": {
"placeholder": "Rechercher...",
"button": "Rechercher"
},
"select-all": "Tout sélectionner",
"yes": "Oui",
"no": "Non",
"cancel": "Annuler",
"close": "Fermer",
"save": "Enregistrer",
"delete": "Supprimer"
},
"login": {
"headline": "Veuillez vous connecter",
"username": {
"label": "Nom d'utilisateur",
"placeholder": "Veuillez entrer votre nom d'utilisateur"
},
"password": {
"label": "Mot de passe",
"placeholder": "Veuillez entrer votre mot de passe"
},
"button": "Se connecter"
},
"menu": {
"home": "Accueil",
"interfaces": "Interfaces",
"users": "Utilisateurs",
"lang": "Changer de langue",
"profile": "Mon profil",
"settings": "Paramètres",
"login": "Se connecter",
"logout": "Se déconnecter"
},
"home": {
"headline": "Portail VPN WireGuard®",
"info-headline": "Plus d'informations",
"abstract": "WireGuard® est un VPN extrêmement simple mais rapide et moderne qui utilise une cryptographie de pointe. Il vise à être plus rapide, plus simple, plus léger et plus utile qu'IPsec, tout en évitant le casse-tête massif. Il se veut considérablement plus performant qu'OpenVPN.",
"installation": {
"box-header": "Installation de WireGuard",
"headline": "Installation",
"content": "Les instructions d'installation du logiciel client sont disponibles sur le site Web officiel de WireGuard.",
"button": "Ouvrir les instructions"
},
"about-wg": {
"box-header": "À propos de WireGuard",
"headline": "À propos",
"content": "WireGuard® est un VPN extrêmement simple mais rapide et moderne qui utilise une cryptographie de pointe.",
"button": "Plus d'informations"
},
"about-portal": {
"box-header": "À propos du Portail WireGuard",
"headline": "Portail WireGuard",
"content": "Le Portail WireGuard est un portail de configuration simple basé sur le Web pour WireGuard.",
"button": "Plus d'informations"
},
"profiles": {
"headline": "Profils VPN",
"abstract": "Vous pouvez accéder et télécharger vos configurations VPN personnelles via votre profil utilisateur.",
"content": "Pour trouver tous vos profils configurés, cliquez sur le bouton ci-dessous.",
"button": "Ouvrir mon profil"
},
"admin": {
"headline": "Zone d'administration",
"abstract": "Dans la zone d'administration, vous pouvez gérer les pairs WireGuard et l'interface du serveur, ainsi que les utilisateurs autorisés à se connecter au Portail WireGuard.",
"content": "",
"button-admin": "Ouvrir l'administration du serveur",
"button-user": "Ouvrir l'administration des utilisateurs"
}
},
"interfaces": {
"headline": "Administration des interfaces",
"headline-peers": "Pairs VPN actuels",
"headline-endpoints": "Points de terminaison actuels",
"no-interface": {
"default-selection": "Aucune interface disponible",
"headline": "Aucune interface trouvée...",
"abstract": "Cliquez sur le bouton plus ci-dessus pour créer une nouvelle interface WireGuard."
},
"no-peer": {
"headline": "Aucun pair disponible",
"abstract": "Actuellement, aucun pair n'est disponible pour l'interface WireGuard sélectionnée."
},
"table-heading": {
"name": "Nom",
"user": "Utilisateur",
"ip": "IP",
"endpoint": "Point de terminaison",
"status": "Statut"
},
"interface": {
"headline": "État de l'interface pour",
"mode": "mode",
"key": "Clé publique",
"endpoint": "Point de terminaison public",
"port": "Port d'écoute",
"peers": "Pairs activés",
"total-peers": "Total des pairs",
"endpoints": "Points de terminaison activés",
"total-endpoints": "Total des points de terminaison",
"ip": "Adresse IP",
"default-allowed-ip": "IP autorisées par défaut",
"dns": "Serveurs DNS",
"mtu": "MTU",
"default-keep-alive": "Intervalle Keepalive par défaut",
"button-show-config": "Afficher la configuration",
"button-download-config": "Télécharger la configuration",
"button-store-config": "Enregistrer la configuration pour wg-quick",
"button-edit": "Modifier l'interface"
},
"button-add-interface": "Ajouter une interface",
"button-add-peer": "Ajouter un pair",
"button-add-peers": "Ajouter plusieurs pairs",
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair",
"peer-disabled": "Le pair est désactivé, raison :",
"peer-expiring": "Le pair expire le",
"peer-connected": "Connecté",
"peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :",
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
},
"users": {
"headline": "Administration des utilisateurs",
"table-heading": {
"id": "ID",
"email": "E-mail",
"firstname": "Prénom",
"lastname": "Nom",
"source": "Source",
"peers": "Pairs",
"admin": "Admin"
},
"no-user": {
"headline": "Aucun utilisateur disponible",
"abstract": "Actuellement, aucun utilisateur n'est enregistré auprès du Portail WireGuard."
},
"button-add-user": "Ajouter un utilisateur",
"button-show-user": "Afficher l'utilisateur",
"button-edit-user": "Modifier l'utilisateur",
"user-disabled": "L'utilisateur est désactivé, raison :",
"user-locked": "Le compte est verrouillé, raison :",
"admin": "L'utilisateur a des privilèges d'administrateur",
"no-admin": "L'utilisateur n'a pas de privilèges d'administrateur"
},
"profile": {
"headline": "Mes pairs VPN",
"table-heading": {
"name": "Nom",
"ip": "IP",
"stats": "Statut",
"interface": "Interface serveur"
},
"no-peer": {
"headline": "Aucun pair disponible",
"abstract": "Actuellement, aucun pair n'est associé à votre profil utilisateur."
},
"peer-connected": "Connecté",
"button-add-peer": "Ajouter un pair",
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
},
"settings": {
"headline": "Paramètres",
"abstract": "Ici, vous pouvez modifier vos paramètres personnels.",
"api": {
"headline": "Paramètres de l'API",
"abstract": "Ici, vous pouvez configurer les paramètres de l'API RESTful.",
"active-description": "L'API est actuellement active pour votre compte utilisateur. Toutes les requêtes API sont authentifiées avec l'authentification de base. Utilisez les informations d'identification suivantes pour l'authentification.",
"inactive-description": "L'API est actuellement inactive. Appuyez sur le bouton ci-dessous pour l'activer.",
"user-label": "Nom d'utilisateur de l'API :",
"user-placeholder": "L'utilisateur de l'API",
"token-label": "Mot de passe de l'API :",
"token-placeholder": "Le jeton de l'API",
"token-created-label": "Accès API accordé le :",
"button-disable-title": "Désactiver l'API, cela invalidera le jeton actuel.",
"button-disable-text": "Désactiver l'API",
"button-enable-title": "Activer l'API, cela générera un nouveau jeton.",
"button-enable-text": "Activer l'API",
"api-link": "Documentation de l'API"
}
},
"modals": {
"user-view": {
"headline": "Compte utilisateur :",
"tab-user": "Informations",
"tab-peers": "Pairs",
"headline-info": "Informations sur l'utilisateur :",
"headline-notes": "Notes :",
"email": "E-mail",
"firstname": "Prénom",
"lastname": "Nom",
"phone": "Numéro de téléphone",
"department": "Département",
"api-enabled": "Accès API",
"disabled": "Compte désactivé",
"locked": "Compte verrouillé",
"no-peers": "L'utilisateur n'a pas de pairs associés.",
"peers": {
"name": "Nom",
"interface": "Interface",
"ip": "IP"
}
},
"user-edit": {
"headline-edit": "Modifier l'utilisateur :",
"headline-new": "Nouvel utilisateur",
"header-general": "Général",
"header-personal": "Informations sur l'utilisateur",
"header-notes": "Notes",
"header-state": "État",
"identifier": {
"label": "Identifiant",
"placeholder": "L'identifiant unique de l'utilisateur"
},
"source": {
"label": "Source",
"placeholder": "La source de l'utilisateur"
},
"password": {
"label": "Mot de passe",
"placeholder": "Un mot de passe super secret",
"description": "Laissez ce champ vide pour conserver le mot de passe actuel."
},
"email": {
"label": "E-mail",
"placeholder": "L'adresse e-mail"
},
"phone": {
"label": "Téléphone",
"placeholder": "Le numéro de téléphone"
},
"department": {
"label": "Département",
"placeholder": "Le département"
},
"firstname": {
"label": "Prénom",
"placeholder": "Prénom"
},
"lastname": {
"label": "Nom",
"placeholder": "Nom"
},
"notes": {
"label": "Notes",
"placeholder": ""
},
"disabled": {
"label": "Désactivé (aucune connexion WireGuard et aucune connexion possible)"
},
"locked": {
"label": "Verrouillé (aucune connexion possible, les connexions WireGuard fonctionnent toujours)"
},
"admin": {
"label": "Est Admin"
}
},
"interface-view": {
"headline": "Configuration pour l'interface :"
},
"interface-edit": {
"headline-edit": "Modifier l'interface :",
"headline-new": "Nouvelle interface",
"tab-interface": "Interface",
"tab-peerdef": "Valeurs par défaut des pairs",
"header-general": "Général",
"header-network": "Réseau",
"header-crypto": "Cryptographie",
"header-hooks": "Hooks d'interface",
"header-peer-hooks": "Hooks",
"header-state": "État",
"identifier": {
"label": "Identifiant",
"placeholder": "L'identifiant unique de l'interface"
},
"mode": {
"label": "Mode de l'interface",
"server": "Mode serveur",
"client": "Mode client",
"any": "Mode inconnu"
},
"display-name": {
"label": "Nom d'affichage",
"placeholder": "Le nom descriptif de l'interface"
},
"private-key": {
"label": "Clé privée",
"placeholder": "La clé privée"
},
"public-key": {
"label": "Clé publique",
"placeholder": "La clé publique"
},
"ip": {
"label": "Adresses IP",
"placeholder": "Adresses IP (format CIDR)"
},
"listen-port": {
"label": "Port d'écoute",
"placeholder": "Le port d'écoute"
},
"dns": {
"label": "Serveur DNS",
"placeholder": "Les serveurs DNS qui doivent être utilisés"
},
"dns-search": {
"label": "Domaines de recherche DNS",
"placeholder": "Préfixes de recherche DNS"
},
"mtu": {
"label": "MTU",
"placeholder": "Le MTU de l'interface (0 = conserver la valeur par défaut)"
},
"firewall-mark": {
"label": "Marque de pare-feu",
"placeholder": "Marque de pare-feu appliquée au trafic sortant. (0 = automatique)"
},
"routing-table": {
"label": "Table de routage",
"placeholder": "L'ID de la table de routage",
"description": "Cas particuliers : off = ne pas gérer les routes, 0 = automatique"
},
"pre-up": {
"label": "Pré-Up",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"pre-down": {
"label": "Pré-Down",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"disabled": {
"label": "Interface désactivée"
},
"save-config": {
"label": "Enregistrer automatiquement la configuration wg-quick"
},
"defaults": {
"endpoint": {
"label": "Adresse du point de terminaison",
"placeholder": "Adresse du point de terminaison",
"description": "L'adresse du point de terminaison auquel les pairs se connecteront. (par exemple, wg.example.com ou wg.example.com:51820)"
},
"networks": {
"label": "Réseaux IP",
"placeholder": "Adresses de réseau",
"description": "Les pairs recevront des adresses IP de ces sous-réseaux."
},
"allowed-ip": {
"label": "Adresses IP autorisées",
"placeholder": "Adresses IP autorisées par défaut"
},
"mtu": {
"label": "MTU",
"placeholder": "Le MTU du client (0 = conserver la valeur par défaut)"
},
"keep-alive": {
"label": "Intervalle Keep Alive",
"placeholder": "Persistent Keepalive (0 = par défaut)"
}
},
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs"
},
"peer-view": {
"headline-peer": "Pair :",
"headline-endpoint": "Point de terminaison :",
"section-info": "Informations sur le pair",
"section-status": "État actuel",
"section-config": "Configuration",
"identifier": "Identifiant",
"ip": "Adresses IP",
"user": "Utilisateur associé",
"notes": "Notes",
"expiry-status": "Expire le",
"disabled-status": "Désactivé le",
"traffic": "Trafic",
"connection-status": "Statistiques de connexion",
"upload": "Octets envoyés (du serveur au pair)",
"download": "Octets téléchargés (du pair au serveur)",
"pingable": "Peut être pingé",
"handshake": "Dernière négociation",
"connected-since": "Connecté depuis",
"endpoint": "Point de terminaison",
"button-download": "Télécharger la configuration",
"button-email": "Envoyer la configuration par e-mail"
},
"peer-edit": {
"headline-edit-peer": "Modifier le pair :",
"headline-edit-endpoint": "Modifier le point de terminaison :",
"headline-new-peer": "Créer un pair",
"headline-new-endpoint": "Créer un point de terminaison",
"header-general": "Général",
"header-network": "Réseau",
"header-crypto": "Cryptographie",
"header-hooks": "Hooks (exécutés sur le pair)",
"header-state": "État",
"display-name": {
"label": "Nom d'affichage",
"placeholder": "Le nom descriptif du pair"
},
"linked-user": {
"label": "Utilisateur lié",
"placeholder": "Le compte utilisateur qui possède ce pair"
},
"private-key": {
"label": "Clé privée",
"placeholder": "La clé privée"
},
"public-key": {
"label": "Clé publique",
"placeholder": "La clé publique"
},
"preshared-key": {
"label": "Clé pré-partagée",
"placeholder": "Clé pré-partagée facultative"
},
"endpoint-public-key": {
"label": "Clé publique du point de terminaison",
"placeholder": "La clé publique du point de terminaison distant"
},
"endpoint": {
"label": "Adresse du point de terminaison",
"placeholder": "L'adresse du point de terminaison distant"
},
"ip": {
"label": "Adresses IP",
"placeholder": "Adresses IP (format CIDR)"
},
"allowed-ip": {
"label": "Adresses IP autorisées",
"placeholder": "Adresses IP autorisées (format CIDR)"
},
"extra-allowed-ip": {
"label": "Adresses IP autorisées supplémentaires",
"placeholder": "IP autorisées supplémentaires (côté serveur)",
"description": "Ces IP seront ajoutées à l'interface WireGuard distante comme IP autorisées."
},
"dns": {
"label": "Serveur DNS",
"placeholder": "Les serveurs DNS qui doivent être utilisés"
},
"dns-search": {
"label": "Domaines de recherche DNS",
"placeholder": "Préfixes de recherche DNS"
},
"keep-alive": {
"label": "Intervalle Keep Alive",
"placeholder": "Persistent Keepalive (0 = par défaut)"
},
"mtu": {
"label": "MTU",
"placeholder": "Le MTU du client (0 = conserver la valeur par défaut)"
},
"pre-up": {
"label": "Pré-Up",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"pre-down": {
"label": "Pré-Down",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Une ou plusieurs commandes bash séparées par ;"
},
"disabled": {
"label": "Pair désactivé"
},
"ignore-global": {
"label": "Ignorer les paramètres globaux"
},
"expires-at": {
"label": "Date d'expiration"
}
},
"peer-multi-create": {
"headline-peer": "Créer plusieurs pairs",
"headline-endpoint": "Créer plusieurs points de terminaison",
"identifiers": {
"label": "Identifiants d'utilisateur",
"placeholder": "Identifiants d'utilisateur",
"description": "Un identifiant d'utilisateur (le nom d'utilisateur) pour lequel un pair doit être créé."
},
"prefix": {
"headline-peer": "Pair :",
"headline-endpoint": "Point de terminaison :",
"label": "Préfixe du nom d'affichage",
"placeholder": "Le préfixe",
"description": "Un préfixe qui est ajouté au nom d'affichage des pairs."
}
}
}
}

View File

@@ -0,0 +1,532 @@
{
"languages": {
"ko": "한국어"
},
"general": {
"pagination": {
"size": "항목 수",
"all": "전체 (느림)"
},
"search": {
"placeholder": "검색...",
"button": "검색"
},
"select-all": "모두 선택",
"yes": "예",
"no": "아니오",
"cancel": "취소",
"close": "닫기",
"save": "저장",
"delete": "삭제"
},
"login": {
"headline": "로그인하세요",
"username": {
"label": "사용자 이름",
"placeholder": "사용자 이름을 입력하세요"
},
"password": {
"label": "비밀번호",
"placeholder": "비밀번호를 입력하세요"
},
"button": "로그인"
},
"menu": {
"home": "홈",
"interfaces": "인터페이스",
"users": "사용자",
"lang": "언어 변경",
"profile": "내 프로필",
"settings": "설정",
"audit": "감사 로그",
"login": "로그인",
"logout": "로그아웃"
},
"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 포털 정보",
"headline": "WireGuard 포털",
"content": "WireGuard 포털은 WireGuard를 위한 간단한 웹 기반 구성 포털입니다.",
"button": "더 보기"
},
"profiles": {
"headline": "VPN 프로필",
"abstract": "사용자 프로필을 통해 개인 VPN 구성에 액세스하고 다운로드할 수 있습니다.",
"content": "구성된 모든 프로필을 찾으려면 아래 버튼을 클릭하세요.",
"button": "내 프로필 열기"
},
"admin": {
"headline": "관리 영역",
"abstract": "관리 영역에서는 WireGuard 피어 및 서버 인터페이스뿐만 아니라 WireGuard 포털에 로그인할 수 있는 사용자도 관리할 수 있습니다.",
"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": "인터페이스 상태:",
"mode": "모드",
"key": "공개 키",
"endpoint": "공개 엔드포인트",
"port": "수신 포트",
"peers": "활성화된 피어",
"total-peers": "총 피어 수",
"endpoints": "활성화된 엔드포인트",
"total-endpoints": "총 엔드포인트 수",
"ip": "IP 주소",
"default-allowed-ip": "기본 허용 IP",
"dns": "DNS 서버",
"mtu": "MTU",
"default-keep-alive": "기본 Keepalive 간격",
"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": "피어 편집",
"peer-disabled": "피어가 비활성화됨, 이유:",
"peer-expiring": "피어 만료 예정:",
"peer-connected": "연결됨",
"peer-not-connected": "연결되지 않음",
"peer-handshake": "마지막 핸드셰이크:"
},
"users": {
"headline": "사용자 관리",
"table-heading": {
"id": "ID",
"email": "이메일",
"firstname": "이름",
"lastname": "성",
"source": "소스",
"peers": "피어",
"admin": "관리자"
},
"no-user": {
"headline": "사용 가능한 사용자 없음",
"abstract": "현재 WireGuard 포털에 등록된 사용자가 없습니다."
},
"button-add-user": "사용자 추가",
"button-show-user": "사용자 보기",
"button-edit-user": "사용자 편집",
"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 Auth)으로 인증됩니다. 인증에 다음 자격 증명을 사용하세요.",
"inactive-description": "현재 API가 비활성화되어 있습니다. 활성화하려면 아래 버튼을 누르세요.",
"user-label": "API 사용자 이름:",
"user-placeholder": "API 사용자",
"token-label": "API 비밀번호:",
"token-placeholder": "API 토큰",
"token-created-label": "API 액세스 권한 부여 시각: ",
"button-disable-title": "API를 비활성화합니다. 현재 토큰이 무효화됩니다.",
"button-disable-text": "API 비활성화",
"button-enable-title": "API를 활성화합니다. 새 토큰이 생성됩니다.",
"button-enable-text": "API 활성화",
"api-link": "API 문서"
}
},
"audit": {
"headline": "감사 로그",
"abstract": "여기에서 WireGuard 포털에서 수행된 모든 작업의 감사 로그를 찾을 수 있습니다.",
"no-entries": {
"headline": "로그 항목 없음",
"abstract": "현재 기록된 감사 로그가 없습니다."
},
"entries-headline": "로그 항목",
"table-heading": {
"id": "#",
"time": "시간",
"user": "사용자",
"severity": "심각도",
"origin": "출처",
"message": "메시지"
}
},
"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": "현재 비밀번호를 유지하려면 이 필드를 비워 두세요."
},
"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": "관리자 여부"
}
},
"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": "알 수 없는 모드"
},
"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": "하나 이상의 bash 명령 (;으로 구분)"
},
"post-up": {
"label": "Post-Up",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"post-down": {
"label": "Post-Down",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"disabled": {
"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": "허용된 IP 주소",
"placeholder": "기본 허용 IP 주소"
},
"mtu": {
"label": "MTU",
"placeholder": "클라이언트 MTU (0 = 기본값 유지)"
},
"keep-alive": {
"label": "Keep Alive 간격",
"placeholder": "영구 Keepalive (0 = 기본값)"
}
},
"button-apply-defaults": "피어 기본값 적용"
},
"peer-view": {
"headline-peer": "피어:",
"headline-endpoint": "엔드포인트:",
"section-info": "피어 정보",
"section-status": "현재 상태",
"section-config": "구성",
"identifier": "식별자",
"ip": "IP 주소",
"user": "연결된 사용자",
"notes": "메모",
"expiry-status": "만료 시각",
"disabled-status": "비활성화 시각",
"traffic": "트래픽",
"connection-status": "연결 통계",
"upload": "업로드된 바이트 (서버에서 피어로)",
"download": "다운로드된 바이트 (피어에서 서버로)",
"pingable": "핑 가능 여부",
"handshake": "마지막 핸드셰이크",
"connected-since": "연결 시작 시각",
"endpoint": "엔드포인트",
"button-download": "구성 다운로드",
"button-email": "이메일로 구성 보내기"
},
"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": "개인 키"
},
"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": "허용된 IP 주소",
"placeholder": "허용된 IP 주소 (CIDR 형식)"
},
"extra-allowed-ip": {
"label": "추가 허용 IP 주소",
"placeholder": "추가 허용 IP (서버 측)",
"description": "이 IP 주소는 원격 WireGuard 인터페이스에 허용된 IP로 추가됩니다."
},
"dns": {
"label": "DNS 서버",
"placeholder": "사용해야 하는 DNS 서버"
},
"dns-search": {
"label": "DNS 검색 도메인",
"placeholder": "DNS 검색 접두사"
},
"keep-alive": {
"label": "Keep Alive 간격",
"placeholder": "영구 Keepalive (0 = 기본값)"
},
"mtu": {
"label": "MTU",
"placeholder": "클라이언트 MTU (0 = 기본값 유지)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"post-up": {
"label": "Post-Up",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"post-down": {
"label": "Post-Down",
"placeholder": "하나 이상의 bash 명령 (;으로 구분)"
},
"disabled": {
"label": "피어 비활성화됨"
},
"ignore-global": {
"label": "전역 설정 무시"
},
"expires-at": {
"label": "만료 날짜"
}
},
"peer-multi-create": {
"headline-peer": "여러 피어 생성",
"headline-endpoint": "여러 엔드포인트 생성",
"identifiers": {
"label": "사용자 식별자",
"placeholder": "사용자 식별자",
"description": "피어를 생성할 사용자 식별자 (사용자 이름)."
},
"prefix": {
"headline-peer": "피어:",
"headline-endpoint": "엔드포인트:",
"label": "표시 이름 접두사",
"placeholder": "접두사",
"description": "피어 표시 이름에 추가되는 접두사."
}
}
}
}

View File

@@ -0,0 +1,552 @@
{
"languages": {
"pt": "Português"
},
"general": {
"pagination": {
"size": "Número de Elementos",
"all": "Todos (lento)"
},
"search": {
"placeholder": "Pesquisar...",
"button": "Pesquisar"
},
"select-all": "Selecionar tudo",
"yes": "Sim",
"no": "Não",
"cancel": "Cancelar",
"close": "Fechar",
"save": "Guardar",
"delete": "Eliminar"
},
"login": {
"headline": "Por favor, inicie a sessão",
"username": {
"label": "Nome de utilizador",
"placeholder": "Por favor, insira o seu nome de utilizador"
},
"password": {
"label": "Palavra-passe",
"placeholder": "Por favor, insira a sua palavra-passe"
},
"button": "Iniciar sessão"
},
"menu": {
"home": "Início",
"interfaces": "Interfaces",
"users": "Utilizadores",
"lang": "Alterar idioma",
"profile": "O Meu Perfil",
"settings": "Definições",
"audit": "Registo de Auditoria",
"login": "Iniciar Sessão",
"logout": "Terminar Sessão",
"keygen": "Gerador de Chave"
},
"home": {
"headline": "WireGuard® Portal VPN",
"info-headline": "Mais Informações",
"abstract": "WireGuard® é uma VPN extremamente simples, mas rápida e moderna que utiliza criptografia de última geração. O seu objetivo é ser mais rápida, simples, leve e útil que o IPsec, enquanto evita grandes dores de cabeça. Pretende ser consideravelmente mais eficiente que o OpenVPN.",
"installation": {
"box-header": "Instalação do WireGuard",
"headline": "Instalação",
"content": "As instruções de instalação para o software cliente podem ser encontradas no site oficial do WireGuard.",
"button": "Abrir Instruções"
},
"about-wg": {
"box-header": "Sobre o WireGuard",
"headline": "Sobre",
"content": "WireGuard® é uma VPN extremamente simples, mas rápida e moderna que utiliza criptografia de última geração.",
"button": "Mais"
},
"about-portal": {
"box-header": "Sobre o WireGuard Portal",
"headline": "WireGuard Portal",
"content": "WireGuard Portal é um portal web de configuração simples para o WireGuard.",
"button": "Mais"
},
"profiles": {
"headline": "Perfis VPN",
"abstract": "Pode aceder e baixar as suas configurações pessoais de VPN através do seu Perfil de Utilizador.",
"content": "Para encontrar todos os seus perfis configurados, clique no botão abaixo.",
"button": "Abrir meu perfil"
},
"admin": {
"headline": "Área de Administração",
"abstract": "Na área de administração, pode gerir os peers do WireGuard, a interface do servidor e os utilizadores que têm permissão para aceder ao Portal WireGuard.",
"content": "",
"button-admin": "Abrir Administração do Servidor",
"button-user": "Abrir Administração de Utilizadores"
}
},
"interfaces": {
"headline": "Administração de Interfaces",
"headline-peers": "Peers VPN Atuais",
"headline-endpoints": "Endpoints Atuais",
"no-interface": {
"default-selection": "Nenhuma interface disponível",
"headline": "Nenhuma interface encontrada...",
"abstract": "Clique no botão + acima para criar uma nova interface WireGuard."
},
"no-peer": {
"headline": "Nenhum peer disponível",
"abstract": "Atualmente, não há peers disponíveis para a interface WireGuard selecionada."
},
"table-heading": {
"name": "Nome",
"user": "Utilizador",
"ip": "IPs",
"endpoint": "Endpoint",
"status": "Status"
},
"interface": {
"headline": "Status da interface para",
"mode": "modo",
"key": "Chave Pública",
"endpoint": "Endpoint Público",
"port": "Porta de Escuta",
"peers": "Peers Ativados",
"total-peers": "Total de Peers",
"endpoints": "Endpoints Ativados",
"total-endpoints": "Total de Endpoints",
"ip": "Endereço IP",
"default-allowed-ip": "IPs permitidos por padrão",
"dns": "Servidores DNS",
"mtu": "MTU",
"default-keep-alive": "Intervalo de Keepalive Padrão",
"button-show-config": "Mostrar configuração",
"button-download-config": "Baixar configuração",
"button-store-config": "Armazenar configuração para wg-quick",
"button-edit": "Editar interface"
},
"button-add-interface": "Adicionar Interface",
"button-add-peer": "Adicionar Peer",
"button-add-peers": "Adicionar Vários Peers",
"button-show-peer": "Mostrar Peer",
"button-edit-peer": "Editar Peer",
"peer-disabled": "Peer desativado, razão:",
"peer-expiring": "Peer expira em",
"peer-connected": "Conectado",
"peer-not-connected": "Não Conectado",
"peer-handshake": "Último handshake:"
},
"users": {
"headline": "Administração de Utilizadores",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Primeiro Nome",
"lastname": "Último Nome",
"source": "Fonte",
"peers": "Peers",
"admin": "Administrador"
},
"no-user": {
"headline": "Nenhum utilizador disponível",
"abstract": "Atualmente, não há utilizadores registados no Portal WireGuard."
},
"button-add-user": "Adicionar Utilizador",
"button-show-user": "Mostrar Utilizador",
"button-edit-user": "Editar Utilizador",
"user-disabled": "Utilizador desativado, razão:",
"user-locked": "Conta bloqueada, razão:",
"admin": "O utilizador tem privilégios de administrador",
"no-admin": "O utilizador não tem privilégios de administrador"
},
"profile": {
"headline": "Os Meus Peers VPN",
"table-heading": {
"name": "Nome",
"ip": "IPs",
"stats": "Status",
"interface": "Interface do Servidor"
},
"no-peer": {
"headline": "Nenhum peer disponível",
"abstract": "Atualmente, não há peers associados ao seu perfil de utilizador."
},
"peer-connected": "Conectado",
"button-add-peer": "Adicionar Peer",
"button-show-peer": "Mostrar Peer",
"button-edit-peer": "Editar Peer"
},
"settings": {
"headline": "Definições",
"abstract": "Aqui pode alterar suas Definições pessoais.",
"api": {
"headline": "Definições da API",
"abstract": "Aqui pode configurar as definições da API RESTful.",
"active-description": "A API está atualmente ativa para a sua conta de utilizador. Todos os pedidos para a API são autenticadas com Basic Auth. Use as seguintes credenciais para autenticação.",
"inactive-description": "A API está atualmente inativa. Pressione o botão abaixo para ativá-la.",
"user-label": "Nome de utilizador API:",
"user-placeholder": "O utilizador da API",
"token-label": "Senha da API:",
"token-placeholder": "O token da API",
"token-created-label": "Acesso API concedido em: ",
"button-disable-title": "Desativar API, invalidando o token atual.",
"button-disable-text": "Desativar API",
"button-enable-title": "Ativar API, gerando um novo token.",
"button-enable-text": "Ativar API",
"api-link": "Documentação da API"
}
},
"audit": {
"headline": "Registo de Auditoria",
"abstract": "Aqui pode encontrar o registo de auditoria de todas as ações realizadas no WireGuard Portal.",
"no-entries": {
"headline": "Nenhuma entrada no registo",
"abstract": "Atualmente, não há entradas de registo de auditoria gravadas."
},
"entries-headline": "Entradas do Registo",
"table-heading": {
"id": "#",
"time": "Hora",
"user": "Utilizador",
"severity": "Gravidade",
"origin": "Origem",
"message": "Mensagem"
}
},
"keygen": {
"headline": "Gerador de Chaves WireGuard",
"abstract": "Gere novas chaves WireGuard. As chaves são geradas no seu browser e nunca são enviadas para o servidor.",
"headline-keypair": "Novo Par de Chaves",
"headline-preshared-key": "Nova Chave Pré-Partilhada",
"button-generate": "Gerar",
"private-key": {
"label": "Chave Privada",
"placeholder": "A chave privada"
},
"public-key": {
"label": "Chave Pública",
"placeholder": "A chave pública"
},
"preshared-key": {
"label": "Chave Pré-Partilhada",
"placeholder": "A chave pré-partilhada"
}
},
"modals": {
"user-view": {
"headline": "Conta de Utilizador:",
"tab-user": "Informação",
"tab-peers": "Peers",
"headline-info": "Informação do Utilizador:",
"headline-notes": "Notas:",
"email": "E-Mail",
"firstname": "Primeiro Nome",
"lastname": "Último Nome",
"phone": "Número de Telefone",
"department": "Departamento",
"api-enabled": "Acesso API",
"disabled": "Conta Desativada",
"locked": "Conta Bloqueada",
"no-peers": "O utilizador não tem peers associados.",
"peers": {
"name": "Nome",
"interface": "Interface",
"ip": "IP's"
}
},
"user-edit": {
"headline-edit": "Editar utilizador:",
"headline-new": "Novo utilizador",
"header-general": "Geral",
"header-personal": "Informação do Utilizador",
"header-notes": "Notas",
"header-state": "Estado",
"identifier": {
"label": "Identificador",
"placeholder": "O identificador único do utilizador"
},
"source": {
"label": "Fonte",
"placeholder": "A fonte do utilizador"
},
"password": {
"label": "Palavra-passe",
"placeholder": "Uma palavra-passe super secreta",
"description": "Deixe este campo em branco para manter a palavra-passe atual."
},
"email": {
"label": "Email",
"placeholder": "O endereço de e-mail"
},
"phone": {
"label": "Telefone",
"placeholder": "O número de telefone"
},
"department": {
"label": "Departamento",
"placeholder": "O departamento"
},
"firstname": {
"label": "Primeiro Nome",
"placeholder": "Primeiro Nome"
},
"lastname": {
"label": "Último Nome",
"placeholder": "Último Nome"
},
"notes": {
"label": "Notas",
"placeholder": ""
},
"disabled": {
"label": "Desativado (sem conexão WireGuard e login possível)"
},
"locked": {
"label": "Bloqueado (sem login possível, as conexões WireGuard ainda funcionam)"
},
"admin": {
"label": "É Administrador"
}
},
"interface-view": {
"headline": "Configuração para a Interface:"
},
"interface-edit": {
"headline-edit": "Editar Interface:",
"headline-new": "Nova Interface",
"tab-interface": "Interface",
"tab-peerdef": "Padrões de Peer",
"header-general": "Geral",
"header-network": "Rede",
"header-crypto": "Criptografia",
"header-hooks": "Hooks da Interface",
"header-peer-hooks": "Hooks",
"header-state": "Estado",
"identifier": {
"label": "Identificador",
"placeholder": "O identificador único da interface"
},
"mode": {
"label": "Modo da Interface",
"server": "Modo Servidor",
"client": "Modo Cliente",
"any": "Modo Desconhecido"
},
"display-name": {
"label": "Nome de Exibição",
"placeholder": "O nome descritivo para a interface"
},
"private-key": {
"label": "Chave Privada",
"placeholder": "A chave privada"
},
"public-key": {
"label": "Chave Pública",
"placeholder": "A chave pública"
},
"ip": {
"label": "Endereços IP",
"placeholder": "Endereços IP (formato CIDR)"
},
"listen-port": {
"label": "Porta de Escuta",
"placeholder": "A porta de escuta"
},
"dns": {
"label": "Servidor DNS",
"placeholder": "Os servidores DNS que devem ser usados"
},
"dns-search": {
"label": "Domínios de Pesquisa DNS",
"placeholder": "Prefixos de pesquisa DNS"
},
"mtu": {
"label": "MTU",
"placeholder": "O MTU da interface (0 = manter o valor padrão)"
},
"firewall-mark": {
"label": "Marca de Firewall",
"placeholder": "Marca de firewall aplicada ao tráfego de saída. (0 = automático)"
},
"routing-table": {
"label": "Tabela de Roteamento",
"placeholder": "O ID da tabela de roteamento",
"description": "Casos especiais: off = não gerenciar rotas, 0 = automático"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"disabled": {
"label": "Interface Desativada"
},
"save-config": {
"label": "Guardar configuração wg-quick automaticamente"
},
"defaults": {
"endpoint": {
"label": "Endereço do Endpoint",
"placeholder": "Endereço do Endpoint",
"description": "O endereço do endpoint ao qual os peers se irão conectar. (ex. wg.exemplo.com ou wg.exemplo.com:51820)"
},
"networks": {
"label": "Redes IP",
"placeholder": "Endereços de Rede",
"description": "Os peers irão obter endereços IP a partir dessas sub-redes."
},
"allowed-ip": {
"label": "Endereços IP Permitidos",
"placeholder": "Endereços IP Permitidos por padrão"
},
"mtu": {
"label": "MTU",
"placeholder": "O MTU do cliente (0 = manter o valor padrão)"
},
"keep-alive": {
"label": "Intervalo de Keep Alive",
"placeholder": "Keepalive persistente (0 = padrão)"
}
},
"button-apply-defaults": "Aplicar Padrões de Peer"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Informação do Peer",
"section-status": "Estado Atual",
"section-config": "Configuração",
"identifier": "Identificador",
"ip": "Endereços IP",
"user": "Utilizador Associado",
"notes": "Notas",
"expiry-status": "Expira em",
"disabled-status": "Desativado em",
"traffic": "Tráfego",
"connection-status": "Estatísticas de Conexão",
"upload": "Bytes Enviados (do Servidor para o Peer)",
"download": "Bytes Recebidos (do Peer para o Servidor)",
"pingable": "É Pingável",
"handshake": "Último Handshake",
"connected-since": "Conectado desde",
"endpoint": "Endpoint",
"button-download": "Baixar configuração",
"button-email": "Enviar configuração por E-Mail"
},
"peer-edit": {
"headline-edit-peer": "Editar peer:",
"headline-edit-endpoint": "Editar endpoint:",
"headline-new-peer": "Criar peer",
"headline-new-endpoint": "Criar endpoint",
"header-general": "Geral",
"header-network": "Rede",
"header-crypto": "Criptografia",
"header-hooks": "Hooks (Executados no Peer)",
"header-state": "Estado",
"display-name": {
"label": "Nome de Exibição",
"placeholder": "O nome descritivo para o peer"
},
"linked-user": {
"label": "Utilizador Associado",
"placeholder": "A conta de utilizador que possui este peer"
},
"private-key": {
"label": "Chave Privada",
"placeholder": "A chave privada",
"help": "A chave privada é armazenada de forma segura no servidor. Se o utilizador já tiver uma cópia, pode omitir este campo. O servidor ainda funciona exclusivamente com a chave pública do peer."
},
"public-key": {
"label": "Chave Pública",
"placeholder": "A chave pública"
},
"preshared-key": {
"label": "Chave Pré-Partilhada",
"placeholder": "Chave pré-partilhada opcional"
},
"endpoint-public-key": {
"label": "Chave Pública do Endpoint",
"placeholder": "A chave pública do endpoint remoto"
},
"endpoint": {
"label": "Endereço do Endpoint",
"placeholder": "O endereço do endpoint remoto"
},
"ip": {
"label": "Endereços IP",
"placeholder": "Endereços IP (formato CIDR)"
},
"allowed-ip": {
"label": "Endereços IP Permitidos",
"placeholder": "Endereços IP permitidos"
},
"extra-allowed-ip": {
"label": "Endereços IP adicionais permitidos",
"placeholder": "IPs adicionais permitidos (lado do servidor)",
"description": "Esses IPs serão adicionados à interface WireGuard remota como IPs permitidos."
},
"dns": {
"label": "Servidor DNS",
"placeholder": "Os servidores DNS que devem ser utilizados"
},
"dns-search": {
"label": "Domínios de Pesquisa DNS",
"placeholder": "Prefixos de pesquisa DNS"
},
"keep-alive": {
"label": "Intervalo de Keep Alive",
"placeholder": "Keepalive persistente (0 = padrão)"
},
"mtu": {
"label": "MTU",
"placeholder": "O MTU do cliente (0 = manter o padrão)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Um ou vários comandos bash separados por ;"
},
"disabled": {
"label": "Peer Desativado"
},
"ignore-global": {
"label": "Ignorar definições globais"
},
"expires-at": {
"label": "Data de expiração"
}
},
"peer-multi-create": {
"headline-peer": "Criar múltiplos peers",
"headline-endpoint": "Criar múltiplos endpoints",
"identifiers": {
"label": "Identificadores de utilizador",
"placeholder": "Identificadores de utilizador",
"description": "Um identificador de utilizador (nome de utilizador) para o qual um peer deve ser criado."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Prefixo do nome exibido",
"placeholder": "O prefixo",
"description": "Um prefixo que será adicionado ao nome exibido do peer."
}
}
}
}

View File

@@ -0,0 +1,515 @@
{
"languages": {
"uk": "Українська"
},
"general": {
"pagination": {
"size": "Кількість елементів",
"all": "Всі (повільно)"
},
"search": {
"placeholder": "Пошук...",
"button": "Пошук"
},
"select-all": "Вибрати все",
"yes": "Так",
"no": "Ні",
"cancel": "Скасувати",
"close": "Закрити",
"save": "Зберегти",
"delete": "Видалити"
},
"login": {
"headline": "Будь ласка, увійдіть",
"username": {
"label": "Ім'я користувача",
"placeholder": "Введіть ім'я користувача"
},
"password": {
"label": "Пароль",
"placeholder": "Введіть пароль"
},
"button": "Увійти"
},
"menu": {
"home": "Головна",
"interfaces": "Інтерфейси",
"users": "Користувачі",
"lang": "Змінити мову",
"profile": "Мій профіль",
"settings": "Налаштування",
"login": "Вхід",
"logout": "Вийти"
},
"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",
"headline": "Портал WireGuard",
"content": "Портал WireGuard — це простий веб-інтерфейс для налаштування WireGuard.",
"button": "Докладніше"
},
"profiles": {
"headline": "VPN профілі",
"abstract": "Ви можете отримати доступ та завантажити свої особисті VPN-конфігурації через свій профіль користувача.",
"content": "Щоб переглянути всі налаштовані профілі, натисніть кнопку нижче.",
"button": "Відкрити мій профіль"
},
"admin": {
"headline": "Адміністративна панель",
"abstract": "У адміністративній панелі ви можете керувати клієнтами WireGuard, серверним інтерфейсом і користувачами, які мають доступ до порталу WireGuard.",
"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": "Статус інтерфейсу для",
"mode": "режим",
"key": "Публічний ключ",
"endpoint": "Публічна кінцева точка",
"port": "Порт прослуховування",
"peers": "Увімкнені піри",
"total-peers": "Загальна кількість пірів",
"endpoints": "Увімкнені кінцеві точки",
"total-endpoints": "Загальна кількість кінцевих точок",
"ip": "IP-адреса",
"default-allowed-ip": "Типові дозволені IP-адреси",
"dns": "DNS-сервери",
"mtu": "MTU",
"default-keep-alive": "Типовий інтервал Keepalive",
"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": "Редагувати пір",
"peer-disabled": "Пір вимкнено, причина:",
"peer-expiring": "Пір припиняє дію о",
"peer-connected": "Підключено",
"peer-not-connected": "Не підключено",
"peer-handshake": "Останнє рукостискання:"
},
"users": {
"headline": "Адміністрування користувачів",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Ім'я",
"lastname": "Прізвище",
"source": "Джерело",
"peers": "Піри",
"admin": "Адміністратор"
},
"no-user": {
"headline": "Немає доступних користувачів",
"abstract": "Наразі немає зареєстрованих користувачів у WireGuard Portal."
},
"button-add-user": "Додати користувача",
"button-show-user": "Показати користувача",
"button-edit-user": "Редагувати користувача",
"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 Auth. Використовуйте такі облікові дані для автентифікації.",
"inactive-description": "API наразі неактивний. Натисніть кнопку нижче, щоб активувати його.",
"user-label": "Ім'я користувача API:",
"user-placeholder": "Користувач API",
"token-label": "Пароль API:",
"token-placeholder": "Токен API",
"token-created-label": "Доступ до API надано:",
"button-disable-title": "Вимкнути API, це зробить поточний токен недійсним.",
"button-disable-text": "Вимкнути API",
"button-enable-title": "Увімкнути API, це згенерує новий токен.",
"button-enable-text": "Увімкнути API",
"api-link": "Документація API"
}
},
"modals": {
"user-view": {
"headline": "Обліковий запис користувача:",
"tab-user": "Інформація",
"tab-peers": "Піри",
"headline-info": "Інформація про користувача:",
"headline-notes": "Примітки:",
"email": "E-Mail",
"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": "Залиште це поле порожнім, щоб зберегти поточний пароль."
},
"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": "Адміністратор"
}
},
"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": "Невідомий режим"
},
"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": "Маркування Firewall",
"placeholder": "Маркування firewall, що застосовується до вихідного трафіку. (0 = автоматично)"
},
"routing-table": {
"label": "Маршрутна таблиця",
"placeholder": "ID маршрутної таблиці",
"description": "Особливі випадки: off = не керувати маршрутами, 0 = автоматично"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"disabled": {
"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": "Дозволені IP-адреси",
"placeholder": "За замовчуванням дозволені IP-адреси"
},
"mtu": {
"label": "MTU",
"placeholder": "MTU клієнта (0 = залишити за замовчуванням)"
},
"keep-alive": {
"label": "Інтервал Keep Alive",
"placeholder": "Постійний Keepalive (0 = за замовчуванням)"
}
},
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів"
},
"peer-view": {
"headline-peer": "Пір:",
"headline-endpoint": "Кінцева точка:",
"section-info": "Інформація про пір",
"section-status": "Поточний стан",
"section-config": "Налаштування",
"identifier": "Ідентифікатор",
"ip": "IP-адреси",
"user": "Пов'язаний користувач",
"notes": "Примітки",
"expiry-status": "Закінчується",
"disabled-status": "Відключено",
"traffic": "Трафік",
"connection-status": "Статистика з'єднання",
"upload": "Передано байтів (з серверу до пір)",
"download": "Завантажено байтів (з пір до серверу)",
"pingable": "Відповідає на ping",
"handshake": "Останній handshake",
"connected-since": "Підключено з",
"endpoint": "Кінцева точка",
"button-download": "Завантажити конфігурацію",
"button-email": "Надіслати конфігурацію електронною поштою"
},
"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": "Приватний ключ"
},
"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": "Дозволені IP-адреси",
"placeholder": "Дозволені IP-адреси (у CIDR форматі)"
},
"extra-allowed-ip": {
"label": "Додаткові дозволені IP-адреси",
"placeholder": "Додаткові дозволені IP (на стороні сервера)",
"description": "Ці IP будуть додані на віддаленому інтерфейсі WireGuard як дозволені IP."
},
"dns": {
"label": "DNS сервер",
"placeholder": "DNS сервери, які слід використовувати"
},
"dns-search": {
"label": "DNS пошукові домени",
"placeholder": "DNS пошукові префікси"
},
"keep-alive": {
"label": "Інтервал збереження зв'язку",
"placeholder": "Постійний Keepalive (0 = за замовчуванням)"
},
"mtu": {
"label": "MTU",
"placeholder": "MTU клієнта (0 = залишити за замовчуванням)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Одна або декілька команд bash, розділених ;"
},
"disabled": {
"label": "Пір відключено"
},
"ignore-global": {
"label": "Ігнорувати глобальні налаштування"
},
"expires-at": {
"label": "Дата закінчення терміну дії"
}
},
"peer-multi-create": {
"headline-peer": "Створити декілька пір",
"headline-endpoint": "Створити декілька кінцевих точок",
"identifiers": {
"label": "Ідентифікатори користувача",
"placeholder": "Ідентифікатори користувача",
"description": "Ідентифікатор користувача (ім'я користувача), для якого слід створити пір."
},
"prefix": {
"headline-peer": "Пір:",
"headline-endpoint": "Кінцева точка:",
"label": "Префікс відображуваного імені",
"placeholder": "Префікс",
"description": "Префікс, що додається до відображуваного імені пірів."
}
}
}
}

View File

@@ -9,13 +9,14 @@ import i18n from "./lang";
import Notifications from '@kyvg/vue3-notification'
// Bootstrap (and theme)
//import "bootstrap/dist/css/bootstrap.min.css"
import "bootswatch/dist/lux/bootstrap.min.css";
import "@/assets/custom.scss";
import "bootstrap";
import "./assets/base.css";
// Fontawesome
// Fonts
import "@fortawesome/fontawesome-free/js/all.js"
import "@fontsource/nunito-sans/400.css";
import "@fontsource/nunito-sans/600.css";
// Flags
import "flag-icons/css/flag-icons.min.css"

View File

@@ -4,6 +4,7 @@ import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification";
const router = createRouter({
@@ -55,6 +56,22 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/SettingsView.vue')
},
{
path: '/audit',
name: 'audit',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AuditView.vue')
},
{
path: '/key-generator',
name: 'key-generator',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/KeyGeneraterView.vue')
}
],
linkActiveClass: "active",
@@ -105,13 +122,22 @@ router.beforeEach(async (to) => {
}
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login']
const publicPages = ['/', '/login', '/key-generator']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) {
auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
auth.SetReturnUrl(to.fullPath) // store the original destination before starting the auth process
return '/login'
}
})
router.afterEach(async (to, from) => {
const sec = securityStore()
const csrfPages = ['/', '/login']
if (csrfPages.includes(to.path)) {
await sec.LoadSecurityProperties() // make sure we have a valid csrf token
}
})
export default router

View File

@@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/audit`
export const auditStore = defineStore('audit', {
state: () => ({
entries: [],
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Count: (state) => state.entries.length,
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.entries,
Filtered: (state) => {
if (!state.filter) {
return state.entries
}
return state.entries.filter((e) => {
return e.Timestamp.includes(state.filter) ||
e.Message.includes(state.filter) ||
e.Severity.includes(state.filter) ||
e.Origin.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
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) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setEntries(entries) {
this.entries = entries
this.calculatePages()
this.fetching = false
},
async LoadEntries() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/entries`)
.then(this.setEntries)
.catch(error => {
this.setEntries([])
console.log("Failed to load audit entries: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load audit entries!",
})
})
},
}
})

View File

@@ -4,8 +4,7 @@ import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
import router from '../router'
export const authStore = defineStore({
id: 'auth',
export const authStore = defineStore('auth',{
state: () => ({
// initialize state from local storage to enable user to stay logged in
user: JSON.parse(localStorage.getItem('user')),
@@ -122,4 +121,4 @@ export const authStore = defineStore({
}
},
}
});
});

View File

@@ -7,8 +7,7 @@ import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/interface`
export const interfaceStore = defineStore({
id: 'interfaces',
export const interfaceStore = defineStore('interfaces', {
state: () => ({
interfaces: [],
prepared: freshInterface(),

View File

@@ -8,8 +8,7 @@ import { ipToBigInt } from '@/helpers/utils';
const baseUrl = `/peer`
export const peerStore = defineStore({
id: 'peers',
export const peerStore = defineStore('peers', {
state: () => ({
peers: [],
stats: {},

View File

@@ -8,10 +8,11 @@ import { ipToBigInt } from '@/helpers/utils';
const baseUrl = `/user`
export const profileStore = defineStore({
id: 'profile',
export const profileStore = defineStore('profile', {
state: () => ({
peers: [],
interfaces: [],
selectedInterfaceId: "",
stats: {},
statsEnabled: false,
user: {},
@@ -71,6 +72,7 @@ export const profileStore = defineStore({
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
CountInterfaces: (state) => state.interfaces.length,
},
actions: {
afterPageSizeChange() {
@@ -116,13 +118,18 @@ export const profileStore = defineStore({
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
setInterfaces(interfaces) {
this.interfaces = interfaces
this.selectedInterfaceId = interfaces.length > 0 ? interfaces[0].Identifier : ""
this.fetching = false
},
async enableApi() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`)
.then(this.setUser)
.catch(error => {
this.setPeers([])
this.fetching = false
console.log("Failed to activate API for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
@@ -136,7 +143,7 @@ export const profileStore = defineStore({
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`)
.then(this.setUser)
.catch(error => {
this.setPeers([])
this.fetching = false
console.log("Failed to deactivate API for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
@@ -186,5 +193,19 @@ export const profileStore = defineStore({
})
})
},
async LoadInterfaces() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}/interfaces`)
.then(this.setInterfaces)
.catch(error => {
this.setInterfaces([])
console.log("Failed to load interfaces for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load interfaces!",
})
})
},
}
})

View File

@@ -3,8 +3,7 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
export const securityStore = defineStore({
id: 'security',
export const securityStore = defineStore('security',{
state: () => ({
csrfToken: "",
}),
@@ -29,4 +28,4 @@ export const securityStore = defineStore({
})
}
}
});
});

View File

@@ -5,8 +5,7 @@ import { apiWrapper } from '@/helpers/fetch-wrapper'
const baseUrl = `/config`
export const settingsStore = defineStore({
id: 'settings',
export const settingsStore = defineStore('settings', {
state: () => ({
settings: {},
}),
@@ -33,4 +32,4 @@ export const settingsStore = defineStore({
})
}
}
});
});

View File

@@ -5,8 +5,7 @@ import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/user`
export const userStore = defineStore({
id: 'users',
export const userStore = defineStore('users', {
state: () => ({
userPeers: [],
users: [],

View File

@@ -0,0 +1,96 @@
<script setup>
import { onMounted } from "vue";
import {auditStore} from "@/stores/audit";
const audit = auditStore()
onMounted(async () => {
await audit.LoadEntries()
})
</script>
<template>
<div class="page-header">
<h1>{{ $t('audit.headline') }}</h1>
</div>
<p class="lead">{{ $t('audit.abstract') }}</p>
<!-- Entry list -->
<div class="mt-4 row">
<div class="col-12 col-lg-6">
<h3>{{ $t('audit.entries-headline') }}</h3>
</div>
<div class="col-12 col-lg-6 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="audit.Count===0">
<h4>{{ $t('audit.no-entries.headline') }}</h4>
<p>{{ $t('audit.no-entries.abstract') }}</p>
</div>
<table v-if="audit.Count!==0" id="auditTable" class="table table-sm">
<thead>
<tr>
<th scope="col">{{ $t('audit.table-heading.id') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.time') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.severity') }}</th>
<th scope="col">{{ $t('audit.table-heading.user') }}</th>
<th scope="col">{{ $t('audit.table-heading.origin') }}</th>
<th scope="col">{{ $t('audit.table-heading.message') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in audit.FilteredAndPaged" :key="entry.Id">
<td>{{entry.Id}}</td>
<td>{{entry.Timestamp}}</td>
<td class="text-center"><span class="badge rounded-pill" :class="[ entry.Severity === 'low' ? 'bg-light' : entry.Severity === 'medium' ? 'bg-warning' : 'bg-danger']">{{entry.Severity}}</span></td>
<td>{{entry.ContextUser}}</td>
<td>{{entry.Origin}}</td>
<td>{{entry.Message}}</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:audit.pageOffset===0}" class="page-item">
<a class="page-link" @click="audit.previousPage">&laquo;</a>
</li>
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item">
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!audit.hasNextPage}" class="page-item">
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -22,8 +22,9 @@ const multiCreatePeerId = ref("")
const editInterfaceId = ref("")
const viewedInterfaceId = ref("")
const sortKey = ref("");
const sortOrder = ref(1);
const sortKey = ref("")
const sortOrder = ref(1)
const selectAll = ref(false)
function sortBy(key) {
if (sortKey.value === key) {
@@ -48,12 +49,11 @@ async function download() {
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
// credit: https://www.bitdegree.org/learn/javascript-download
let filename = interfaces.GetSelected.Identifier + ".conf"
let text = interfaces.configuration
let element = document.createElement('a')
element.setAttribute('href', 'data:application/octet-stream;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.setAttribute('download', interfaces.GetSelected.Filename)
element.style.display = 'none'
document.body.appendChild(element)
@@ -81,6 +81,12 @@ async function saveConfig() {
}
}
function toggleSelectAll() {
peers.FilteredAndPaged.forEach(peer => {
peer.IsSelected = selectAll.value;
});
}
onMounted(async () => {
await interfaces.LoadInterfaces()
await peers.LoadPeers(undefined) // use default interface
@@ -326,7 +332,7 @@ onMounted(async () => {
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
<input class="form-check-input" :title="$t('general.select-all')" type="checkbox" v-model="selectAll" @change="toggleSelectAll">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col" @click="sortBy('DisplayName')">
@@ -357,7 +363,7 @@ onMounted(async () => {
<tbody>
<tr v-for="peer in peers.FilteredAndPaged" :key="peer.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
<input class="form-check-input" type="checkbox" v-model="peer.IsSelected">
</th>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger" :title="$t('interfaces.peer-disabled') + ' ' + peer.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
@@ -410,7 +416,7 @@ onMounted(async () => {
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>

View File

@@ -0,0 +1,147 @@
<script setup>
import {ref} from "vue";
const privateKey = ref("")
const publicKey = ref("")
const presharedKey = ref("")
/**
* Generate an X25519 keypair using the Web Crypto API and return Base64-encoded strings.
* @async
* @function generateKeypair
* @returns {Promise<{ publicKey: string, privateKey: string }>} Resolves with an object containing
* - publicKey: the Base64-encoded public key
* - privateKey: the Base64-encoded private key
*/
async function generateKeypair() {
// 1. Generate an X25519 key pair
const keyPair = await crypto.subtle.generateKey(
{ name: 'X25519', namedCurve: 'X25519' },
true, // extractable
['deriveBits'] // allowed usage for ECDH
);
// 2. Export keys as JWK to access raw key material
const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
// 3. Convert Base64URL to standard Base64 with padding
return {
publicKey: b64urlToB64(pubJwk.x),
privateKey: b64urlToB64(privJwk.d)
};
}
/**
* Generate a 32-byte pre-shared key using crypto.getRandomValues.
* @function generatePresharedKey
* @returns {Uint8Array} A Uint8Array of length 32 with random bytes.
*/
function generatePresharedKey() {
let privateKey = new Uint8Array(32);
window.crypto.getRandomValues(privateKey);
return privateKey;
}
/**
* Convert a Base64URL-encoded string to standard Base64 with padding.
* @function b64urlToB64
* @param {string} input - The Base64URL string.
* @returns {string} The padded, standard Base64 string.
*/
function b64urlToB64(input) {
let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) {
b64 += '=';
}
return b64;
}
/**
* Convert an ArrayBuffer or TypedArray buffer to a Base64-encoded string.
* @function arrayBufferToBase64
* @param {ArrayBuffer|Uint8Array} buffer - The buffer to convert.
* @returns {string} Base64-encoded representation of the buffer.
*/
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; ++i) {
binary += String.fromCharCode(bytes[i]);
}
// Window.btoa handles binary → Base64
return btoa(binary);
}
/**
* Generate a new keypair and update the corresponding Vue refs.
* @async
* @function generateNewKeyPair
* @returns {Promise<void>}
*/
async function generateNewKeyPair() {
const keypair = await generateKeypair();
privateKey.value = keypair.privateKey;
publicKey.value = keypair.publicKey;
}
/**
* Generate a new pre-shared key and update the Vue ref.
* @function generateNewPresharedKey
*/
function generateNewPresharedKey() {
const rawPsk = generatePresharedKey();
presharedKey.value = arrayBufferToBase64(rawPsk);
}
</script>
<template>
<div class="page-header">
<h1>{{ $t('keygen.headline') }}</h1>
</div>
<p class="lead">{{ $t('keygen.abstract') }}</p>
<div class="mt-4 row">
<div class="col-12 col-lg-5">
<h1>{{ $t('keygen.headline-keypair') }}</h1>
<fieldset>
<div class="form-group">
<label class="form-label mt-4">{{ $t('keygen.private-key.label') }}</label>
<input class="form-control" v-model="privateKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('keygen.public-key.label') }}</label>
<input class="form-control" v-model="publicKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
</div>
</fieldset>
<fieldset>
<hr class="mt-4">
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewKeyPair">{{ $t('keygen.button-generate') }}</button>
</fieldset>
</div>
<div class="col-12 col-lg-2 mt-sm-4">
</div>
<div class="col-12 col-lg-5">
<h1>{{ $t('keygen.headline-preshared-key') }}</h1>
<fieldset>
<div class="form-group">
<label class="form-label mt-4">{{ $t('keygen.preshared-key.label') }}</label>
<input class="form-control" v-model="presharedKey" :placeholder="$t('keygen.preshared-key.placeholder')" readonly>
</div>
</fieldset>
<fieldset>
<hr class="mt-4">
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewPresharedKey">{{ $t('keygen.button-generate') }}</button>
</fieldset>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -3,7 +3,7 @@ import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref } from "vue";
import { profileStore } from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils";
@@ -13,8 +13,9 @@ const profile = profileStore()
const viewedPeerId = ref("")
const editPeerId = ref("")
const sortKey = ref("");
const sortOrder = ref(1);
const sortKey = ref("")
const sortOrder = ref(1)
const selectAll = ref(false)
function sortBy(key) {
if (sortKey.value === key) {
@@ -27,10 +28,24 @@ function sortBy(key) {
profile.sortOrder = sortOrder.value;
}
function friendlyInterfaceName(id, name) {
if (name) {
return name
}
return id
}
function toggleSelectAll() {
profile.FilteredAndPagedPeers.forEach(peer => {
peer.IsSelected = selectAll.value;
});
}
onMounted(async () => {
await profile.LoadUser()
await profile.LoadPeers()
await profile.LoadStats()
await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
})
@@ -38,7 +53,7 @@ onMounted(async () => {
<template>
<PeerViewModal :peerId="viewedPeerId" :visible="viewedPeerId !== ''" @close="viewedPeerId = ''"></PeerViewModal>
<PeerEditModal :peerId="editPeerId" :visible="editPeerId !== ''" @close="editPeerId = ''"></PeerEditModal>
<UserPeerEditModal :peerId="editPeerId" :visible="editPeerId !== ''" @close="editPeerId = ''; profile.LoadPeers()"></UserPeerEditModal>
<!-- Peer list -->
<div class="mt-4 row">
@@ -56,9 +71,17 @@ onMounted(async () => {
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a v-if="settings.Setting('SelfProvisioning')" class="btn btn-primary ms-2" href="#"
:title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'"><i
class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
<div class="input-group mb-3">
<button class="input-group-text 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>
</button>
<select v-model="profile.selectedInterfaceId" :disabled="profile.CountInterfaces===0" class="form-select">
<option v-if="profile.CountInterfaces===0" value="nothing">{{ $t('interfaces.no-interface.default-selection') }}</option>
<option v-for="iface in profile.interfaces" :key="iface.Identifier" :value="iface.Identifier">{{ friendlyInterfaceName(iface.Identifier,iface.DisplayName) }}</option>
</select>
</div>
</div>
</div>
</div>
<div class="mt-2 table-responsive">
@@ -70,8 +93,7 @@ onMounted(async () => {
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox"
value="">
<input class="form-check-input" :title="$t('general.select-all')" type="checkbox" v-model="selectAll" @change="toggleSelectAll">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col" @click="sortBy('DisplayName')">
@@ -96,7 +118,7 @@ onMounted(async () => {
<tbody>
<tr v-for="peer in profile.FilteredAndPagedPeers" :key="peer.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
<input class="form-check-input" type="checkbox" v-model="peer.IsSelected">
</th>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger"><i class="fa fa-circle-xmark"
@@ -156,7 +178,7 @@ onMounted(async () => {
{{ $t('general.pagination.size')}}:
</label>
<div class="col-sm-6">
<select v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>

View File

@@ -1,13 +1,8 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref } from "vue";
import { onMounted } from "vue";
import { profileStore } from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils";
import {RouterLink} from "vue-router";
import {authStore} from "../stores/auth";
import { authStore } from "../stores/auth";
const profile = profileStore()
const settings = settingsStore()

View File

@@ -3,15 +3,20 @@ import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const users = userStore()
const editUserId = ref("")
const viewedUserId = ref("")
const selectAll = ref(false)
function toggleSelectAll() {
users.FilteredAndPaged.forEach(user => {
user.IsSelected = selectAll.value;
});
}
onMounted(() => {
users.LoadUsers()
})
@@ -49,7 +54,7 @@ onMounted(() => {
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
<input class="form-check-input" :title="$t('general.select-all')" type="checkbox" v-model="selectAll" @change="toggleSelectAll">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('users.table-heading.id') }}</th>
@@ -65,7 +70,7 @@ onMounted(() => {
<tbody>
<tr v-for="user in users.FilteredAndPaged" :key="user.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
<input class="form-check-input" type="checkbox" v-model="user.IsSelected">
</th>
<td class="text-center">
<span v-if="user.Disabled" class="text-danger" :title="$t('users.user-disabled') + ' ' + user.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
@@ -111,7 +116,7 @@ onMounted(() => {
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>

101
go.mod
View File

@@ -1,36 +1,34 @@
module github.com/h44z/wg-portal
go 1.23
go 1.24.0
require (
github.com/a8m/envsubst v1.4.2
github.com/coreos/go-oidc/v3 v3.12.0
github.com/gin-contrib/cors v1.7.3
github.com/gin-contrib/sessions v1.0.2
github.com/gin-gonic/gin v1.10.0
github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.8.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-pkgz/routegroup v1.4.1
github.com/go-playground/validator/v10 v10.26.0
github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.5.0
github.com/prometheus/client_golang v1.20.5
github.com/sirupsen/logrus v1.9.3
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.22.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/swag v1.16.4
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
github.com/vardius/message-bus v1.1.5
github.com/vishvananda/netlink v1.3.0
github.com/vishvananda/netlink v1.3.1
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.4
golang.org/x/crypto v0.32.0
golang.org/x/oauth2 v0.25.0
golang.org/x/sys v0.29.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.38.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlserver v1.5.4
gorm.io/gorm v1.25.12
gorm.io/gorm v1.26.1
)
require (
@@ -38,46 +36,32 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.12.7 // indirect
github.com/bytedance/sonic/loader v0.2.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/uniuri v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.24.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -85,35 +69,26 @@ require (
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.8.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.32.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
google.golang.org/protobuf v1.36.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.61.7 // indirect
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.63.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.1 // indirect
modernc.org/sqlite v1.34.4 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

311
go.sum
View File

@@ -25,92 +25,60 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/a8m/envsubst v1.4.2 h1:4yWIHXOLEJHQEFd4UjrWDrYeYlV7ncFWJOCBRLOZHQg=
github.com/a8m/envsubst v1.4.2/go.mod h1:MVUTQNGQ3tsjOOtKCNd+fl8RzhsXcDvvAEzkhGtlsbY=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o=
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
github.com/gin-contrib/sessions v1.0.2 h1:UaIjUvTH1cMeOdj3in6dl+Xb6It8RiKRF9Z1anbUyCA=
github.com/gin-contrib/sessions v1.0.2/go.mod h1:KxKxWqWP5LJVDCInulOl4WbLzK2KSPlLesfZ66wRvMs=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-pkgz/routegroup v1.4.1 h1:iw1yW3lXuurZZOv/DF9fY8Mkpvy6J9UjBiP1oDIQE/s=
github.com/go-pkgz/routegroup v1.4.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
@@ -119,31 +87,17 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
@@ -151,8 +105,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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -175,21 +129,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -198,7 +141,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
@@ -207,56 +149,39 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
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/prometheus-community/pro-bing v0.5.0 h1:Fq+4BUXKIvsPtXUY8K+04ud9dkAuFozqGmRAyNUpffY=
github.com/prometheus-community/pro-bing v0.5.0/go.mod h1:1joR9oXdMEAcAJJvhs+8vNDvTg5thfAZcRFhcUozG2g=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b h1:aUNXCGgukb4gtY99imuIeoh8Vr0GSwAlYxPAhqZrpFc=
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -268,62 +193,41 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f h1:oqdnd6OGlOUu1InG37hWcCB3a+Jy3fwjylyVboaNMwY=
github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f/go.mod h1:X3Dd1SB8Gt1V968NTzpKFjMM6O8ccta2NPC6MprOxZQ=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
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/vardius/message-bus v1.1.5 h1:YSAC2WB4HRlwc4neFPTmT88kzzoiQ+9WRRbej/E/LZc=
github.com/vardius/message-bus v1.1.5/go.mod h1:6xladCV2lMkUAE4bzzS85qKOiB5miV7aBVRafiTJGqw=
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/yeqown/go-qrcode/v2 v2.2.4 h1:cXdYlrhzHzVAnJHiwr/T6lAUmS9MtEStjEZBjArrvnc=
github.com/yeqown/go-qrcode/v2 v2.2.4/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 h1:0el6zOppx3oPiYWMUJWRYGvxWYh8MDmUU0j3rSWGWlI=
github.com/yeqown/go-qrcode/writer/compressed v1.0.1/go.mod h1:BJScsGUIKM+eg0CCLCcVaDTaclDM1IEXtq2r8qQnDKk=
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -334,34 +238,23 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -370,16 +263,11 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -387,13 +275,9 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -404,39 +288,27 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -449,40 +321,31 @@ gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.24.2 h1:uektamHbSXU7egelXcyVpMaaAsrRH4/+uMKUQAQUdOw=
modernc.org/cc/v4 v4.24.2/go.mod h1:T1lKJZhXIi2VSqGBiB4LIbKs9NsKTbUXj4IDrmGqtTI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/ccgo/v4 v4.23.5 h1:6uAwu8u3pnla3l/+UVUrDDO1HIGxHTYmFH6w+X9nsyw=
modernc.org/ccgo/v4 v4.23.5/go.mod h1:FogrWfBdzqLWm1ku6cfr4IzEFouq2fSAPf6aSAHdAJQ=
modernc.org/ccgo/v4 v4.23.10 h1:DnDZT/H6TtoJvQmVf7d8W+lVqEZpIJY/+0ENFh1LIHE=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.0 h1:Tiw3pezQj7PfV8k4Dzyu/vhRHR2e92kOXtTFU8pbCl4=
modernc.org/gc/v2 v2.6.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v2 v2.6.1 h1:+Qf6xdG8l7B27TQ8D8lw/iFMUj1RXRBOuMUWziJOsk8=
modernc.org/libc v1.61.6 h1:L2jW0wxHPCyHK0YSHaGaVlY0WxjpG/TTVdg6gRJOPqw=
modernc.org/libc v1.61.6/go.mod h1:G+DzuaCcReUYYg4nNSfigIfTDCENdj9EByglvaRx53A=
modernc.org/libc v1.61.7 h1:exz8rasFniviSgh3dH7QBnQHqYh9lolA5hVYfsiwkfo=
modernc.org/libc v1.61.7/go.mod h1:xspSrXRNVSfWfcfqgvZDVe/Hw5kv4FVC6IRfoms5v/0=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.1 h1:HS1HRg1jEohnuONobEq2WrLEhLyw8+J42yLFTnllm2A=
modernc.org/memory v1.8.1/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View File

@@ -4,23 +4,23 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
"gorm.io/gorm/utils"
"github.com/glebarez/sqlite"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
gormMySQL "gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
)
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
@@ -32,13 +32,15 @@ type SysStat struct {
SchemaVersion uint64 `gorm:"primaryKey,column:schema_version"`
}
// GormLogger is a custom logger for Gorm, making it use logrus.
// GormLogger is a custom logger for Gorm, making it use slog
type GormLogger struct {
SlowThreshold time.Duration
SourceField string
IgnoreErrRecordNotFound bool
Debug bool
Silent bool
prefix string
}
func NewLogger(slowThreshold time.Duration, debug bool) *GormLogger {
@@ -48,6 +50,7 @@ func NewLogger(slowThreshold time.Duration, debug bool) *GormLogger {
IgnoreErrRecordNotFound: true,
Silent: false,
SourceField: "src",
prefix: "GORM-SQL: ",
}
}
@@ -60,25 +63,25 @@ func (l *GormLogger) LogMode(level logger.LogLevel) logger.Interface {
return l
}
func (l *GormLogger) Info(ctx context.Context, s string, args ...interface{}) {
func (l *GormLogger) Info(ctx context.Context, s string, args ...any) {
if l.Silent {
return
}
logrus.WithContext(ctx).Infof(s, args...)
slog.InfoContext(ctx, l.prefix+s, args...)
}
func (l *GormLogger) Warn(ctx context.Context, s string, args ...interface{}) {
func (l *GormLogger) Warn(ctx context.Context, s string, args ...any) {
if l.Silent {
return
}
logrus.WithContext(ctx).Warnf(s, args...)
slog.WarnContext(ctx, l.prefix+s, args...)
}
func (l *GormLogger) Error(ctx context.Context, s string, args ...interface{}) {
func (l *GormLogger) Error(ctx context.Context, s string, args ...any) {
if l.Silent {
return
}
logrus.WithContext(ctx).Errorf(s, args...)
slog.ErrorContext(ctx, l.prefix+s, args...)
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
@@ -88,36 +91,40 @@ func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (stri
elapsed := time.Since(begin)
sql, rows := fc()
fields := logrus.Fields{
"rows": rows,
"duration": elapsed,
attrs := []any{
"rows", rows,
"duration", elapsed,
}
if l.SourceField != "" {
fields[l.SourceField] = utils.FileWithLineNum()
attrs = append(attrs, l.SourceField, utils.FileWithLineNum())
}
if err != nil && !(errors.Is(err, gorm.ErrRecordNotFound) && l.IgnoreErrRecordNotFound) {
fields[logrus.ErrorKey] = err
logrus.WithContext(ctx).WithFields(fields).Errorf("%s", sql)
attrs = append(attrs, "error", err)
slog.ErrorContext(ctx, l.prefix+sql, attrs...)
return
}
if l.SlowThreshold != 0 && elapsed > l.SlowThreshold {
logrus.WithContext(ctx).WithFields(fields).Warnf("%s", sql)
slog.WarnContext(ctx, l.prefix+sql, attrs...)
return
}
if l.Debug {
logrus.WithContext(ctx).WithFields(fields).Tracef("%s", sql)
slog.DebugContext(ctx, l.prefix+sql, attrs...)
}
}
// NewDatabase creates a new database connection and returns a Gorm database instance.
func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
var gormDb *gorm.DB
var err error
switch cfg.Type {
case config.DatabaseMySQL:
gormDb, err = gorm.Open(gormMySQL.Open(cfg.DSN), &gorm.Config{
gormDb, err = gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{
Logger: NewLogger(cfg.SlowQueryThreshold, cfg.Debug),
})
if err != nil {
@@ -172,6 +179,7 @@ type SqlRepo struct {
db *gorm.DB
}
// NewSqlRepository creates a new SqlRepo instance.
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
repo := &SqlRepo{
db: db,
@@ -210,13 +218,13 @@ func (r *SqlRepo) preCheck() error {
}
func (r *SqlRepo) migrate() error {
logrus.Tracef("sysstat migration: %v", r.db.AutoMigrate(&SysStat{}))
logrus.Tracef("user migration: %v", r.db.AutoMigrate(&domain.User{}))
logrus.Tracef("interface migration: %v", r.db.AutoMigrate(&domain.Interface{}))
logrus.Tracef("peer migration: %v", r.db.AutoMigrate(&domain.Peer{}))
logrus.Tracef("peer status migration: %v", r.db.AutoMigrate(&domain.PeerStatus{}))
logrus.Tracef("interface status migration: %v", r.db.AutoMigrate(&domain.InterfaceStatus{}))
logrus.Tracef("audit data migration: %v", r.db.AutoMigrate(&domain.AuditEntry{}))
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{}))
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{}
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat)
@@ -228,7 +236,7 @@ func (r *SqlRepo) migrate() error {
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err)
}
logrus.Debugf("sysstat entry for schema version %d written", SchemaVersion)
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
}
return nil
@@ -236,6 +244,8 @@ func (r *SqlRepo) migrate() error {
// region interfaces
// GetInterface returns the interface with the given id.
// If no interface is found, an error domain.ErrNotFound is returned.
func (r *SqlRepo) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
var in domain.Interface
@@ -251,6 +261,8 @@ func (r *SqlRepo) GetInterface(ctx context.Context, id domain.InterfaceIdentifie
return &in, nil
}
// GetInterfaceAndPeers returns the interface with the given id and all peers associated with it.
// If no interface is found, an error domain.ErrNotFound is returned.
func (r *SqlRepo) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface,
[]domain.Peer,
@@ -269,6 +281,7 @@ func (r *SqlRepo) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceI
return in, peers, nil
}
// GetPeersStats returns the stats for the given peer ids. The order of the returned stats is not guaranteed.
func (r *SqlRepo) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
if len(ids) == 0 {
return nil, nil
@@ -284,6 +297,7 @@ func (r *SqlRepo) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifie
return stats, nil
}
// GetAllInterfaces returns all interfaces.
func (r *SqlRepo) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
var interfaces []domain.Interface
@@ -295,6 +309,8 @@ func (r *SqlRepo) GetAllInterfaces(ctx context.Context) ([]domain.Interface, err
return interfaces, nil
}
// GetInterfaceStats returns the stats for the given interface id.
// If no stats are found, an error domain.ErrNotFound is returned.
func (r *SqlRepo) GetInterfaceStats(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.InterfaceStatus,
error,
@@ -319,6 +335,8 @@ func (r *SqlRepo) GetInterfaceStats(ctx context.Context, id domain.InterfaceIden
return &stat, nil
}
// FindInterfaces returns all interfaces that match the given search string.
// The search string is matched against the interface identifier and display name.
func (r *SqlRepo) FindInterfaces(ctx context.Context, search string) ([]domain.Interface, error) {
var users []domain.Interface
@@ -335,6 +353,7 @@ func (r *SqlRepo) FindInterfaces(ctx context.Context, search string) ([]domain.I
return users, nil
}
// SaveInterface updates the interface with the given id.
func (r *SqlRepo) SaveInterface(
ctx context.Context,
id domain.InterfaceIdentifier,
@@ -410,6 +429,7 @@ func (r *SqlRepo) upsertInterface(ui *domain.ContextUserInfo, tx *gorm.DB, in *d
return nil
}
// DeleteInterface deletes the interface with the given id.
func (r *SqlRepo) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("interface_identifier = ?", id).Delete(&domain.Peer{}).Error
@@ -436,6 +456,7 @@ func (r *SqlRepo) DeleteInterface(ctx context.Context, id domain.InterfaceIdenti
return nil
}
// GetInterfaceIps returns a map of interface identifiers to their respective IP addresses.
func (r *SqlRepo) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
var ips []struct {
domain.Cidr
@@ -461,6 +482,8 @@ func (r *SqlRepo) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIden
// region peers
// GetPeer returns the peer with the given id.
// If no peer is found, an error domain.ErrNotFound is returned.
func (r *SqlRepo) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
var peer domain.Peer
@@ -476,6 +499,7 @@ func (r *SqlRepo) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domai
return &peer, nil
}
// GetInterfacePeers returns all peers associated with the given interface id.
func (r *SqlRepo) GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) {
var peers []domain.Peer
@@ -487,6 +511,8 @@ func (r *SqlRepo) GetInterfacePeers(ctx context.Context, id domain.InterfaceIden
return peers, nil
}
// FindInterfacePeers returns all peers associated with the given interface id that match the given search string.
// The search string is matched against the peer identifier, display name and IP address.
func (r *SqlRepo) FindInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier, search string) (
[]domain.Peer,
error,
@@ -506,6 +532,7 @@ func (r *SqlRepo) FindInterfacePeers(ctx context.Context, id domain.InterfaceIde
return peers, nil
}
// GetUserPeers returns all peers associated with the given user id.
func (r *SqlRepo) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
var peers []domain.Peer
@@ -517,6 +544,8 @@ func (r *SqlRepo) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([
return peers, nil
}
// FindUserPeers returns all peers associated with the given user id that match the given search string.
// The search string is matched against the peer identifier, display name and IP address.
func (r *SqlRepo) FindUserPeers(ctx context.Context, id domain.UserIdentifier, search string) ([]domain.Peer, error) {
var peers []domain.Peer
@@ -533,6 +562,8 @@ func (r *SqlRepo) FindUserPeers(ctx context.Context, id domain.UserIdentifier, s
return peers, nil
}
// SavePeer updates the peer with the given id.
// If no existing peer is found, a new peer is created.
func (r *SqlRepo) SavePeer(
ctx context.Context,
id domain.PeerIdentifier,
@@ -607,6 +638,7 @@ func (r *SqlRepo) upsertPeer(ui *domain.ContextUserInfo, tx *gorm.DB, peer *doma
return nil
}
// DeletePeer deletes the peer with the given id.
func (r *SqlRepo) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Delete(&domain.PeerStatus{PeerId: id}).Error
@@ -628,6 +660,7 @@ func (r *SqlRepo) DeletePeer(ctx context.Context, id domain.PeerIdentifier) erro
return nil
}
// GetPeerIps returns a map of peer identifiers to their respective IP addresses.
func (r *SqlRepo) GetPeerIps(ctx context.Context) (map[domain.PeerIdentifier][]domain.Cidr, error) {
var ips []struct {
domain.Cidr
@@ -649,6 +682,7 @@ func (r *SqlRepo) GetPeerIps(ctx context.Context) (map[domain.PeerIdentifier][]d
return result, nil
}
// GetUsedIpsPerSubnet returns a map of subnets to their respective used IP addresses.
func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (
map[domain.Cidr][]domain.Cidr,
error,
@@ -707,6 +741,8 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
// region users
// GetUser returns the user with the given id.
// If no user is found, an error domain.ErrNotFound is returned.
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
@@ -722,6 +758,9 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
return &user, nil
}
// GetUserByEmail returns the user with the given email.
// If no user is found, an error domain.ErrNotFound is returned.
// If multiple users are found, an error domain.ErrNotUnique is returned.
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var users []domain.User
@@ -746,6 +785,7 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
return &user, nil
}
// GetAllUsers returns all users.
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User
@@ -757,6 +797,8 @@ func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
return users, nil
}
// FindUsers returns all users that match the given search string.
// The search string is matched against the user identifier, firstname, lastname and email.
func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User, error) {
var users []domain.User
@@ -774,6 +816,8 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
return users, nil
}
// SaveUser updates the user with the given id.
// If no user is found, a new user is created.
func (r *SqlRepo) SaveUser(
ctx context.Context,
id domain.UserIdentifier,
@@ -807,6 +851,7 @@ func (r *SqlRepo) SaveUser(
return nil
}
// DeleteUser deletes the user with the given id.
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
if err != nil {
@@ -859,6 +904,8 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
// region statistics
// UpdateInterfaceStatus updates the interface status with the given id.
// If no interface status is found, a new one is created.
func (r *SqlRepo) UpdateInterfaceStatus(
ctx context.Context,
id domain.InterfaceIdentifier,
@@ -919,6 +966,8 @@ func (r *SqlRepo) upsertInterfaceStatus(tx *gorm.DB, in *domain.InterfaceStatus)
return nil
}
// UpdatePeerStatus updates the peer status with the given id.
// If no peer status is found, a new one is created.
func (r *SqlRepo) UpdatePeerStatus(
ctx context.Context,
id domain.PeerIdentifier,
@@ -976,6 +1025,7 @@ func (r *SqlRepo) upsertPeerStatus(tx *gorm.DB, in *domain.PeerStatus) error {
return nil
}
// DeletePeerStatus deletes the peer status with the given id.
func (r *SqlRepo) DeletePeerStatus(ctx context.Context, id domain.PeerIdentifier) error {
err := r.db.WithContext(ctx).Delete(&domain.PeerStatus{}, id).Error
if err != nil {
@@ -989,6 +1039,7 @@ func (r *SqlRepo) DeletePeerStatus(ctx context.Context, id domain.PeerIdentifier
// region audit
// SaveAuditEntry saves the given audit entry.
func (r *SqlRepo) SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry) error {
err := r.db.WithContext(ctx).Save(entry).Error
if err != nil {
@@ -998,4 +1049,16 @@ func (r *SqlRepo) SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry)
return nil
}
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
func (r *SqlRepo) GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error) {
var entries []domain.AuditEntry
err := r.db.WithContext(ctx).Order("created_at desc").Find(&entries).Error
if err != nil {
return nil, err
}
return entries, nil
}
// endregion audit

View File

@@ -5,17 +5,14 @@ package adapters
import (
"database/sql"
"fmt"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"testing"
)
func tempSqliteDb(t *testing.T) *gorm.DB {
// github.com/mattn/go-sqlite3
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)

View File

@@ -2,8 +2,8 @@ package adapters
import (
"fmt"
"github.com/sirupsen/logrus"
"io"
"log/slog"
"os"
"path/filepath"
)
@@ -12,6 +12,7 @@ type FilesystemRepo struct {
basePath string
}
// NewFileSystemRepository creates a new FilesystemRepo instance.
func NewFileSystemRepository(basePath string) (*FilesystemRepo, error) {
if basePath == "" {
return nil, nil // no path, return empty repository
@@ -26,6 +27,10 @@ func NewFileSystemRepository(basePath string) (*FilesystemRepo, error) {
return r, nil
}
// WriteFile writes the given contents to the given path.
// The path is relative to the base path of the repository.
// If the parent directory does not exist, it is created.
// If the file already exists, it is overwritten.
func (r *FilesystemRepo) WriteFile(path string, contents io.Reader) error {
filePath := filepath.Join(r.basePath, path)
parentDirectory := filepath.Dir(filePath)
@@ -36,11 +41,11 @@ func (r *FilesystemRepo) WriteFile(path string, contents io.Reader) error {
file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", file.Name(), err)
return fmt.Errorf("failed to open file %s: %w", filePath, err)
}
defer func(file *os.File) {
if err := file.Close(); err != nil {
logrus.Errorf("failed to close file %s: %v", file.Name(), err)
slog.Error("failed to close file", "file", file.Name(), "error", err)
}
}(file)
@@ -50,5 +55,17 @@ func (r *FilesystemRepo) WriteFile(path string, contents io.Reader) error {
}
return nil
}
// DeleteFile deletes the file at the given path.
// The path is relative to the base path of the repository.
// If the file does not exist, it is ignored.
func (r *FilesystemRepo) DeleteFile(path string) error {
filePath := filepath.Join(r.basePath, path)
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete file %s: %w", filePath, err)
}
return nil
}

View File

@@ -5,23 +5,26 @@ import (
"crypto/tls"
"errors"
"fmt"
"io"
"time"
mail "github.com/xhit/go-simple-mail/v2"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
mail "github.com/xhit/go-simple-mail/v2"
"io"
"time"
)
type MailRepo struct {
cfg *config.MailConfig
}
// NewSmtpMailRepo creates a new MailRepo instance.
func NewSmtpMailRepo(cfg config.MailConfig) MailRepo {
return MailRepo{cfg: &cfg}
}
// Send sends a mail.
// Send sends a mail using SMTP.
func (r MailRepo) Send(_ context.Context, subject, body string, to []string, options *domain.MailOptions) error {
if options == nil {
options = &domain.MailOptions{}

View File

@@ -2,16 +2,18 @@ package adapters
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/sirupsen/logrus"
)
type MetricsServer struct {
@@ -84,16 +86,16 @@ func NewMetricsServer(cfg *config.Config) *MetricsServer {
}
}
// Run starts the metrics server
// Run starts the metrics server. The function blocks until the context is cancelled.
func (m *MetricsServer) Run(ctx context.Context) {
// Run the metrics server in a goroutine
go func() {
if err := m.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logrus.Errorf("metrics service on %s exited: %v", m.Addr, err)
if err := m.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("metrics service exited", "address", m.Addr, "error", err)
}
}()
logrus.Infof("started metrics service on %s", m.Addr)
slog.Info("started metrics service", "address", m.Addr)
// Wait for the context to be done
<-ctx.Done()
@@ -102,11 +104,11 @@ func (m *MetricsServer) Run(ctx context.Context) {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Attempt to gracefully shutdown the metrics server
// Attempt to gracefully shut down the metrics server
if err := m.Shutdown(shutdownCtx); err != nil {
logrus.Errorf("metrics service on %s shutdown failed: %v", m.Addr, err)
slog.Error("metrics service shutdown failed", "address", m.Addr, "error", err)
} else {
logrus.Infof("metrics service on %s shutdown gracefully", m.Addr)
slog.Info("metrics service shutdown gracefully", "address", m.Addr)
}
}
@@ -121,9 +123,9 @@ func (m *MetricsServer) UpdateInterfaceMetrics(status domain.InterfaceStatus) {
func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerStatus) {
labels := []string{
string(peer.InterfaceIdentifier),
string(peer.Interface.AddressStr()),
peer.Interface.AddressStr(),
string(status.PeerId),
string(peer.DisplayName),
peer.DisplayName,
}
if status.LastHandshake != nil {

View File

@@ -3,12 +3,12 @@ package adapters
import (
"bytes"
"fmt"
"log/slog"
"os/exec"
"strings"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
"github.com/sirupsen/logrus"
)
// WgQuickRepo implements higher level wg-quick like interactions like setting DNS, routing tables or interface hooks.
@@ -17,6 +17,7 @@ type WgQuickRepo struct {
resolvConfIfacePrefix string
}
// NewWgQuickRepo creates a new WgQuickRepo instance.
func NewWgQuickRepo() *WgQuickRepo {
return &WgQuickRepo{
shellCmd: "bash",
@@ -24,12 +25,16 @@ func NewWgQuickRepo() *WgQuickRepo {
}
}
// ExecuteInterfaceHook executes the given hook command.
// The hook command can contain the following placeholders:
//
// %i: the interface identifier.
func (r *WgQuickRepo) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
if hookCmd == "" {
return nil
}
logrus.Tracef("interface %s: executing hook %s", id, hookCmd)
slog.Debug("executing interface hook", "interface", id, "hook", hookCmd)
err := r.exec(hookCmd, id)
if err != nil {
return fmt.Errorf("failed to exec hook: %w", err)
@@ -38,6 +43,7 @@ func (r *WgQuickRepo) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCm
return nil
}
// SetDNS sets the DNS settings for the given interface. It uses resolvconf to set the DNS settings.
func (r *WgQuickRepo) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
if dnsStr == "" && dnsSearchStr == "" {
return nil
@@ -67,6 +73,7 @@ func (r *WgQuickRepo) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr
return nil
}
// UnsetDNS unsets the DNS settings for the given interface. It uses resolvconf to unset the DNS settings.
func (r *WgQuickRepo) UnsetDNS(id domain.InterfaceIdentifier) error {
dnsCommand := "resolvconf -d %resPref%i -f"
@@ -99,6 +106,8 @@ func (r *WgQuickRepo) exec(command string, interfaceId domain.InterfaceIdentifie
if err != nil {
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
}
logrus.Tracef("executed shell command %s, with output: %s", commandWithInterfaceName, string(out))
slog.Debug("executed shell command",
"command", commandWithInterfaceName,
"output", string(out))
return nil
}

View File

@@ -6,11 +6,12 @@ import (
"fmt"
"os"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/lowlevel"
"github.com/vishvananda/netlink"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/lowlevel"
)
// WgRepo implements all low-level WireGuard interactions.
@@ -19,6 +20,8 @@ type WgRepo struct {
nl lowlevel.NetlinkClient
}
// NewWireGuardRepository creates a new WgRepo instance.
// This repository is used to interact with the WireGuard kernel or userspace module.
func NewWireGuardRepository() *WgRepo {
wg, err := wgctrl.New()
if err != nil {
@@ -35,6 +38,7 @@ func NewWireGuardRepository() *WgRepo {
return repo
}
// GetInterfaces returns all existing WireGuard interfaces.
func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
devices, err := r.wg.Devices()
if err != nil {
@@ -53,10 +57,14 @@ func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, e
return interfaces, nil
}
// GetInterface returns the interface with the given id.
// If no interface is found, an error os.ErrNotExist is returned.
func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
return r.getInterface(id)
}
// GetPeers returns all peers associated with the given interface id.
// If the requested interface is found, an error os.ErrNotExist is returned.
func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
device, err := r.wg.Device(string(deviceId))
if err != nil {
@@ -75,6 +83,8 @@ func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier
return peers, nil
}
// GetPeer returns the peer with the given id.
// If the requested interface or peer is found, an error os.ErrNotExist is returned.
func (r *WgRepo) GetPeer(
_ context.Context,
deviceId domain.InterfaceIdentifier,
@@ -156,6 +166,9 @@ func (r *WgRepo) convertWireGuardPeer(peer *wgtypes.Peer) (domain.PhysicalPeer,
return peerModel, nil
}
// SaveInterface updates the interface with the given id.
// If no existing interface is found, a new interface is created.
// Updating the interface does not interrupt any existing connections.
func (r *WgRepo) SaveInterface(
_ context.Context,
id domain.InterfaceIdentifier,
@@ -186,10 +199,10 @@ func (r *WgRepo) SaveInterface(
func (r *WgRepo) getOrCreateInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
device, err := r.getInterface(id)
if err == nil {
return device, nil
return device, nil // interface exists
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("device error: %w", err)
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("device error: %w", err) // unknown error
}
// create new device
@@ -307,6 +320,8 @@ func (r *WgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
return nil
}
// DeleteInterface deletes the interface with the given id.
// If the requested interface is found, no error is returned.
func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
if err := r.deleteLowLevelInterface(id); err != nil {
return err
@@ -333,6 +348,8 @@ func (r *WgRepo) deleteLowLevelInterface(id domain.InterfaceIdentifier) error {
return nil
}
// SavePeer updates the peer with the given id.
// If no existing peer is found, a new peer is created.
func (r *WgRepo) SavePeer(
_ context.Context,
deviceId domain.InterfaceIdentifier,
@@ -362,10 +379,10 @@ func (r *WgRepo) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.
) {
peer, err := r.getPeer(deviceId, id)
if err == nil {
return peer, nil
return peer, nil // peer exists
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("peer error: %w", err)
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("peer error: %w", err) // unknown error
}
// create new peer
@@ -376,8 +393,14 @@ func (r *WgRepo) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.
},
},
})
if err != nil {
return nil, fmt.Errorf("peer create error for %s: %w", id.ToPublicKey(), err)
}
peer, err = r.getPeer(deviceId, id)
if err != nil {
return nil, fmt.Errorf("peer error after create: %w", err)
}
return peer, nil
}
@@ -424,6 +447,8 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
return nil
}
// DeletePeer deletes the peer with the given id.
// If the requested interface or peer is found, no error is returned.
func (r *WgRepo) DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
if !id.IsPublicKey() {
return errors.New("invalid public key")

View File

@@ -12,11 +12,11 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
"github.com/stretchr/testify/assert"
)
// setup WireGuard manager with no linked store
@@ -43,12 +43,12 @@ func Test_wgRepository_GetInterfaces(t *testing.T) {
mgr := setup(t)
interfaceName := domain.InterfaceIdentifier("wg_test_001")
defer mgr.DeleteInterface(context.Background(), interfaceName)
defer internal.LogError(mgr.DeleteInterface(context.Background(), interfaceName))
err := mgr.SaveInterface(context.Background(), interfaceName, nil)
require.NoError(t, err)
interfaceName2 := domain.InterfaceIdentifier("wg_test_002")
defer mgr.DeleteInterface(context.Background(), interfaceName2)
defer internal.LogError(mgr.DeleteInterface(context.Background(), interfaceName2))
err = mgr.SaveInterface(context.Background(), interfaceName2, nil)
require.NoError(t, err)
@@ -66,15 +66,16 @@ func TestWireGuardCreateInterface(t *testing.T) {
interfaceName := domain.InterfaceIdentifier("wg_test_001")
ipAddress := "10.11.12.13"
ipV6Address := "1337:d34d:b33f::2"
defer mgr.DeleteInterface(context.Background(), interfaceName)
defer internal.LogError(mgr.DeleteInterface(context.Background(), interfaceName))
err := mgr.SaveInterface(context.Background(), interfaceName, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = []domain.Cidr{
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipAddress), Mask: net.CIDRMask(24, 32)}),
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipV6Address), Mask: net.CIDRMask(64, 128)}),
}
return pi, nil
})
err := mgr.SaveInterface(context.Background(), interfaceName,
func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = []domain.Cidr{
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipAddress), Mask: net.CIDRMask(24, 32)}),
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipV6Address), Mask: net.CIDRMask(64, 128)}),
}
return pi, nil
})
assert.NoError(t, err)
// Validate that the interface has been created
@@ -90,7 +91,7 @@ func TestWireGuardUpdateInterface(t *testing.T) {
mgr := setup(t)
interfaceName := domain.InterfaceIdentifier("wg_test_001")
defer mgr.DeleteInterface(context.Background(), interfaceName)
defer internal.LogError(mgr.DeleteInterface(context.Background(), interfaceName))
err := mgr.SaveInterface(context.Background(), interfaceName, nil)
require.NoError(t, err)
@@ -102,13 +103,14 @@ func TestWireGuardUpdateInterface(t *testing.T) {
ipAddress := "10.11.12.13"
ipV6Address := "1337:d34d:b33f::2"
err = mgr.SaveInterface(context.Background(), interfaceName, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = []domain.Cidr{
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipAddress), Mask: net.CIDRMask(24, 32)}),
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipV6Address), Mask: net.CIDRMask(64, 128)}),
}
return pi, nil
})
err = mgr.SaveInterface(context.Background(), interfaceName,
func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = []domain.Cidr{
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipAddress), Mask: net.CIDRMask(24, 32)}),
domain.CidrFromIpNet(net.IPNet{IP: net.ParseIP(ipV6Address), Mask: net.CIDRMask(64, 128)}),
}
return pi, nil
})
assert.NoError(t, err)
// Validate that the interface has been updated

View File

@@ -11,6 +11,29 @@
},
"basePath": "/api/v0",
"paths": {
"/audit/entries": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Audit"
],
"summary": "Get all available audit entries. Ordered by timestamp.",
"operationId": "audit_handleEntriesGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.AuditEntry"
}
}
}
}
}
},
"/auth/login": {
"post": {
"produces": [
@@ -35,7 +58,7 @@
}
},
"/auth/logout": {
"get": {
"post": {
"produces": [
"application/json"
],
@@ -43,15 +66,12 @@
"Authentication"
],
"summary": "Get all available external login providers.",
"operationId": "auth_handleLogoutGet",
"operationId": "auth_handleLogoutPost",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.LoginProviderInfo"
}
"$ref": "#/definitions/model.Error"
}
}
}
@@ -171,6 +191,9 @@
"schema": {
"type": "string"
}
},
"500": {
"description": "Internal Server Error"
}
}
}
@@ -1363,6 +1386,50 @@
}
}
},
"/user/{id}/interfaces": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get interfaces for the given user. Returns an empty list if self provisioning is disabled.",
"operationId": "users_handleInterfacesGet",
"parameters": [
{
"type": "string",
"description": "The user identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Interface"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/{id}/peers": {
"get": {
"produces": [
@@ -1373,6 +1440,15 @@
],
"summary": "Get peers for the given user.",
"operationId": "users_handlePeersGet",
"parameters": [
{
"type": "string",
"description": "The user identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
@@ -1408,6 +1484,15 @@
],
"summary": "Get peer stats for the given user.",
"operationId": "users_handleStatsGet",
"parameters": [
{
"type": "string",
"description": "The user identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
@@ -1432,6 +1517,30 @@
}
},
"definitions": {
"model.AuditEntry": {
"type": "object",
"properties": {
"ContextUser": {
"type": "string"
},
"Id": {
"type": "integer"
},
"Message": {
"type": "string"
},
"Origin": {
"description": "origin: for example user auth, stats, ...",
"type": "string"
},
"Severity": {
"type": "string"
},
"Timestamp": {
"type": "string"
}
}
},
"model.ConfigOption-array_string": {
"type": "object",
"properties": {
@@ -1537,6 +1646,10 @@
"EnabledPeers": {
"type": "integer"
},
"Filename": {
"description": "the filename of the config file, for example: wg0.conf",
"type": "string"
},
"FirewallMark": {
"description": "a firewall mark",
"type": "integer"
@@ -1778,6 +1891,10 @@
"type": "string"
}
},
"Filename": {
"description": "the filename of the config file, for example: wg_peer_x.conf",
"type": "string"
},
"FirewallMark": {
"description": "a firewall mark",
"allOf": [

View File

@@ -1,5 +1,21 @@
basePath: /api/v0
definitions:
model.AuditEntry:
properties:
ContextUser:
type: string
Id:
type: integer
Message:
type: string
Origin:
description: 'origin: for example user auth, stats, ...'
type: string
Severity:
type: string
Timestamp:
type: string
type: object
model.ConfigOption-array_string:
properties:
Overridable:
@@ -72,6 +88,9 @@ definitions:
type: array
EnabledPeers:
type: integer
Filename:
description: 'the filename of the config file, for example: wg0.conf'
type: string
FirewallMark:
description: a firewall mark
type: integer
@@ -240,6 +259,9 @@ definitions:
items:
type: string
type: array
Filename:
description: 'the filename of the config file, for example: wg_peer_x.conf'
type: string
FirewallMark:
allOf:
- $ref: '#/definitions/model.ConfigOption-uint32'
@@ -419,6 +441,21 @@ info:
title: WireGuard Portal SPA-UI API
version: "0.0"
paths:
/audit/entries:
get:
operationId: audit_handleEntriesGet
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.AuditEntry'
type: array
summary: Get all available audit entries. Ordered by timestamp.
tags:
- Audit
/auth/{provider}/callback:
get:
operationId: auth_handleOauthCallbackGet
@@ -465,17 +502,15 @@ paths:
tags:
- Authentication
/auth/logout:
get:
operationId: auth_handleLogoutGet
post:
operationId: auth_handleLogoutPost
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.LoginProviderInfo'
type: array
$ref: '#/definitions/model.Error'
summary: Get all available external login providers.
tags:
- Authentication
@@ -523,6 +558,8 @@ paths:
description: The JavaScript contents
schema:
type: string
"500":
description: Internal Server Error
summary: Get the dynamic frontend configuration javascript.
tags:
- Configuration
@@ -1262,9 +1299,45 @@ paths:
summary: Enable the REST API for the given user.
tags:
- Users
/user/{id}/interfaces:
get:
operationId: users_handleInterfacesGet
parameters:
- description: The user identifier
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.Interface'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Get interfaces for the given user. Returns an empty list if self provisioning
is disabled.
tags:
- Users
/user/{id}/peers:
get:
operationId: users_handlePeersGet
parameters:
- description: The user identifier
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
@@ -1288,6 +1361,12 @@ paths:
/user/{id}/stats:
get:
operationId: users_handleStatsGet
parameters:
- description: The user identifier
in: path
name: id
required: true
type: string
produces:
- application/json
responses:

View File

@@ -118,6 +118,7 @@
"BasicAuth": []
}
],
"description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
"produces": [
"application/json"
],
@@ -250,6 +251,7 @@
"BasicAuth": []
}
],
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
"produces": [
"application/json"
],
@@ -309,6 +311,50 @@
}
}
},
"/interface/prepare": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
"produces": [
"application/json"
],
"tags": [
"Interfaces"
],
"summary": "Prepare a new interface record.",
"operationId": "interfaces_handlePrepareGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Interface"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/models.Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/models.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/models.Error"
}
}
}
}
},
"/metrics/by-interface/{id}": {
"get": {
"security": [
@@ -547,7 +593,7 @@
"BasicAuth": []
}
],
"description": "Only admins can update existing records.",
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
"produces": [
"application/json"
],
@@ -779,7 +825,7 @@
"BasicAuth": []
}
],
"description": "Only admins can create new records.",
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
"produces": [
"application/json"
],
@@ -839,6 +885,71 @@
}
}
},
"/peer/prepare/{id}": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
"produces": [
"application/json"
],
"tags": [
"Peers"
],
"summary": "Prepare a new peer record for the given WireGuard interface.",
"operationId": "peers_handlePrepareGet",
"parameters": [
{
"type": "string",
"description": "The interface identifier.",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Peer"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/models.Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/models.Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/models.Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/models.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/models.Error"
}
}
}
}
},
"/provisioning/data/peer-config": {
"get": {
"security": [
@@ -1471,14 +1582,6 @@
}
}
},
"models.ExpiryDate": {
"type": "object",
"properties": {
"time.Time": {
"type": "string"
}
}
},
"models.Interface": {
"type": "object",
"required": [
@@ -1539,6 +1642,13 @@
"type": "integer",
"readOnly": true
},
"Filename": {
"description": "Filename is the name of the config file for this interface.\nThis value is read only and is not settable by the user.",
"type": "string",
"maxLength": 21,
"readOnly": true,
"example": "wg0.conf"
},
"FirewallMark": {
"description": "FirewallMark is an optional firewall mark which is used to handle interface traffic.",
"type": "integer"
@@ -1798,11 +1908,7 @@
},
"ExpiresAt": {
"description": "ExpiresAt is the expiry date of the peer in YYYY-MM-DD format. An expired peer is not able to connect.",
"allOf": [
{
"$ref": "#/definitions/models.ExpiryDate"
}
]
"type": "string"
},
"ExtraAllowedIPs": {
"description": "ExtraAllowedIPs is a list of additional allowed IP subnets for the peer. These allowed IP subnets are added on the server side.",
@@ -1811,6 +1917,13 @@
"type": "string"
}
},
"Filename": {
"description": "Filename is the name of the config file for this peer.\nThis value is read only and is not settable by the user.",
"type": "string",
"maxLength": 21,
"readOnly": true,
"example": "wg_peer_x.conf"
},
"FirewallMark": {
"description": "FirewallMark is an optional firewall mark which is used to handle peer traffic.",
"allOf": [
@@ -1998,8 +2111,7 @@
"models.User": {
"type": "object",
"required": [
"Identifier",
"IsAdmin"
"Identifier"
],
"properties": {
"ApiEnabled": {

View File

@@ -42,11 +42,6 @@ definitions:
description: Error message.
type: string
type: object
models.ExpiryDate:
properties:
time.Time:
type: string
type: object
models.Interface:
properties:
Addresses:
@@ -92,6 +87,14 @@ definitions:
Only enabled peers are able to connect.
readOnly: true
type: integer
Filename:
description: |-
Filename is the name of the config file for this interface.
This value is read only and is not settable by the user.
example: wg0.conf
maxLength: 21
readOnly: true
type: string
FirewallMark:
description: FirewallMark is an optional firewall mark which is used to handle
interface traffic.
@@ -306,16 +309,23 @@ definitions:
- $ref: '#/definitions/models.ConfigOption-string'
description: EndpointPublicKey is the endpoint public key.
ExpiresAt:
allOf:
- $ref: '#/definitions/models.ExpiryDate'
description: ExpiresAt is the expiry date of the peer in YYYY-MM-DD format.
An expired peer is not able to connect.
type: string
ExtraAllowedIPs:
description: ExtraAllowedIPs is a list of additional allowed IP subnets for
the peer. These allowed IP subnets are added on the server side.
items:
type: string
type: array
Filename:
description: |-
Filename is the name of the config file for this peer.
This value is read only and is not settable by the user.
example: wg_peer_x.conf
maxLength: 21
readOnly: true
type: string
FirewallMark:
allOf:
- $ref: '#/definitions/models.ConfigOption-uint32'
@@ -549,7 +559,6 @@ definitions:
type: string
required:
- Identifier
- IsAdmin
type: object
models.UserInformation:
properties:
@@ -739,6 +748,8 @@ paths:
tags:
- Interfaces
put:
description: This endpoint updates an existing interface with the provided data.
All required fields must be filled (e.g. name, private key, public key, ...).
operationId: interfaces_handleUpdatePut
parameters:
- description: The interface identifier.
@@ -786,6 +797,8 @@ paths:
- Interfaces
/interface/new:
post:
description: This endpoint creates a new interface with the provided data. All
required fields must be filled (e.g. name, private key, public key, ...).
operationId: interfaces_handleCreatePost
parameters:
- description: The interface data.
@@ -826,6 +839,35 @@ paths:
summary: Create a new interface record.
tags:
- Interfaces
/interface/prepare:
get:
description: This endpoint returns a new interface with default values (fresh
key pair, valid name, new IP address pool, ...).
operationId: interfaces_handlePrepareGet
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Interface'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/models.Error'
"403":
description: Forbidden
schema:
$ref: '#/definitions/models.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/models.Error'
security:
- BasicAuth: []
summary: Prepare a new interface record.
tags:
- Interfaces
/metrics/by-interface/{id}:
get:
operationId: metrics_handleMetricsForInterfaceGet
@@ -1015,7 +1057,8 @@ paths:
tags:
- Peers
put:
description: Only admins can update existing records.
description: Only admins can update existing records. The peer record must contain
all required fields (e.g., public key, allowed IPs).
operationId: peers_handleUpdatePut
parameters:
- description: The peer identifier.
@@ -1127,7 +1170,8 @@ paths:
- Peers
/peer/new:
post:
description: Only admins can create new records.
description: Only admins can create new records. The peer record must contain
all required fields (e.g., public key, allowed IPs).
operationId: peers_handleCreatePost
parameters:
- description: The peer data.
@@ -1168,6 +1212,49 @@ paths:
summary: Create a new peer record.
tags:
- Peers
/peer/prepare/{id}:
get:
description: This endpoint is used to prepare a new peer record. The returned
data contains a fresh key pair and valid ip address.
operationId: peers_handlePrepareGet
parameters:
- description: The interface identifier.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Peer'
"400":
description: Bad Request
schema:
$ref: '#/definitions/models.Error'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/models.Error'
"403":
description: Forbidden
schema:
$ref: '#/definitions/models.Error'
"404":
description: Not Found
schema:
$ref: '#/definitions/models.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/models.Error'
security:
- BasicAuth: []
summary: Prepare a new peer record for the given WireGuard interface.
tags:
- Peers
/provisioning/data/peer-config:
get:
description: Normal users can only access their own record. Admins can access

View File

@@ -0,0 +1,214 @@
package cors
import (
"net/http"
"slices"
"strconv"
"strings"
)
// Middleware is a type that creates a new CORS middleware. The CORS middleware
// adds Cross-Origin Resource Sharing headers to the response. This middleware should
// be used to allow cross-origin requests to your server.
type Middleware struct {
o options
varyHeaders string // precomputed Vary header
allOrigins bool // all origins are allowed
}
// New returns a new CORS middleware with the provided options.
func New(opts ...Option) *Middleware {
o := newOptions(opts...)
m := &Middleware{
o: o,
}
// set vary headers
if m.o.allowPrivateNetworks {
m.varyHeaders = "Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network"
} else {
m.varyHeaders = "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
}
if len(m.o.allowedOrigins) == 1 && m.o.allowedOrigins[0] == "*" {
m.allOrigins = true
}
return m
}
// Handler returns the CORS middleware handler.
func (m *Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle preflight requests and stop the chain as some other
// middleware may not handle OPTIONS requests correctly.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
m.handlePreflight(w, r)
w.WriteHeader(http.StatusNoContent) // always return 204 No Content
return
}
// handle normal CORS requests
m.handleNormal(w, r)
next.ServeHTTP(w, r) // execute the next handler
})
}
// region internal-helpers
// handlePreflight handles preflight requests. If the request was successful, this function will
// write the CORS headers and return. If the request was not successful, this function will
// not add any CORS headers and return - thus the CORS request is considered invalid.
func (m *Middleware) handlePreflight(w http.ResponseWriter, r *http.Request) {
// Always set Vary headers
// see https://github.com/rs/cors/issues/10,
// https://github.com/rs/cors/commit/dbdca4d95feaa7511a46e6f1efb3b3aa505bc43f#commitcomment-12352001
w.Header().Add("Vary", m.varyHeaders)
// check origin
origin := r.Header.Get("Origin")
if origin == "" {
return // not a valid CORS request
}
if !m.originAllowed(origin) {
return
}
// check method
reqMethod := r.Header.Get("Access-Control-Request-Method")
if !m.methodAllowed(reqMethod) {
return
}
// check headers
reqHeaders := r.Header.Get("Access-Control-Request-Headers")
if !m.headersAllowed(reqHeaders) {
return
}
// set CORS headers for the successful preflight request
if m.allOrigins {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin) // return original origin
}
w.Header().Set("Access-Control-Allow-Methods", reqMethod)
if reqHeaders != "" {
// Spec says: Since the list of headers can be unbounded, simply returning supported headers
// from Access-Control-Request-Headers can be enough
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
}
if m.o.allowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if m.o.allowPrivateNetworks && r.Header.Get("Access-Control-Request-Private-Network") == "true" {
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
if m.o.maxAge > 0 {
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(m.o.maxAge))
}
}
// handleNormal handles normal CORS requests. If the request was successful, this function will
// write the CORS headers to the response. If the request was not successful, this function will
// not add any CORS headers to the response. In this case, the CORS request is considered invalid.
func (m *Middleware) handleNormal(w http.ResponseWriter, r *http.Request) {
// Always set Vary headers
// see https://github.com/rs/cors/issues/10,
// https://github.com/rs/cors/commit/dbdca4d95feaa7511a46e6f1efb3b3aa505bc43f#commitcomment-12352001
w.Header().Add("Vary", "Origin")
// check origin
origin := r.Header.Get("Origin")
if origin == "" {
return // not a valid CORS request
}
if !m.originAllowed(origin) {
return
}
// check method
if !m.methodAllowed(r.Method) {
return
}
// set CORS headers for the successful CORS request
if m.allOrigins {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin) // return original origin
}
if len(m.o.exposedHeaders) > 0 {
w.Header().Set("Access-Control-Expose-Headers", strings.Join(m.o.exposedHeaders, ", "))
}
if m.o.allowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
}
func (m *Middleware) originAllowed(origin string) bool {
if len(m.o.allowedOrigins) == 1 && m.o.allowedOrigins[0] == "*" {
return true // everything is allowed
}
// check simple origins
if slices.Contains(m.o.allowedOrigins, origin) {
return true
}
// check wildcard origins
for _, allowedOrigin := range m.o.allowedOriginPatterns {
if allowedOrigin.match(origin) {
return true
}
}
return false
}
func (m *Middleware) methodAllowed(method string) bool {
if method == http.MethodOptions {
return true // preflight request is always allowed
}
if len(m.o.allowedMethods) == 1 && m.o.allowedMethods[0] == "*" {
return true // everything is allowed
}
if slices.Contains(m.o.allowedMethods, method) {
return true
}
return false
}
func (m *Middleware) headersAllowed(headers string) bool {
if headers == "" {
return true // no headers are requested
}
if len(m.o.allowedHeaders) == 0 {
return false // no headers are allowed
}
if _, ok := m.o.allowedHeaders["*"]; ok {
return true // everything is allowed
}
// split headers by comma (according to definition, the headers are sorted and in lowercase)
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers
for header := range strings.SplitSeq(headers, ",") {
if _, ok := m.o.allowedHeaders[strings.TrimSpace(header)]; !ok {
return false
}
}
return true
}
// endregion internal-helpers

View File

@@ -0,0 +1,101 @@
package cors
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestMiddleware_New(t *testing.T) {
m := New(WithAllowedOrigins("*"))
if len(m.varyHeaders) == 0 {
t.Errorf("expected vary headers to be populated, got %v", m.varyHeaders)
}
if !m.allOrigins {
t.Errorf("expected allOrigins to be true, got %v", m.allOrigins)
}
}
func TestMiddleware_Handler_normal(t *testing.T) {
m := New(WithAllowedOrigins("http://example.com"))
handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusOK {
t.Errorf("expected status code 200, got %d", w.Result().StatusCode)
}
if w.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
t.Errorf("expected Access-Control-Allow-Origin to be 'http://example.com', got %s",
w.Header().Get("Access-Control-Allow-Origin"))
}
}
func TestMiddleware_Handler_preflight(t *testing.T) {
m := New(WithAllowedOrigins("http://example.com"))
handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodOptions, "http://example.com", nil)
req.Header.Set("Origin", "http://example.com")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusNoContent {
t.Errorf("expected status code 204, got %d", w.Result().StatusCode)
}
if w.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
t.Errorf("expected Access-Control-Allow-Origin to be 'http://example.com', got %s",
w.Header().Get("Access-Control-Allow-Origin"))
}
}
func TestMiddleware_originAllowed(t *testing.T) {
m := New(WithAllowedOrigins("http://example.com"))
if !m.originAllowed("http://example.com") {
t.Errorf("expected origin 'http://example.com' to be allowed")
}
if m.originAllowed("http://notallowed.com") {
t.Errorf("expected origin 'http://notallowed.com' to be not allowed")
}
}
func TestMiddleware_methodAllowed(t *testing.T) {
m := New(WithAllowedMethods(http.MethodGet, http.MethodPost))
if !m.methodAllowed(http.MethodGet) {
t.Errorf("expected method 'GET' to be allowed")
}
if m.methodAllowed(http.MethodDelete) {
t.Errorf("expected method 'DELETE' to be not allowed")
}
}
func TestMiddleware_headersAllowed(t *testing.T) {
m := New(WithAllowedHeaders("Content-Type", "Authorization"))
if !m.headersAllowed("content-type, authorization") {
t.Errorf("expected headers 'Content-Type, Authorization' to be allowed")
}
if m.headersAllowed("x-custom-header") {
t.Errorf("expected header 'X-Custom-Header' to be not allowed")
}
}

View File

@@ -0,0 +1,133 @@
package cors
import (
"net/http"
"strings"
)
type void struct{}
// options is a struct that contains options for the CORS middleware.
// It uses the functional options pattern for flexible configuration.
type options struct {
allowedOrigins []string // origins without wildcards
allowedOriginPatterns []wildcard // origins with wildcards
allowedMethods []string
allowedHeaders map[string]void
exposedHeaders []string // these are in addition to the CORS-safelisted response headers
allowCredentials bool
allowPrivateNetworks bool
maxAge int
}
// Option is a type that is used to set options for the CORS middleware.
// It implements the functional options pattern.
type Option func(*options)
// WithAllowedOrigins sets the allowed origins for the CORS middleware.
// If the special "*" value is present in the list, all origins will be allowed.
// An origin may contain a wildcard (*) to replace 0 or more characters
// (i.e.: http://*.domain.com). Usage of wildcards implies a small performance penalty.
// Only one wildcard can be used per origin.
// By default, all origins are allowed (*).
func WithAllowedOrigins(origins ...string) Option {
return func(o *options) {
o.allowedOrigins = nil
o.allowedOriginPatterns = nil
for _, origin := range origins {
if len(origin) > 1 && strings.Contains(origin, "*") {
o.allowedOriginPatterns = append(
o.allowedOriginPatterns,
newWildcard(origin),
)
} else {
o.allowedOrigins = append(o.allowedOrigins, origin)
}
}
}
}
// WithAllowedMethods sets the allowed methods for the CORS middleware.
// By default, all methods are allowed (*).
func WithAllowedMethods(methods ...string) Option {
return func(o *options) {
o.allowedMethods = methods
}
}
// WithAllowedHeaders sets the allowed headers for the CORS middleware.
// By default, all headers are allowed (*).
func WithAllowedHeaders(headers ...string) Option {
return func(o *options) {
o.allowedHeaders = make(map[string]void)
for _, header := range headers {
// allowed headers are always checked in lowercase
o.allowedHeaders[strings.ToLower(header)] = void{}
}
}
}
// WithExposedHeaders sets the exposed headers for the CORS middleware.
// By default, no headers are exposed.
func WithExposedHeaders(headers ...string) Option {
return func(o *options) {
o.exposedHeaders = nil
for _, header := range headers {
o.exposedHeaders = append(o.exposedHeaders, http.CanonicalHeaderKey(header))
}
}
}
// WithAllowCredentials sets the allow credentials option for the CORS middleware.
// This setting indicates whether the request can include user credentials like
// cookies, HTTP authentication or client side SSL certificates.
// By default, credentials are not allowed.
func WithAllowCredentials(allow bool) Option {
return func(o *options) {
o.allowCredentials = allow
}
}
// WithAllowPrivateNetworks sets the allow private networks option for the CORS middleware.
// This setting indicates whether to accept cross-origin requests over a private network.
func WithAllowPrivateNetworks(allow bool) Option {
return func(o *options) {
o.allowPrivateNetworks = allow
}
}
// WithMaxAge sets the max age (in seconds) for the CORS middleware.
// The maximum age indicates how long (in seconds) the results of a preflight request
// can be cached. A value of 0 means that no Access-Control-Max-Age header is sent back,
// resulting in browsers using their default value (5s by spec).
// If you need to force a 0 max-age, set it to a negative value (ie: -1).
// By default, the max age is 7200 seconds.
func WithMaxAge(age int) Option {
return func(o *options) {
o.maxAge = age
}
}
// newOptions is a function that returns a new options struct with sane default values.
func newOptions(opts ...Option) options {
o := options{
allowedOrigins: []string{"*"},
allowedMethods: []string{
http.MethodHead, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete,
},
allowedHeaders: map[string]void{"*": {}},
exposedHeaders: nil,
allowCredentials: false,
allowPrivateNetworks: false,
maxAge: 0,
}
for _, opt := range opts {
opt(&o)
}
return o
}

View File

@@ -0,0 +1,96 @@
package cors
import (
"maps"
"net/http"
"slices"
"testing"
)
func TestWithAllowedOrigins(t *testing.T) {
tests := []struct {
name string
origins []string
wantNormal []string
wantWildcard []wildcard
}{
{
name: "No origins",
origins: []string{},
wantNormal: nil,
wantWildcard: nil,
},
{
name: "Single origin",
origins: []string{"http://example.com"},
wantNormal: []string{"http://example.com"},
wantWildcard: nil,
},
{
name: "Wildcard origin",
origins: []string{"http://*.example.com"},
wantNormal: nil,
wantWildcard: []wildcard{newWildcard("http://*.example.com")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := newOptions(WithAllowedOrigins(tt.origins...))
if !slices.Equal(o.allowedOrigins, tt.wantNormal) {
t.Errorf("got %v, want %v", o, tt.wantNormal)
}
if !slices.Equal(o.allowedOriginPatterns, tt.wantWildcard) {
t.Errorf("got %v, want %v", o, tt.wantWildcard)
}
})
}
}
func TestWithAllowedMethods(t *testing.T) {
methods := []string{http.MethodGet, http.MethodPost}
o := newOptions(WithAllowedMethods(methods...))
if !slices.Equal(o.allowedMethods, methods) {
t.Errorf("got %v, want %v", o.allowedMethods, methods)
}
}
func TestWithAllowedHeaders(t *testing.T) {
headers := []string{"Content-Type", "Authorization"}
o := newOptions(WithAllowedHeaders(headers...))
expectedHeaders := map[string]void{"content-type": {}, "authorization": {}}
if !maps.Equal(o.allowedHeaders, expectedHeaders) {
t.Errorf("got %v, want %v", o.allowedHeaders, expectedHeaders)
}
}
func TestWithExposedHeaders(t *testing.T) {
headers := []string{"X-Custom-Header"}
o := newOptions(WithExposedHeaders(headers...))
expectedHeaders := []string{http.CanonicalHeaderKey("X-Custom-Header")}
if !slices.Equal(o.exposedHeaders, expectedHeaders) {
t.Errorf("got %v, want %v", o.exposedHeaders, expectedHeaders)
}
}
func TestWithAllowCredentials(t *testing.T) {
o := newOptions(WithAllowCredentials(true))
if !o.allowCredentials {
t.Errorf("got %v, want %v", o.allowCredentials, true)
}
}
func TestWithAllowPrivateNetworks(t *testing.T) {
o := newOptions(WithAllowPrivateNetworks(true))
if !o.allowPrivateNetworks {
t.Errorf("got %v, want %v", o.allowPrivateNetworks, true)
}
}
func TestWithMaxAge(t *testing.T) {
maxAge := 3600
o := newOptions(WithMaxAge(maxAge))
if o.maxAge != maxAge {
t.Errorf("got %v, want %v", o.maxAge, maxAge)
}
}

View File

@@ -0,0 +1,33 @@
package cors
import "strings"
// wildcard is a type that represents a wildcard string.
// This type allows faster matching of strings with a wildcard
// in comparison to using regex.
type wildcard struct {
prefix string
suffix string
}
// match returns true if the string s has the prefix and suffix of the wildcard.
func (w wildcard) match(s string) bool {
return len(s) >= len(w.prefix)+len(w.suffix) &&
strings.HasPrefix(s, w.prefix) &&
strings.HasSuffix(s, w.suffix)
}
func newWildcard(s string) wildcard {
if i := strings.IndexByte(s, '*'); i >= 0 {
return wildcard{
prefix: s[:i],
suffix: s[i+1:],
}
}
// fallback, usually this case should not happen
return wildcard{
prefix: s,
suffix: "",
}
}

View File

@@ -0,0 +1,94 @@
package cors
import "testing"
func TestWildcardMatch(t *testing.T) {
tests := []struct {
name string
wildcard wildcard
input string
expected bool
}{
{
name: "Match with prefix and suffix",
wildcard: newWildcard("http://*.example.com"),
input: "http://sub.example.com",
expected: true,
},
{
name: "No match with different prefix",
wildcard: newWildcard("http://*.example.com"),
input: "https://sub.example.com",
expected: false,
},
{
name: "No match with different suffix",
wildcard: newWildcard("http://*.example.com"),
input: "http://sub.example.org",
expected: false,
},
{
name: "Match with empty suffix",
wildcard: newWildcard("http://*"),
input: "http://example.com",
expected: true,
},
{
name: "Match with empty prefix",
wildcard: newWildcard("*.example.com"),
input: "sub.example.com",
expected: true,
},
{
name: "No match with empty prefix and different suffix",
wildcard: newWildcard("*.example.com"),
input: "sub.example.org",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.wildcard.match(tt.input); got != tt.expected {
t.Errorf("wildcard.match(%s) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestNewWildcard(t *testing.T) {
tests := []struct {
name string
input string
expected wildcard
}{
{
name: "Wildcard with prefix and suffix",
input: "http://*.example.com",
expected: wildcard{prefix: "http://", suffix: ".example.com"},
},
{
name: "Wildcard with empty suffix",
input: "http://*",
expected: wildcard{prefix: "http://", suffix: ""},
},
{
name: "Wildcard with empty prefix",
input: "*.example.com",
expected: wildcard{prefix: "", suffix: ".example.com"},
},
{
name: "No wildcard character",
input: "http://example.com",
expected: wildcard{prefix: "http://example.com", suffix: ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := newWildcard(tt.input); got != tt.expected {
t.Errorf("newWildcard(%s) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,137 @@
package csrf
import (
"context"
"net/http"
"slices"
)
// ContextValueIdentifier is the context value identifier for the CSRF token.
// The token is only stored in the context if the RefreshToken function was called before.
const ContextValueIdentifier = "_csrf_token"
// Middleware is a type that creates a new CSRF middleware. The CSRF middleware
// can be used to mitigate Cross-Site Request Forgery attacks.
type Middleware struct {
o options
}
// New returns a new CSRF middleware with the provided options.
func New(sessionReader SessionReader, sessionWriter SessionWriter, opts ...Option) *Middleware {
opts = append(opts, withSessionReader(sessionReader), withSessionWriter(sessionWriter))
o := newOptions(opts...)
m := &Middleware{
o: o,
}
checkForPRNG()
return m
}
// Handler returns the CSRF middleware handler. This middleware validates the CSRF token and calls the specified
// error handler if an invalid CSRF token was found.
func (m *Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if slices.Contains(m.o.ignoreMethods, r.Method) {
next.ServeHTTP(w, r) // skip CSRF check for ignored methods
return
}
// get the token from the request
token := m.o.tokenGetter(r)
storedToken := m.o.sessionGetter(r)
if !tokenEqual(token, storedToken) {
m.o.errCallback(w, r)
return
}
next.ServeHTTP(w, r) // execute the next handler
})
}
// RefreshToken generates a new CSRF Token and stores it in the session. The token is also passed to subsequent handlers
// via the context value ContextValueIdentifier.
func (m *Middleware) RefreshToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetToken(r.Context()) != "" {
// token already generated higher up in the chain
next.ServeHTTP(w, r)
return
}
// generate a new token
token := generateToken(m.o.tokenLength)
key := generateToken(m.o.tokenLength)
// mask the token
maskedToken := maskToken(token, key)
encodedToken := encodeToken(maskedToken)
// pass the token down the chain via the context
r = r.WithContext(setToken(r.Context(), encodedToken))
// store the token in the session
m.o.sessionWriter(r, encodedToken)
next.ServeHTTP(w, r)
})
}
// region token-access
// GetToken retrieves the CSRF token from the given context. Ensure that the RefreshToken function was called before,
// otherwise, no token is populated in the context.
func GetToken(ctx context.Context) string {
token, ok := ctx.Value(ContextValueIdentifier).(string)
if !ok {
return ""
}
return token
}
// endregion token-access
// region internal-helpers
func setToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, ContextValueIdentifier, token)
}
// defaultTokenGetter is the default token getter function for the CSRF middleware.
// It checks the request form values, URL query parameters, and headers for the CSRF token.
// The order of precedence is:
// 1. Header "X-CSRF-TOKEN"
// 2. Header "X-XSRF-TOKEN"
// 3. URL query parameter "_csrf"
// 4. Form value "_csrf"
func defaultTokenGetter(r *http.Request) string {
if t := r.Header.Get("X-CSRF-TOKEN"); len(t) > 0 {
return t
}
if t := r.Header.Get("X-XSRF-TOKEN"); len(t) > 0 {
return t
}
if t := r.URL.Query().Get("_csrf"); len(t) > 0 {
return t
}
if t := r.FormValue("_csrf"); len(t) > 0 {
return t
}
return ""
}
// defaultErrorHandler is the default error handler function for the CSRF middleware.
// It writes a 403 Forbidden response.
func defaultErrorHandler(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "CSRF token mismatch", http.StatusForbidden)
}
// endregion internal-helpers

View File

@@ -0,0 +1,251 @@
package csrf
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/h44z/wg-portal/internal/app/api/core/request"
)
func TestMiddleware_Handler(t *testing.T) {
sessionToken := "stored-token"
sessionReader := func(r *http.Request) string {
return sessionToken
}
sessionWriter := func(r *http.Request, token string) {
sessionToken = token
}
m := New(sessionReader, sessionWriter)
handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
method string
token string
wantStatus int
}{
{"ValidToken", "POST", "stored-token", http.StatusOK},
{"ValidToken2", "PUT", "stored-token", http.StatusOK},
{"ValidToken3", "GET", "stored-token", http.StatusOK},
{"InvalidToken", "POST", "invalid-token", http.StatusForbidden},
{"IgnoredMethod", "GET", "", http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/", nil)
req.Header.Set("X-CSRF-TOKEN", tt.token)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != tt.wantStatus {
t.Errorf("Handler() status = %d, want %d", status, tt.wantStatus)
}
})
}
}
func TestMiddleware_RefreshToken(t *testing.T) {
sessionToken := ""
sessionReader := func(r *http.Request) string {
return sessionToken
}
sessionWriter := func(r *http.Request, token string) {
sessionToken = token
}
m := New(sessionReader, sessionWriter)
handler := m.RefreshToken(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := GetToken(r.Context())
if token == "" {
t.Errorf("RefreshToken() did not set token in context")
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("POST", "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("RefreshToken() status = %d, want %d", status, http.StatusOK)
}
if sessionToken == "" {
t.Errorf("RefreshToken() did not set token in session")
}
}
func TestMiddleware_RefreshToken_chained(t *testing.T) {
sessionToken := ""
tokenWrites := 0
sessionReader := func(r *http.Request) string {
return sessionToken
}
sessionWriter := func(r *http.Request, token string) {
sessionToken = token
tokenWrites++
}
m := New(sessionReader, sessionWriter)
handler := m.RefreshToken(m.RefreshToken(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := GetToken(r.Context())
if token == "" {
t.Errorf("RefreshToken() did not set token in context")
}
w.WriteHeader(http.StatusOK)
})))
req := httptest.NewRequest("POST", "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("RefreshToken() status = %d, want %d", status, http.StatusOK)
}
if sessionToken == "" {
t.Errorf("RefreshToken() did not set token in session")
}
if tokenWrites != 1 {
t.Errorf("RefreshToken() wrote token to session more than once: %d", tokenWrites)
}
}
func TestMiddleware_RefreshToken_Handler(t *testing.T) {
sessionToken := ""
sessionReader := func(r *http.Request) string {
return sessionToken
}
sessionWriter := func(r *http.Request, token string) {
sessionToken = token
}
m := New(sessionReader, sessionWriter)
// simulate two requests: first one GET request with the RefreshToken handler, the next one is a PUT request with
// the token from the first request added as X-CSRF-TOKEN header
// first request
retrievedToken := ""
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
handler := m.RefreshToken(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retrievedToken = GetToken(r.Context())
if retrievedToken == "" {
t.Errorf("RefreshToken() did not set token in context")
}
w.WriteHeader(http.StatusAccepted)
}))
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusAccepted {
t.Errorf("Handler() status = %d, want %d", status, http.StatusAccepted)
}
if retrievedToken == "" {
t.Errorf("no token retrieved")
}
if retrievedToken != sessionToken {
t.Errorf("token in context does not match token in session")
}
// second request
req = httptest.NewRequest("PUT", "/", nil)
req.Header.Set("X-CSRF-TOKEN", retrievedToken)
rr = httptest.NewRecorder()
handler = m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler() status = %d, want %d", status, http.StatusOK)
}
}
func TestMiddleware_Handler_FormBody(t *testing.T) {
sessionToken := "stored-token"
sessionReader := func(r *http.Request) string {
return sessionToken
}
sessionWriter := func(r *http.Request, token string) {
sessionToken = token
}
m := New(sessionReader, sessionWriter)
handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyData, err := request.BodyString(r)
if err != nil {
t.Errorf("Handler() error = %v, want nil", err)
}
// ensure that the body is empty - ParseForm() should have been called before by the CSRF middleware
if bodyData != "" {
t.Errorf("Handler() bodyData = %s, want empty", bodyData)
}
if r.FormValue("_csrf") != "stored-token" {
t.Errorf("Handler() _csrf = %s, want %s", r.FormValue("_csrf"), "stored-token")
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("POST", "/", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Form = make(map[string][]string)
req.Form.Add("_csrf", "stored-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler() status = %d, want %d", status, http.StatusOK)
}
}
func TestMiddleware_Handler_FormBodyAvailable(t *testing.T) {
sessionToken := "stored-token"
sessionReader := func(r *http.Request) string {
return sessionToken
}
sessionWriter := func(r *http.Request, token string) {
sessionToken = token
}
m := New(sessionReader, sessionWriter)
handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyData, err := request.BodyString(r)
if err != nil {
t.Errorf("Handler() error = %v, want nil", err)
}
// ensure that the body is not empty, as the CSRF middleware should not have read the body
if bodyData != "the original body" {
t.Errorf("Handler() bodyData = %s, want %s", bodyData, "the original body")
}
// check if the token is available in the form values (from query parameters)
if r.FormValue("_csrf") != "stored-token" {
t.Errorf("Handler() _csrf = %s, want %s", r.FormValue("_csrf"), "stored-token")
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("POST", "/?_csrf=stored-token", nil)
req.Header.Set("Content-Type", "text/plain")
req.Body = io.NopCloser(strings.NewReader("the original body"))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler() status = %d, want %d", status, http.StatusOK)
}
}

View File

@@ -0,0 +1,88 @@
package csrf
import "net/http"
type SessionReader func(r *http.Request) string
type SessionWriter func(r *http.Request, token string)
// options is a struct that contains options for the CSRF middleware.
// It uses the functional options pattern for flexible configuration.
type options struct {
tokenLength int
ignoreMethods []string
errCallbackOverride bool
errCallback func(w http.ResponseWriter, r *http.Request)
tokenGetterOverride bool
tokenGetter func(r *http.Request) string
sessionGetter SessionReader
sessionWriter SessionWriter
}
// Option is a type that is used to set options for the CSRF middleware.
// It implements the functional options pattern.
type Option func(*options)
// WithTokenLength is a method that sets the token length for the CSRF middleware.
// The default value is 32.
func WithTokenLength(length int) Option {
return func(o *options) {
o.tokenLength = length
}
}
// WithErrorCallback is a method that sets the error callback function for the CSRF middleware.
// The error callback function is called when the CSRF token is invalid.
// The default behavior is to write a 403 Forbidden response.
func WithErrorCallback(fn func(w http.ResponseWriter, r *http.Request)) Option {
return func(o *options) {
o.errCallback = fn
o.errCallbackOverride = true
}
}
// WithTokenGetter is a method that sets the token getter function for the CSRF middleware.
// The token getter function is called to get the CSRF token from the request.
// The default behavior is to get the token from the "X-CSRF-Token" header.
func WithTokenGetter(fn func(r *http.Request) string) Option {
return func(o *options) {
o.tokenGetter = fn
o.tokenGetterOverride = true
}
}
// withSessionReader is a method that sets the session reader function for the CSRF middleware.
// The session reader function is called to get the CSRF token from the session.
func withSessionReader(fn SessionReader) Option {
return func(o *options) {
o.sessionGetter = fn
}
}
// withSessionWriter is a method that sets the session writer function for the CSRF middleware.
// The session writer function is called to write the CSRF token to the session.
func withSessionWriter(fn SessionWriter) Option {
return func(o *options) {
o.sessionWriter = fn
}
}
// newOptions is a function that returns a new options struct with sane default values.
func newOptions(opts ...Option) options {
o := options{
tokenLength: 32,
ignoreMethods: []string{"GET", "HEAD", "OPTIONS"},
errCallbackOverride: false,
errCallback: defaultErrorHandler,
tokenGetterOverride: false,
tokenGetter: defaultTokenGetter,
}
for _, opt := range opts {
opt(&o)
}
return o
}

View File

@@ -0,0 +1,75 @@
package csrf
import (
"net/http"
"testing"
)
func TestWithTokenLength(t *testing.T) {
o := newOptions(WithTokenLength(64))
if o.tokenLength != 64 {
t.Errorf("WithTokenLength() = %d, want %d", o.tokenLength, 64)
}
}
func TestWithErrorCallback(t *testing.T) {
callback := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}
o := newOptions(WithErrorCallback(callback))
if !o.errCallbackOverride {
t.Errorf("WithErrorCallback() did not set errCallbackOverride to true")
}
if o.errCallback == nil {
t.Errorf("WithErrorCallback() did not set errCallback")
}
}
func TestWithTokenGetter(t *testing.T) {
getter := func(r *http.Request) string {
return "test-token"
}
o := newOptions(WithTokenGetter(getter))
if !o.tokenGetterOverride {
t.Errorf("WithTokenGetter() did not set tokenGetterOverride to true")
}
if o.tokenGetter == nil {
t.Errorf("WithTokenGetter() did not set tokenGetter")
}
}
func TestWithSessionReader(t *testing.T) {
reader := func(r *http.Request) string {
return "session-token"
}
o := newOptions(withSessionReader(reader))
if o.sessionGetter == nil {
t.Errorf("withSessionReader() did not set sessionGetter")
}
}
func TestWithSessionWriter(t *testing.T) {
writer := func(r *http.Request, token string) {
// do nothing
}
o := newOptions(withSessionWriter(writer))
if o.sessionWriter == nil {
t.Errorf("withSessionWriter() did not set sessionWriter")
}
}
func TestNewOptionsDefaults(t *testing.T) {
o := newOptions()
if o.tokenLength != 32 {
t.Errorf("newOptions() default tokenLength = %d, want %d", o.tokenLength, 32)
}
if len(o.ignoreMethods) != 3 {
t.Errorf("newOptions() default ignoreMethods length = %d, want %d", len(o.ignoreMethods), 3)
}
if o.errCallback == nil {
t.Errorf("newOptions() default errCallback is nil")
}
if o.tokenGetter == nil {
t.Errorf("newOptions() default tokenGetter is nil")
}
}

View File

@@ -0,0 +1,90 @@
package csrf
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"slices"
)
// checkForPRNG is a function that checks if a cryptographically secure PRNG is available.
// If it is not available, the function panics.
func checkForPRNG() {
buf := make([]byte, 1)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
panic(fmt.Sprintf("crypto/rand is unavailable: Read() failed with %#v", err))
}
}
// generateToken is a function that generates a secure random CSRF token.
func generateToken(length int) []byte {
bytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
panic(err)
}
return bytes
}
// encodeToken is a function that encodes a token to a base64 string.
func encodeToken(token []byte) string {
return base64.URLEncoding.EncodeToString(token)
}
// decodeToken is a function that decodes a base64 string to a token.
func decodeToken(token string) ([]byte, error) {
return base64.URLEncoding.DecodeString(token)
}
// maskToken is a function that masks a token with a given key.
// The returned byte slice contains the key + the masked token.
// The key needs to have the same length as the token, otherwise the function panics.
// So the resulting slice has a length of len(token) * 2.
func maskToken(token, key []byte) []byte {
if len(token) != len(key) {
panic("token and key must have the same length")
}
// masked contains the key in the first half and the XOR masked token in the second half
tokenLength := len(token)
masked := make([]byte, tokenLength*2)
for i := 0; i < len(token); i++ {
masked[i] = key[i]
masked[i+tokenLength] = token[i] ^ key[i] // XOR mask
}
return masked
}
// unmaskToken is a function that unmask a token which contains the key in the first half.
// The returned byte slice contains the unmasked token, it has exactly half the length of the input slice.
func unmaskToken(masked []byte) []byte {
tokenLength := len(masked) / 2
token := make([]byte, tokenLength)
for i := 0; i < tokenLength; i++ {
token[i] = masked[i] ^ masked[i+tokenLength] // XOR unmask
}
return token
}
// tokenEqual is a function that compares two tokens for equality.
func tokenEqual(a, b string) bool {
decodedA, err := decodeToken(a)
if err != nil {
return false
}
decodedB, err := decodeToken(b)
if err != nil {
return false
}
unmaskedA := unmaskToken(decodedA)
unmaskedB := unmaskToken(decodedB)
return slices.Equal(unmaskedA, unmaskedB)
}

View File

@@ -0,0 +1,81 @@
package csrf
import (
"encoding/base64"
"testing"
)
func TestCheckForPRNG(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("checkForPRNG() panicked: %v", r)
}
}()
checkForPRNG()
}
func TestGenerateToken(t *testing.T) {
length := 32
token := generateToken(length)
if len(token) != length {
t.Errorf("generateToken() returned token of length %d, expected %d", len(token), length)
}
}
func TestEncodeToken(t *testing.T) {
token := []byte("testtoken")
encoded := encodeToken(token)
expected := base64.URLEncoding.EncodeToString(token)
if encoded != expected {
t.Errorf("encodeToken() = %v, want %v", encoded, expected)
}
}
func TestDecodeToken(t *testing.T) {
token := "dGVzdHRva2Vu"
expected := []byte("testtoken")
decoded, err := decodeToken(token)
if err != nil {
t.Errorf("decodeToken() error = %v", err)
}
if string(decoded) != string(expected) {
t.Errorf("decodeToken() = %v, want %v", decoded, expected)
}
}
func TestMaskToken(t *testing.T) {
token := []byte("testtoken")
key := []byte("keykeykey")
masked := maskToken(token, key)
if len(masked) != len(token)*2 {
t.Errorf("maskToken() returned masked token of length %d, expected %d", len(masked), len(token)*2)
}
}
func TestUnmaskToken(t *testing.T) {
token := []byte("testtoken")
key := []byte("keykeykey")
masked := maskToken(token, key)
unmasked := unmaskToken(masked)
if string(unmasked) != string(token) {
t.Errorf("unmaskToken() = %v, want %v", unmasked, token)
}
}
func TestTokenEqual(t *testing.T) {
tokenA := encodeToken(maskToken([]byte{0x01, 0x02, 0x03}, []byte{0x01, 0x02, 0x03}))
tokenB := encodeToken(maskToken([]byte{0x01, 0x02, 0x03}, []byte{0x04, 0x05, 0x06}))
if !tokenEqual(tokenA, tokenB) {
t.Errorf("tokenEqual() = false, want true")
}
tokenC := encodeToken(maskToken([]byte{0x01, 0x02, 0x03}, []byte{0x07, 0x08, 0x09}))
if !tokenEqual(tokenA, tokenC) {
t.Errorf("tokenEqual() = false, want true")
}
tokenD := encodeToken(maskToken([]byte{0x09, 0x02, 0x03}, []byte{0x04, 0x05, 0x06}))
if tokenEqual(tokenA, tokenD) {
t.Errorf("tokenEqual() = true, want false")
}
}

View File

@@ -0,0 +1,199 @@
package logging
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
)
// LogLevel is an enumeration of the different log levels.
type LogLevel int
const (
LogLevelDebug LogLevel = iota
LogLevelInfo
LogLevelWarn
LogLevelError
)
// Logger is an interface that defines the methods that a logger must implement.
// This allows the logging middleware to be used with different logging libraries.
type Logger interface {
// Debugf logs a message at debug level.
Debugf(format string, args ...any)
// Infof logs a message at info level.
Infof(format string, args ...any)
// Warnf logs a message at warn level.
Warnf(format string, args ...any)
// Errorf logs a message at error level.
Errorf(format string, args ...any)
}
// Middleware is a type that creates a new logging middleware. The logging middleware
// logs information about each request.
type Middleware struct {
o options
}
// New returns a new logging middleware with the provided options.
func New(opts ...Option) *Middleware {
o := newOptions(opts...)
m := &Middleware{
o: o,
}
return m
}
// Handler returns the logging middleware handler.
func (m *Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := newWriterWrapper(w)
start := time.Now()
defer func() {
info := m.extractInfoMap(r, start, ww)
if m.o.logger == nil {
msg, args := m.buildSlogMessageAndArguments(info)
m.logMsg(msg, args...)
} else {
msg := m.buildNormalLogMessage(info)
m.logMsg(msg)
}
}()
next.ServeHTTP(ww, r)
})
}
func (m *Middleware) extractInfoMap(r *http.Request, start time.Time, ww *writerWrapper) map[string]any {
info := make(map[string]any)
info["method"] = r.Method
info["path"] = r.URL.Path
info["protocol"] = r.Proto
info["clientIP"] = r.Header.Get("X-Forwarded-For")
if info["clientIP"] == "" {
// If the X-Forwarded-For header is not set, use the remote address without the port number.
lastColonIndex := strings.LastIndex(r.RemoteAddr, ":")
switch lastColonIndex {
case -1:
info["clientIP"] = r.RemoteAddr
default:
info["clientIP"] = r.RemoteAddr[:lastColonIndex]
}
}
info["userAgent"] = r.UserAgent()
info["referer"] = r.Header.Get("Referer")
info["duration"] = time.Since(start).String()
info["status"] = ww.StatusCode
info["dataLength"] = ww.WrittenBytes
if m.o.headerRequestIdKey != "" {
info["headerRequestId"] = r.Header.Get(m.o.headerRequestIdKey)
}
if m.o.contextRequestIdKey != "" {
info["contextRequestId"], _ = r.Context().Value(m.o.contextRequestIdKey).(string)
}
return info
}
func (m *Middleware) buildNormalLogMessage(info map[string]any) string {
switch {
case info["headerRequestId"] != nil && info["contextRequestId"] != nil:
return fmt.Sprintf("%s %s %s - %d %d - %s - %s %s %s - rid=%s ctx=%s",
info["method"], info["path"], info["protocol"],
info["status"], info["dataLength"],
info["duration"],
info["clientIP"], info["userAgent"], info["referer"],
info["headerRequestId"], info["contextRequestId"])
case info["headerRequestId"] != nil:
return fmt.Sprintf("%s %s %s - %d %d - %s - %s %s %s - rid=%s",
info["method"], info["path"], info["protocol"],
info["status"], info["dataLength"],
info["duration"],
info["clientIP"], info["userAgent"], info["referer"],
info["headerRequestId"])
case info["contextRequestId"] != nil:
return fmt.Sprintf("%s %s %s - %d %d - %s - %s %s %s - ctx=%s",
info["method"], info["path"], info["protocol"],
info["status"], info["dataLength"],
info["duration"],
info["clientIP"], info["userAgent"], info["referer"],
info["contextRequestId"])
default:
return fmt.Sprintf("%s %s %s - %d %d - %s - %s %s %s",
info["method"], info["path"], info["protocol"],
info["status"], info["dataLength"],
info["duration"],
info["clientIP"], info["userAgent"], info["referer"])
}
}
func (m *Middleware) buildSlogMessageAndArguments(info map[string]any) (message string, args []any) {
message = fmt.Sprintf("%s %s", info["method"], info["path"])
// Use a fixed order for the keys, so that the message is always the same.
// Skip method and path as they are already in the message.
keys := []string{
"protocol",
"status",
"dataLength",
"duration",
"clientIP",
"userAgent",
"referer",
"headerRequestId",
"contextRequestId",
}
for _, k := range keys {
if v, ok := info[k]; ok {
args = append(args, k, v) // only add key, value if it exists
}
}
return
}
func (m *Middleware) addPrefix(message string) string {
if m.o.prefix != "" {
return m.o.prefix + " " + message
}
return message
}
func (m *Middleware) logMsg(message string, args ...any) {
message = m.addPrefix(message)
if m.o.logger != nil {
switch m.o.logLevel {
case LogLevelDebug:
m.o.logger.Debugf(message, args...)
case LogLevelInfo:
m.o.logger.Infof(message, args...)
case LogLevelWarn:
m.o.logger.Warnf(message, args...)
case LogLevelError:
m.o.logger.Errorf(message, args...)
default:
m.o.logger.Infof(message, args...)
}
} else {
switch m.o.logLevel {
case LogLevelDebug:
slog.Debug(message, args...)
case LogLevelInfo:
slog.Info(message, args...)
case LogLevelWarn:
slog.Warn(message, args...)
case LogLevelError:
slog.Error(message, args...)
default:
slog.Info(message, args...)
}
}
}

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