From 72f912359230a5f0d7769e9f2f36bbbf6e218d53 Mon Sep 17 00:00:00 2001 From: Mykhailo Roit <88772563+Mykhailo-Roit@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:01:07 +0300 Subject: [PATCH 01/23] Add test-in-docker target to Makefile (#659) * Add test-in-docker target to Makefile Add a target to run tests in Docker for non-Linux environments. * Add GOVERSION variable to Makefile * fix: update test-in-docker command to use user permissions * Fix docker command syntax in Makefile --- Makefile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2bf202b..3989d45 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ # Go parameters GOCMD=go +GOVERSION=1.25 MODULENAME=github.com/h44z/wg-portal -GOFILES:=$(shell go list ./... | grep -v /vendor/) +GOFILES=$(shell go list ./... | grep -v /vendor/) BUILDDIR=dist BINARIES=$(subst cmd/,,$(wildcard cmd/*)) IMAGE=h44z/wg-portal @@ -51,6 +52,11 @@ format: .PHONY: test test: test-vet test-race +#> test-in-docker: Run tests in Docker (for non-Linux environments e.g. MacOS) +.PHONY: test-in-docker +test-in-docker: + docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -v $(PWD):/app -w /app golang:$(GOVERSION) make test + #< test-vet: Static code analysis .PHONY: test-vet test-vet: build-dependencies From 401642701a000f4d4fafba79dd3e9bc29ce0b2ce Mon Sep 17 00:00:00 2001 From: h44z Date: Tue, 7 Apr 2026 22:17:53 +0200 Subject: [PATCH 02/23] feat: improve pagination (#662) (#663) --- frontend/src/components/Pagination.vue | 121 +++++++++++++++++++++++++ frontend/src/stores/audit.js | 24 ++--- frontend/src/stores/peers.js | 24 ++--- frontend/src/stores/profile.js | 23 ++--- frontend/src/stores/users.js | 24 ++--- frontend/src/views/AuditView.vue | 34 +++---- frontend/src/views/InterfaceView.vue | 42 ++++----- frontend/src/views/ProfileView.vue | 41 ++++----- frontend/src/views/UserView.vue | 38 ++++---- 9 files changed, 216 insertions(+), 155 deletions(-) create mode 100644 frontend/src/components/Pagination.vue diff --git a/frontend/src/components/Pagination.vue b/frontend/src/components/Pagination.vue new file mode 100644 index 0000000..46d9213 --- /dev/null +++ b/frontend/src/components/Pagination.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/frontend/src/stores/audit.js b/frontend/src/stores/audit.js index 771df90..7f80762 100644 --- a/frontend/src/stores/audit.js +++ b/frontend/src/stores/audit.js @@ -11,7 +11,6 @@ export const auditStore = defineStore('audit', { filter: "", pageSize: 10, pageOffset: 0, - pages: [], fetching: false, }), getters: { @@ -41,33 +40,22 @@ export const auditStore = defineStore('audit', { 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() + if (this.hasNextPage) { + this.pageOffset += this.pageSize + } }, previousPage() { - this.pageOffset -= this.pageSize - - this.calculatePages() + if (this.hasPrevPage) { + this.pageOffset -= this.pageSize + } }, setEntries(entries) { this.entries = entries - this.calculatePages() this.fetching = false }, async LoadEntries() { diff --git a/frontend/src/stores/peers.js b/frontend/src/stores/peers.js index 8e80b2c..f464cdd 100644 --- a/frontend/src/stores/peers.js +++ b/frontend/src/stores/peers.js @@ -19,7 +19,6 @@ export const peerStore = defineStore('peers', { filter: "", pageSize: 10, pageOffset: 0, - pages: [], fetching: false, sortKey: 'IsConnected', // Default sort key sortOrder: -1, // 1 for ascending, -1 for descending @@ -87,33 +86,22 @@ export const peerStore = defineStore('peers', { 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() + if (this.hasNextPage) { + this.pageOffset += this.pageSize + } }, previousPage() { - this.pageOffset -= this.pageSize - - this.calculatePages() + if (this.hasPrevPage) { + this.pageOffset -= this.pageSize + } }, setPeers(peers) { this.peers = peers - this.calculatePages() this.fetching = false this.trafficStats = {} }, diff --git a/frontend/src/stores/profile.js b/frontend/src/stores/profile.js index 632e931..268e4db 100644 --- a/frontend/src/stores/profile.js +++ b/frontend/src/stores/profile.js @@ -20,7 +20,6 @@ export const profileStore = defineStore('profile', { filter: "", pageSize: 10, pageOffset: 0, - pages: [], fetching: false, sortKey: 'IsConnected', // Default sort key sortOrder: -1, // 1 for ascending, -1 for descending @@ -80,29 +79,19 @@ export const profileStore = defineStore('profile', { 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.FilteredPeerCount; i+=this.pageSize) { - this.pages.push(pageCounter++) - } }, gotoPage(page) { this.pageOffset = (page-1) * this.pageSize - - this.calculatePages() }, nextPage() { - this.pageOffset += this.pageSize - - this.calculatePages() + if (this.hasNextPage) { + this.pageOffset += this.pageSize + } }, previousPage() { - this.pageOffset -= this.pageSize - - this.calculatePages() + if (this.hasPrevPage) { + this.pageOffset -= this.pageSize + } }, setPeers(peers) { this.peers = peers diff --git a/frontend/src/stores/users.js b/frontend/src/stores/users.js index 8eb194a..19816ca 100644 --- a/frontend/src/stores/users.js +++ b/frontend/src/stores/users.js @@ -12,7 +12,6 @@ export const userStore = defineStore('users', { filter: "", pageSize: 10, pageOffset: 0, - pages: [], fetching: false, }), getters: { @@ -43,33 +42,22 @@ export const userStore = defineStore('users', { 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() + if (this.hasNextPage) { + this.pageOffset += this.pageSize + } }, previousPage() { - this.pageOffset -= this.pageSize - - this.calculatePages() + if (this.hasPrevPage) { + this.pageOffset -= this.pageSize + } }, setUsers(users) { this.users = users - this.calculatePages() this.fetching = false }, setUserPeers(peers) { diff --git a/frontend/src/views/AuditView.vue b/frontend/src/views/AuditView.vue index ccd78f7..7a47d26 100644 --- a/frontend/src/views/AuditView.vue +++ b/frontend/src/views/AuditView.vue @@ -1,6 +1,7 @@ @@ -185,36 +185,33 @@ onMounted(async () => {
-
- +
+
-
+
-
-
diff --git a/frontend/src/views/UserView.vue b/frontend/src/views/UserView.vue index 42e452b..777a999 100644 --- a/frontend/src/views/UserView.vue +++ b/frontend/src/views/UserView.vue @@ -1,8 +1,9 @@ " + want := "" + got := SanitizeString(input, 256) + if got != want { + t.Errorf("SanitizeString(%q, 256) = %q; want %q", input, got, want) + } +} + +// --------------------------------------------------------------------------- +// Property 1: SanitizeString output invariants +// --------------------------------------------------------------------------- + +// Feature: external-identity-sanitization, Property 1: SanitizeString output is free of control characters and bounded in length +func TestPropertySanitizeStringOutputInvariants(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + s := rapid.String().Draw(t, "s") + maxLen := rapid.IntRange(0, 512).Draw(t, "maxLen") + result := SanitizeString(s, maxLen) + + // No control or format runes in result + for _, r := range result { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + t.Fatalf("result %q contains unsafe character %U", result, r) + } + } + + if !utf8.ValidString(result) { + t.Fatalf("result %q is not valid UTF-8", result) + } + + // No leading or trailing whitespace + if result != strings.TrimSpace(result) { + t.Fatalf("result %q has leading or trailing whitespace", result) + } + + // Rune count <= maxLen + runeCount := utf8.RuneCountInString(result) + if runeCount > maxLen { + t.Fatalf("result %q has %d runes, exceeds maxLen %d", result, runeCount, maxLen) + } + }) +} + +// --------------------------------------------------------------------------- +// Property 2: SanitizeString is idempotent +// --------------------------------------------------------------------------- + +// Feature: external-identity-sanitization, Property 2: SanitizeString is idempotent +func TestPropertySanitizeStringIdempotent(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + s := rapid.String().Draw(t, "s") + maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen") + + once := SanitizeString(s, maxLen) + twice := SanitizeString(once, maxLen) + + if once != twice { + t.Fatalf("SanitizeString is not idempotent: once=%q, twice=%q (input=%q, maxLen=%d)", + once, twice, s, maxLen) + } + }) +} + +// --------------------------------------------------------------------------- +// Property 3: SanitizeEmail rejection rules +// --------------------------------------------------------------------------- + +// Feature: external-identity-sanitization, Property 3: SanitizeEmail rejects strings without "@" or containing CR/LF +func TestPropertySanitizeEmailRejectionRules(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + s := rapid.String().Draw(t, "s") + maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen") + result := SanitizeEmail(s, maxLen) + + sanitized := SanitizeString(s, maxLen) + addr, parseErr := mail.ParseAddress(sanitized) + reject := strings.ContainsAny(s, "\r\n") || + sanitized == "" || + strings.Count(sanitized, "@") != 1 || + parseErr != nil || + addr.Name != "" || + addr.Address != sanitized + if reject { + if result != "" { + t.Fatalf("SanitizeEmail(%q, %d) = %q; expected empty string (contains CR/LF or no @)", + s, maxLen, result) + } + } + }) +} + +// --------------------------------------------------------------------------- +// Property 4: SanitizePhone allowed character set +// --------------------------------------------------------------------------- + +// isAllowedPhoneCharTest mirrors the internal isAllowedPhoneRune logic for test assertions. +func isAllowedPhoneCharTest(r rune) bool { + switch { + case r >= '0' && r <= '9': + return true + case r == '+', r == '-', r == '(', r == ')', r == ' ', r == '.': + return true + default: + return false + } +} + +// Feature: external-identity-sanitization, Property 4: SanitizePhone output contains only allowed characters +func TestPropertySanitizePhoneAllowedChars(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + s := rapid.String().Draw(t, "s") + maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen") + result := SanitizePhone(s, maxLen) + + for _, r := range result { + if !isAllowedPhoneCharTest(r) { + t.Fatalf("SanitizePhone(%q, %d) = %q; contains disallowed rune %U (%c)", + s, maxLen, result, r, r) + } + } + }) +} + +// --------------------------------------------------------------------------- +// Property 5: SanitizeIdentifier rejects reserved identifiers +// --------------------------------------------------------------------------- + +// Feature: external-identity-sanitization, Property 5: SanitizeIdentifier rejects reserved values +func TestPropertySanitizeIdentifierRejectsReservedValues(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + s := rapid.String().Draw(t, "s") + maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen") + result := SanitizeIdentifier(s, maxLen) + sanitized := SanitizeString(s, maxLen) + + _, reserved := reservedUserIdentifiers[sanitized] + if reserved { + if result != "" { + t.Fatalf("SanitizeIdentifier(%q, %d) = %q; expected empty string when sanitized is reserved", + s, maxLen, result) + } + } else { + if result != sanitized { + t.Fatalf("SanitizeIdentifier(%q, %d) = %q; expected %q (== SanitizeString result)", + s, maxLen, result, sanitized) + } + } + }) +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 39fc982..4eed5f2 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -282,6 +282,32 @@ func (u *User) CreateDefaultPeers() bool { return true } +// SanitizeExternalData sanitizes user profile fields received from an external identity provider. +// Returns ErrInvalidData if the identifier becomes empty after sanitization. +func (u *User) SanitizeExternalData(providerType, providerName string) error { + identifier := string(u.Identifier) + LogSanitizeChange(providerType, providerName, "identifier", identifier, + func() string { return SanitizeIdentifier(identifier, 256) }, &identifier) + u.Identifier = UserIdentifier(identifier) + + LogSanitizeChange(providerType, providerName, "email", u.Email, + func() string { return SanitizeEmail(u.Email, 254) }, &u.Email) + LogSanitizeChange(providerType, providerName, "firstname", u.Firstname, + func() string { return SanitizeString(u.Firstname, 128) }, &u.Firstname) + LogSanitizeChange(providerType, providerName, "lastname", u.Lastname, + func() string { return SanitizeString(u.Lastname, 128) }, &u.Lastname) + LogSanitizeChange(providerType, providerName, "phone", u.Phone, + func() string { return SanitizePhone(u.Phone, 50) }, &u.Phone) + LogSanitizeChange(providerType, providerName, "department", u.Department, + func() string { return SanitizeString(u.Department, 128) }, &u.Department) + + if u.Identifier == "" { + return fmt.Errorf("empty user identifier: %w", ErrInvalidData) + } + + return nil +} + // region webauthn func (u *User) WebAuthnID() []byte { diff --git a/internal/domain/user_sanitize_test.go b/internal/domain/user_sanitize_test.go new file mode 100644 index 0000000..0d5f8a5 --- /dev/null +++ b/internal/domain/user_sanitize_test.go @@ -0,0 +1,64 @@ +package domain + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/h44z/wg-portal/internal/testutil" +) + +func TestUser_SanitizeExternalData_NullByteInFirstname(t *testing.T) { + u := &User{ + Identifier: "alice", + Email: "alice@example.com", + Firstname: "Ali\x00ce", + Lastname: "Smith", + } + + restore := testutil.CaptureWarnLogs(t) + err := u.SanitizeExternalData("ldap", "test-provider") + records := restore() + + require.NoError(t, err) + assert.Equal(t, "Alice", u.Firstname) + assert.Equal(t, 1, testutil.CountWarnEntries(records)) + + _, found := testutil.FindWarnWithField(records, "firstname") + assert.True(t, found) +} + +func TestUser_SanitizeExternalData_IdentifierAll(t *testing.T) { + u := &User{ + Identifier: "all", + Email: "all@example.com", + Firstname: "Alice", + Lastname: "Smith", + } + + err := u.SanitizeExternalData("ldap", "test-provider") + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidData)) +} + +func TestUser_SanitizeExternalData_AllFieldsClean(t *testing.T) { + u := &User{ + Identifier: "alice", + Email: "alice@example.com", + Firstname: "Alice", + Lastname: "Smith", + Phone: "+1 555-1234", + Department: "Engineering", + } + + restore := testutil.CaptureWarnLogs(t) + err := u.SanitizeExternalData("ldap", "test-provider") + records := restore() + + require.NoError(t, err) + assert.Equal(t, UserIdentifier("alice"), u.Identifier) + assert.Equal(t, 0, testutil.CountWarnEntries(records)) +} diff --git a/internal/sanitize/log.go b/internal/sanitize/log.go new file mode 100644 index 0000000..51e4d9b --- /dev/null +++ b/internal/sanitize/log.go @@ -0,0 +1,32 @@ +package sanitize + +import ( + "log/slog" + + "github.com/h44z/wg-portal/internal/domain" +) + +// LogChange applies sanitizeFn to raw, logs when the value changes, and writes +// the sanitized value to dest. Raw and sanitized values are intentionally omitted. +func LogChange( + providerType string, + providerName string, + field string, + raw string, + sanitizeFn func() string, + dest *string, +) { + sanitized := sanitizeFn() + if sanitized != raw { + message := "sanitization modified field value from external provider" + if sanitized == "" { + message = "sanitization cleared field value from external provider" + } + slog.Warn(message, + "provider_type", domain.SanitizeString(providerType, 64), + "provider", domain.SanitizeString(providerName, 128), + "field", domain.SanitizeString(field, 64), + ) + } + *dest = sanitized +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..c34c37a --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,50 @@ +package testutil + +import ( + "bytes" + "encoding/json" + "log/slog" + "testing" +) + +func CaptureWarnLogs(t *testing.T) (restore func() []map[string]any) { + t.Helper() + original := slog.Default() + var buf bytes.Buffer + handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + slog.SetDefault(slog.New(handler)) + + return func() []map[string]any { + slog.SetDefault(original) + var records []map[string]any + decoder := json.NewDecoder(&buf) + for decoder.More() { + var rec map[string]any + if err := decoder.Decode(&rec); err == nil { + records = append(records, rec) + } + } + return records + } +} + +func CountWarnEntries(records []map[string]any) int { + count := 0 + for _, r := range records { + if lvl, ok := r["level"].(string); ok && lvl == "WARN" { + count++ + } + } + return count +} + +func FindWarnWithField(records []map[string]any, fieldName string) (map[string]any, bool) { + for _, r := range records { + if lvl, ok := r["level"].(string); ok && lvl == "WARN" { + if f, ok := r["field"].(string); ok && f == fieldName { + return r, true + } + } + } + return nil, false +} From 835f76bf58358973b3e53e9cc9459631102ee7ae Mon Sep 17 00:00:00 2001 From: nesbyte <63811145+nesbyte@users.noreply.github.com> Date: Mon, 18 May 2026 23:31:46 +0300 Subject: [PATCH 10/23] feat(docs): how to troubleshoot admin_group_regex with oidc (#684) Added instructions for identifying claims in OIDC user info payload for admin rights. --- docs/documentation/configuration/overview.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index af3c632..5157f9e 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -602,6 +602,7 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`: - **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`. - `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. + - To identify which claim to match against, set log_level: debug and reload the config. Log in with the intended admin account and inspect the logs for the OIDC user info payload. If the required claim is missing it must be added by the OIDC provider. If it is present, use its value as the pattern for admin_group_regex. #### `registration_enabled` - **Default:** `false` From c2b4a5d03c50abfec0afdc443bdcfe2ad6e09b7d Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 18 May 2026 22:33:42 +0200 Subject: [PATCH 11/23] chore: update Go dependencies --- go.mod | 36 ++++++++++++++++++------------------ go.sum | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index c88340c..f44a5db 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-pkgz/routegroup v1.6.0 github.com/go-playground/validator/v10 v10.30.2 - github.com/go-webauthn/webauthn v0.16.4 + github.com/go-webauthn/webauthn v0.17.3 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/prometheus-community/pro-bing v0.8.0 @@ -22,9 +22,10 @@ require ( github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/writer/compressed v1.0.1 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.51.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sys v0.43.0 + golang.org/x/sys v0.44.0 + golang.org/x/text v0.37.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 @@ -36,18 +37,18 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-openapi/jsonpointer v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/spec v0.22.4 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect @@ -59,10 +60,10 @@ require ( github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-webauthn/x v0.2.3 // indirect + github.com/go-webauthn/x v0.2.5 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect @@ -70,16 +71,16 @@ require ( github.com/google/go-tpm v0.9.8 // 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.9.1 // indirect + github.com/jackc/pgx/v5 v5.9.2 // 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/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mdlayher/genetlink v1.4.0 // indirect - github.com/mdlayher/netlink v1.11.0 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect github.com/mdlayher/socket v0.6.0 // indirect - github.com/microsoft/go-mssqldb v1.9.8 // indirect + github.com/microsoft/go-mssqldb v1.10.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -96,16 +97,15 @@ require ( github.com/yeqown/reedsolomon v1.0.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect google.golang.org/protobuf v1.36.11 // indirect - modernc.org/libc v1.72.0 // indirect + modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.48.2 // indirect + modernc.org/sqlite v1.50.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 808d6ee..e31e2a5 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= @@ -14,6 +15,7 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7 github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= @@ -22,6 +24,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= @@ -50,6 +54,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= @@ -64,6 +70,8 @@ github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baD github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-openapi/jsonpointer v0.23.0 h1:c25HFTJ6uWGmoe5BQI6p72p4o7KnlWYsy1MeFlAumsw= github.com/go-openapi/jsonpointer v0.23.0/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= @@ -101,14 +109,20 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q= github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4= +github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo= +github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ= github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= +github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY= +github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -145,6 +159,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -178,15 +194,21 @@ 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/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= github.com/mdlayher/netlink v1.11.0 h1:Cot7ixQZL6P/pxRFB4z3jRdGPYeZosFT+WHS3sMXy8Y= github.com/mdlayher/netlink v1.11.0/go.mod h1:rMwDzh42W85uW3yTtiTRZFX9uway98aDQ5i+D8Jq4g4= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM= github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus= +github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= +github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= @@ -280,6 +302,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -288,6 +312,8 @@ 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -307,6 +333,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -340,6 +368,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -370,6 +400,8 @@ golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -378,6 +410,8 @@ 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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= @@ -406,8 +440,10 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -418,16 +454,21 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 0cf04d07e0f4355281dff5f6e7560eee7a95fee3 Mon Sep 17 00:00:00 2001 From: Dan Berg <61684965+wg-daniel@users.noreply.github.com> Date: Tue, 19 May 2026 21:52:54 +0200 Subject: [PATCH 12/23] fix vue and oauth redirects under web base path (#683) --- frontend/src/router/index.js | 4 +- frontend/src/views/LoginView.vue | 4 +- .../v0/handlers/endpoint_authentication.go | 123 +++++++++++------- .../endpoint_authentication_basepath_test.go | 114 ++++++++++++++++ 4 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 07cb5c5..6b5728b 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -6,8 +6,10 @@ import {authStore} from '@/stores/auth' import {securityStore} from '@/stores/security' import {notify} from "@kyvg/vue3-notification"; +const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}` + const router = createRouter({ - history: createWebHashHistory(), + history: createWebHashHistory(routerBase), routes: [ { path: '/', diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 9003b83..3ac6dad 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -83,7 +83,9 @@ const externalLogin = function (provider) { console.log("Performing external login for provider", provider.Identifier); loggingIn.value = true; console.log(router.currentRoute.value); - let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath; + const currentUrl = new URL(`${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`, window.location.origin); + currentUrl.hash = router.currentRoute.value.fullPath; + let currentUri = currentUrl.toString(); let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`; redirectUrl += "?redirect=true"; redirectUrl += "&return=" + encodeURIComponent(currentUri); diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index 2e019e4..7e9aedd 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "github.com/go-pkgz/routegroup" @@ -201,9 +202,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { provider := request.Path(r, "provider") var returnUrl *url.URL - var returnParams string - redirectToReturn := func() { - respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams) + redirectToReturn := func(loginState string) { + respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState)) } if returnTo != "" { @@ -212,21 +212,18 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"}) return } - if u, err := url.Parse(returnTo); err == nil { - returnUrl = u + u, err := url.Parse(returnTo) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"}) + return } - queryParams := returnUrl.Query() - queryParams.Set("wgLoginState", "err") // by default, we set the state to error - returnUrl.RawQuery = "" // remove potential query params - returnParams = queryParams.Encode() + returnUrl = u } if currentSession.LoggedIn { - if autoRedirect && e.isValidReturnUrl(returnTo) { - queryParams := returnUrl.Query() - queryParams.Set("wgLoginState", "success") - returnParams = queryParams.Encode() - redirectToReturn() + if autoRedirect && returnUrl != nil { + redirectToReturn("success") } else { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "already logged in"}) @@ -238,8 +235,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { if err != nil { slog.Debug("failed to create oauth auth code URL", "provider", provider, "error", err) - if autoRedirect && e.isValidReturnUrl(returnTo) { - redirectToReturn() + if autoRedirect && returnUrl != nil { + redirectToReturn("err") } else { respond.JSON(w, http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) @@ -278,27 +275,19 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { currentSession := e.session.GetData(r.Context()) var returnUrl *url.URL - var returnParams string - redirectToReturn := func() { - respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams) + redirectToReturn := func(loginState string) { + respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState)) } - if currentSession.OauthReturnTo != "" { + if currentSession.OauthReturnTo != "" && e.isValidReturnUrl(currentSession.OauthReturnTo) { if u, err := url.Parse(currentSession.OauthReturnTo); err == nil { returnUrl = u } - queryParams := returnUrl.Query() - queryParams.Set("wgLoginState", "err") // by default, we set the state to error - returnUrl.RawQuery = "" // remove potential query params - returnParams = queryParams.Encode() } if currentSession.LoggedIn { - if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { - queryParams := returnUrl.Query() - queryParams.Set("wgLoginState", "success") - returnParams = queryParams.Encode() - redirectToReturn() + if returnUrl != nil { + redirectToReturn("success") } else { respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"}) } @@ -312,8 +301,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { if provider != currentSession.OauthProvider { slog.Debug("invalid oauth provider in callback", "expected", currentSession.OauthProvider, "got", provider, "state", oauthState) - if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { - redirectToReturn() + if returnUrl != nil { + redirectToReturn("err") } else { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"}) @@ -323,8 +312,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { if oauthState != currentSession.OauthState { slog.Debug("invalid oauth state in callback", "expected", currentSession.OauthState, "got", oauthState, "provider", provider) - if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { - redirectToReturn() + if returnUrl != nil { + redirectToReturn("err") } else { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"}) @@ -339,8 +328,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { if err != nil { slog.Debug("failed to process oauth code", "provider", provider, "state", oauthState, "error", err) - if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { - redirectToReturn() + if returnUrl != nil { + redirectToReturn("err") } else { respond.JSON(w, http.StatusUnauthorized, model.Error{Code: http.StatusUnauthorized, Message: err.Error()}) @@ -350,11 +339,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { e.setAuthenticatedUser(r, user, provider, idTokenHint) - if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { - queryParams := returnUrl.Query() - queryParams.Set("wgLoginState", "success") - returnParams = queryParams.Encode() - redirectToReturn() + if returnUrl != nil { + redirectToReturn("success") } else { respond.JSON(w, http.StatusOK, user) } @@ -444,11 +430,7 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc { return } - postLogoutRedirectUri := e.cfg.Web.ExternalUrl - if e.cfg.Web.BasePath != "" { - postLogoutRedirectUri += e.cfg.Web.BasePath - } - postLogoutRedirectUri += "/#/login" + postLogoutRedirectUri := e.frontendUrl("/login") var redirectUrl *string if currentSession.OauthProvider != "" { @@ -479,9 +461,60 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool { return false } + if e.cfg.Web.BasePath != "" { + expectedPath := e.cfg.Web.BasePath + "/app" + if returnUrlParsed.Path != expectedPath && !strings.HasPrefix(returnUrlParsed.Path, expectedPath+"/") { + return false + } + } + return true } +func (e AuthEndpoint) frontendUrl(route string) string { + frontendUrl := e.cfg.Web.ExternalUrl + e.cfg.Web.BasePath + "/app/" + if route != "" { + frontendUrl += "#" + route + } + return frontendUrl +} + +func (e AuthEndpoint) returnUrlWithLoginState(returnUrl *url.URL, loginState string) string { + if returnUrl == nil { + frontendURL, err := url.Parse(e.frontendUrl("/login")) + if err != nil { + return e.frontendUrl("/login") + } + returnUrl = frontendURL + } + + redirectUrl := *returnUrl + + if redirectUrl.Fragment != "" { + fragmentPath := redirectUrl.Fragment + fragmentQuery := "" + if queryStart := strings.Index(fragmentPath, "?"); queryStart >= 0 { + fragmentQuery = fragmentPath[queryStart+1:] + fragmentPath = fragmentPath[:queryStart] + } + + queryParams, err := url.ParseQuery(fragmentQuery) + if err != nil { + queryParams = url.Values{} + } + queryParams.Set("wgLoginState", loginState) + redirectUrl.Fragment = fragmentPath + "?" + queryParams.Encode() + + return redirectUrl.String() + } + + queryParams := redirectUrl.Query() + queryParams.Set("wgLoginState", loginState) + redirectUrl.RawQuery = queryParams.Encode() + + return redirectUrl.String() +} + // handleWebAuthnCredentialsGet returns a gorm Handler function. // // @ID auth_handleWebAuthnCredentialsGet diff --git a/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go b/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go new file mode 100644 index 0000000..872dc83 --- /dev/null +++ b/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/h44z/wg-portal/internal/config" +) + +type testSession struct { + data SessionData +} + +func (s *testSession) SetData(_ context.Context, val SessionData) { + s.data = val +} + +func (s *testSession) GetData(_ context.Context) SessionData { + return s.data +} + +func (s *testSession) DestroyData(_ context.Context) { + s.data = SessionData{} +} + +func newBasePathAuthEndpoint(session Session) AuthEndpoint { + return AuthEndpoint{ + cfg: &config.Config{ + Web: config.WebConfig{ + ExternalUrl: "https://wg.example.com", + BasePath: "/subpath", + }, + }, + session: session, + } +} + +func TestAuthEndpointIsValidReturnUrlRequiresBasePathApp(t *testing.T) { + ep := newBasePathAuthEndpoint(&testSession{}) + + valid := []string{ + "https://wg.example.com/subpath/app/#/login", + "https://wg.example.com/subpath/app/#/login?all=true", + "https://wg.example.com/subpath/app/?beforeHash=true#/login", + } + for _, returnURL := range valid { + if !ep.isValidReturnUrl(returnURL) { + t.Fatalf("expected return URL to be valid: %s", returnURL) + } + } + + invalid := []string{ + "https://wg.example.com/#/login", + "https://wg.example.com/subpath/#/login", + "https://other.example.com/subpath/app/#/login", + } + for _, returnURL := range invalid { + if ep.isValidReturnUrl(returnURL) { + t.Fatalf("expected return URL to be invalid: %s", returnURL) + } + } +} + +func TestAuthEndpointOauthCallbackRedirectsToBasePathHashRoute(t *testing.T) { + session := &testSession{data: SessionData{ + LoggedIn: true, + OauthReturnTo: "https://wg.example.com/subpath/app/#/login", + }} + ep := newBasePathAuthEndpoint(session) + + req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil) + req.SetPathValue("provider", "google") + res := httptest.NewRecorder() + + ep.handleOauthCallbackGet().ServeHTTP(res, req) + + if res.Code != http.StatusFound { + t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code) + } + if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?wgLoginState=success"; got != want { + t.Fatalf("expected redirect %q, got %q", want, got) + } +} + +func TestAuthEndpointReturnUrlWithLoginStatePreservesHashQuery(t *testing.T) { + session := &testSession{data: SessionData{ + LoggedIn: true, + OauthReturnTo: "https://wg.example.com/subpath/app/#/login?all=true", + }} + ep := newBasePathAuthEndpoint(session) + + req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil) + req.SetPathValue("provider", "google") + res := httptest.NewRecorder() + + ep.handleOauthCallbackGet().ServeHTTP(res, req) + + if res.Code != http.StatusFound { + t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code) + } + if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?all=true&wgLoginState=success"; got != want { + t.Fatalf("expected redirect %q, got %q", want, got) + } +} + +func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) { + ep := newBasePathAuthEndpoint(&testSession{}) + + if got, want := ep.frontendUrl("/login"), "https://wg.example.com/subpath/app/#/login"; got != want { + t.Fatalf("expected frontend URL %q, got %q", want, got) + } +} From 8fe50bf7dd5d28a05026d8826f7cac60f5d56d5d Mon Sep 17 00:00:00 2001 From: nbk1982 Date: Sat, 23 May 2026 15:31:37 -0300 Subject: [PATCH 13/23] Fix single-peer interface import mode (#695) Co-authored-by: nbk1982 <16351736+nbk1982@users.noreply.github.com> --- .../app/wireguard/wireguard_interfaces.go | 25 ++++++----- .../wireguard/wireguard_interfaces_test.go | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index c4d1923..8f8d193 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -893,16 +893,7 @@ func (m Manager) importInterface( } } - // try to predict the interface type based on the number of peers - switch len(peers) { - case 0: - iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface - case 1: - iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface - default: // multiple peers means this is a server interface - - iface.Type = domain.InterfaceTypeServer - } + iface.Type = inferImportedInterfaceType(iface, peers) existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) if err != nil && !errors.Is(err, domain.ErrNotFound) { @@ -930,6 +921,20 @@ func (m Manager) importInterface( return nil } +func inferImportedInterfaceType(iface *domain.Interface, peers []domain.PhysicalPeer) domain.InterfaceType { + switch len(peers) { + case 0: + return domain.InterfaceTypeAny // no peers means this is an unknown interface + case 1: + if iface.ListenPort > 0 { + return domain.InterfaceTypeServer // a listening interface with one peer is commonly a site-to-site server + } + return domain.InterfaceTypeClient + default: // multiple peers means this is a server interface + return domain.InterfaceTypeServer + } +} + // extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers // For server interfaces, peers typically have endpoints pointing to the server, so we use the most common one func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) { diff --git a/internal/app/wireguard/wireguard_interfaces_test.go b/internal/app/wireguard/wireguard_interfaces_test.go index 4d15fd0..3709556 100644 --- a/internal/app/wireguard/wireguard_interfaces_test.go +++ b/internal/app/wireguard/wireguard_interfaces_test.go @@ -10,6 +10,49 @@ import ( "github.com/h44z/wg-portal/internal/domain" ) +func TestInferImportedInterfaceType(t *testing.T) { + tests := []struct { + name string + listenPort int + peerCount int + expected domain.InterfaceType + }{ + { + name: "no peers stays unknown", + listenPort: 51820, + peerCount: 0, + expected: domain.InterfaceTypeAny, + }, + { + name: "single peer with listen port is server", + listenPort: 51820, + peerCount: 1, + expected: domain.InterfaceTypeServer, + }, + { + name: "single peer without listen port stays client", + listenPort: 0, + peerCount: 1, + expected: domain.InterfaceTypeClient, + }, + { + name: "multiple peers is server", + listenPort: 0, + peerCount: 2, + expected: domain.InterfaceTypeServer, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iface := &domain.Interface{ListenPort: tt.listenPort} + peers := make([]domain.PhysicalPeer, tt.peerCount) + + assert.Equal(t, tt.expected, inferImportedInterfaceType(iface, peers)) + }) + } +} + func TestImportPeer_AddressMapping(t *testing.T) { tests := []struct { name string From 8fd2721345e80cfaf91cfddcd49cdd5a99c68f4a Mon Sep 17 00:00:00 2001 From: Mark Lawrence Date: Sat, 23 May 2026 18:33:14 +0000 Subject: [PATCH 14/23] Document necessary systemd-networkd configuration (#694) By default, the systemd-networkd.service(8) removes routing policy created by other tools when it starts. This can cause wireguard tunnels to stop working during a system upgrade or other administration actions. Document the configuration necessary to prevent this occuring. Signed-off-by: Mark Lawrence --- .../documentation/getting-started/binaries.md | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/documentation/getting-started/binaries.md b/docs/documentation/getting-started/binaries.md index 54eda62..843cefc 100644 --- a/docs/documentation/getting-started/binaries.md +++ b/docs/documentation/getting-started/binaries.md @@ -51,13 +51,31 @@ sudo install wg-portal /opt/wg-portal/ To handle tasks such as restarting the service or configuring automatic startup, it is recommended to use a process manager like [systemd](https://systemd.io/). Refer to [Systemd Service Setup](#systemd-service-setup) for instructions. -## Systemd Service Setup +## Systemd Integration > **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand. > > The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`. > The configuration file is expected to be located at `/opt/wg-portal/config.yml`. +### Limit Systemd-Networkd Management Scope + +If you are using `systemd-networkd` to manage the rest of your network +configuration, you will need to ensure it doesn't remove routing policy +created by `wg-portal` when it restarts: + +```shell +sudo mkdir --parents /etc/systemd/networkd.conf.d/ +sudo tee --append /etc/systemd/networkd.conf.d/foreign-routing.conf < Date: Sat, 23 May 2026 20:33:46 +0200 Subject: [PATCH 15/23] chore(deps): bump pgregory.net/rapid from 1.2.0 to 1.3.0 (#692) Bumps [pgregory.net/rapid](https://github.com/flyingmutant/rapid) from 1.2.0 to 1.3.0. - [Release notes](https://github.com/flyingmutant/rapid/releases) - [Commits](https://github.com/flyingmutant/rapid/compare/v1.2.0...v1.3.0) --- updated-dependencies: - dependency-name: pgregory.net/rapid dependency-version: 1.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 55 +++++++------------------------------------------------ 2 files changed, 8 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index f44a5db..313b1dd 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlserver v1.6.3 gorm.io/gorm v1.31.1 - pgregory.net/rapid v1.2.0 + pgregory.net/rapid v1.3.0 ) require ( diff --git a/go.sum b/go.sum index e31e2a5..a7cce6d 100644 --- a/go.sum +++ b/go.sum @@ -3,9 +3,8 @@ filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= @@ -13,17 +12,14 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= -github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= -github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= @@ -52,8 +48,6 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/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/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= -github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -68,8 +62,6 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= -github.com/go-openapi/jsonpointer v0.23.0 h1:c25HFTJ6uWGmoe5BQI6p72p4o7KnlWYsy1MeFlAumsw= -github.com/go-openapi/jsonpointer v0.23.0/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= @@ -107,20 +99,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q= -github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4= github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo= github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ= -github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= -github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY= github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -157,8 +143,6 @@ 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.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -192,21 +176,15 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/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/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= -github.com/mdlayher/netlink v1.11.0 h1:Cot7ixQZL6P/pxRFB4z3jRdGPYeZosFT+WHS3sMXy8Y= -github.com/mdlayher/netlink v1.11.0/go.mod h1:rMwDzh42W85uW3yTtiTRZFX9uway98aDQ5i+D8Jq4g4= github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= -github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM= -github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= @@ -300,8 +278,6 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -310,8 +286,6 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -331,8 +305,6 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -366,8 +338,6 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -398,8 +368,6 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -408,8 +376,6 @@ 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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -438,12 +404,10 @@ gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQ gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= -modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= -modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -452,28 +416,23 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= -modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= -modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= -pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= +pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 1517041363a8075b7c63899e90a693a2b9bf28fc Mon Sep 17 00:00:00 2001 From: Aram Akhavan Date: Thu, 28 May 2026 11:48:37 -0700 Subject: [PATCH 16/23] fix: fetch user info from OIDC userinfo endpoint (#698) The OIDC client was only extracting claims from the ID token, but many OIDC providers (like Authelia) don't include all user information in the ID token. Fields like 'preferred_username' are typically only available via the userinfo endpoint. This fix fetches additional user information from the provider's userinfo endpoint and merges it with the ID token claims, ensuring that all required user fields are available for user registration and login. Fixes #697 Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com> --- internal/app/auth/auth_oidc.go | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/app/auth/auth_oidc.go b/internal/app/auth/auth_oidc.go index 5bcdbc7..bfb0bcb 100644 --- a/internal/app/auth/auth_oidc.go +++ b/internal/app/auth/auth_oidc.go @@ -144,7 +144,7 @@ func (o OidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oa return o.cfg.Exchange(ctx, code, opts...) } -// GetUserInfo retrieves the user info from the token. +// GetUserInfo retrieves the user info from the token and the userinfo endpoint. func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) ( map[string]any, error, @@ -182,6 +182,41 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, return nil, fmt.Errorf("failed to parse extra claims: %w", err) } + // Fetch additional user information from the userinfo endpoint + userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + if err != nil { + if o.sensitiveInfoLogging { + slog.Debug("OIDC: failed to fetch user info from endpoint", "provider", o.name, "error", err) + } + // Don't fail the entire flow if userinfo endpoint is unavailable; + // ID token claims may be sufficient + slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name) + } else { + // Parse claims from userinfo endpoint response + var userInfoFields map[string]any + if err = userInfo.Claims(&userInfoFields); err != nil { + if o.sensitiveInfoLogging { + slog.Debug("OIDC: failed to parse userinfo claims", "provider", o.name, "error", err) + } + // Don't fail if we can't parse userinfo; continue with ID token claims + slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name) + } else { + // Merge userinfo fields into tokenFields, preferring ID token claims + for key, value := range userInfoFields { + if _, exists := tokenFields[key]; !exists { + tokenFields[key] = value + } + } + + if o.userInfoLogging { + contents, _ := json.Marshal(userInfoFields) + slog.Debug("OIDC: user info from endpoint", + "source", o.name, + "info", string(contents)) + } + } + } + if o.userInfoLogging { contents, _ := json.Marshal(tokenFields) slog.Debug("OIDC: user info debug", From 72cfd1d8a926cbfec3c0ee026eea13653019b827 Mon Sep 17 00:00:00 2001 From: h44z Date: Thu, 28 May 2026 20:49:13 +0200 Subject: [PATCH 17/23] feat: add support for PKCE (#686) (#702) --- config.yml.sample | 3 + docs/documentation/configuration/overview.md | 16 ++++ .../v0/handlers/endpoint_authentication.go | 10 ++- internal/app/api/v0/handlers/web_session.go | 34 ++++---- internal/app/auth/auth.go | 32 ++++++-- internal/app/auth/auth_oauth.go | 36 +++++++++ internal/app/auth/auth_oauth_test.go | 61 ++++++++++++++ internal/app/auth/auth_oidc.go | 36 +++++++++ internal/app/auth/auth_oidc_test.go | 79 +++++++++++++++++++ internal/config/auth.go | 16 ++++ 10 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 internal/app/auth/auth_oauth_test.go create mode 100644 internal/app/auth/auth_oidc_test.go diff --git a/config.yml.sample b/config.yml.sample index 3be9ce5..7baed32 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -47,6 +47,8 @@ auth: extra_scopes: - https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.profile + use_pkce: true + pkce_method: S256 registration_enabled: true logout_idp_session: true - id: oidc2 @@ -79,6 +81,7 @@ auth: user_identifier: sub is_admin: this-attribute-must-be-true registration_enabled: true + use_pkce: false - id: google_plain_oauth_with_groups provider_name: google4 display_name: Login with
Google4 diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 5157f9e..10ec936 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -617,6 +617,14 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`: - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. +#### `use_pkce` +- **Default:** `true` +- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE. + +#### `pkce_method` +- **Default:** `S256` +- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it. + #### `logout_idp_session` - **Default:** `true` - **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session. @@ -703,6 +711,14 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`: - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. +#### `use_pkce` +- **Default:** `true` +- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE. + +#### `pkce_method` +- **Default:** `S256` +- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it. + --- ### LDAP diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index 7e9aedd..3b3f872 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -24,9 +24,9 @@ type AuthenticationService interface { // PlainLogin authenticates a user with a username and password. PlainLogin(ctx context.Context, username, password string) (*domain.User, error) // OauthLoginStep1 initiates the OAuth login flow. - OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) + OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce, codeVerifier string, err error) // OauthLoginStep2 completes the OAuth login flow and logins the user in. - OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error) + OauthLoginStep2(ctx context.Context, providerId, nonce, code, codeVerifier string) (*domain.User, string, error) // OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported. OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool) } @@ -231,7 +231,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { return } - authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider) + authCodeUrl, state, nonce, codeVerifier, err := e.authService.OauthLoginStep1(context.Background(), provider) if err != nil { slog.Debug("failed to create oauth auth code URL", "provider", provider, "error", err) @@ -247,6 +247,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { authSession := e.session.GetData(r.Context()) authSession.OauthState = state authSession.OauthNonce = nonce + authSession.OauthCodeVerifier = codeVerifier authSession.OauthProvider = provider authSession.OauthReturnTo = returnTo e.session.SetData(r.Context(), authSession) @@ -323,7 +324,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, - oauthCode) + oauthCode, currentSession.OauthCodeVerifier) cancel() if err != nil { slog.Debug("failed to process oauth code", @@ -362,6 +363,7 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, o currentSession.OauthState = "" currentSession.OauthNonce = "" + currentSession.OauthCodeVerifier = "" currentSession.OauthProvider = oauthProvider currentSession.OauthReturnTo = "" currentSession.OauthIdToken = idTokenHint diff --git a/internal/app/api/v0/handlers/web_session.go b/internal/app/api/v0/handlers/web_session.go index 5b3ba26..cf893b0 100644 --- a/internal/app/api/v0/handlers/web_session.go +++ b/internal/app/api/v0/handlers/web_session.go @@ -26,11 +26,12 @@ type SessionData struct { Lastname string Email string - OauthState string - OauthNonce string - OauthProvider string - OauthReturnTo string - OauthIdToken string + OauthState string + OauthNonce string + OauthCodeVerifier string + OauthProvider string + OauthReturnTo string + OauthIdToken string WebAuthnData string @@ -80,16 +81,17 @@ func (s *SessionWrapper) DestroyData(ctx context.Context) { func (s *SessionWrapper) defaultSessionData() SessionData { return SessionData{ - LoggedIn: false, - IsAdmin: false, - UserIdentifier: "", - Firstname: "", - Lastname: "", - Email: "", - OauthState: "", - OauthNonce: "", - OauthProvider: "", - OauthReturnTo: "", - OauthIdToken: "", + LoggedIn: false, + IsAdmin: false, + UserIdentifier: "", + Firstname: "", + Lastname: "", + Email: "", + OauthState: "", + OauthNonce: "", + OauthCodeVerifier: "", + OauthProvider: "", + OauthReturnTo: "", + OauthIdToken: "", } } diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index c4ce8b3..1f4f9db 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -47,6 +47,11 @@ const ( AuthenticatorTypeOidc AuthenticatorType = "oidc" ) +const ( + pkceMethodS256 = "S256" // SHA-256 hashing + pkceMethodPlain = "plain" // plain text +) + // AuthenticatorOauth is the interface for all OAuth authenticators. type AuthenticatorOauth interface { // GetName returns the name of the authenticator. @@ -70,6 +75,10 @@ type AuthenticatorOauth interface { GetAllowedUserGroups() []string // GetLogoutUrl returns an IdP logout URL if supported by the provider. GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) + // PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange. + PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) + // PKCETokenOptions returns PKCE options for the token exchange. + PKCETokenOptions(verifier string) []oauth2.AuthCodeOption } // AuthenticatorLdap is the interface for all LDAP authenticators. @@ -448,30 +457,34 @@ func (a *Authenticator) passwordAuthentication( // OauthLoginStep1 starts the oauth authentication flow by returning the authentication URL, state and nonce. func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) ( - authCodeUrl, state, nonce string, + authCodeUrl, state, nonce, codeVerifier string, err error, ) { oauthProvider, ok := a.oauthAuthenticators[providerId] if !ok { - return "", "", "", fmt.Errorf("missing oauth provider %s", providerId) + return "", "", "", "", fmt.Errorf("missing oauth provider %s", providerId) } // Prepare authentication flow, set state cookies state, err = a.randString(16) if err != nil { - return "", "", "", fmt.Errorf("failed to generate state: %w", err) + return "", "", "", "", fmt.Errorf("failed to generate state: %w", err) } + // Generate PKCE code verifier and challenge if enabled. Otherwise, options will be empty. + authCodeOptions, codeVerifier := oauthProvider.PKCEAuthCodeOptions() + switch oauthProvider.GetType() { case AuthenticatorTypeOAuth: - authCodeUrl = oauthProvider.AuthCodeURL(state) + authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...) case AuthenticatorTypeOidc: nonce, err = a.randString(16) if err != nil { - return "", "", "", fmt.Errorf("failed to generate nonce: %w", err) + return "", "", "", "", fmt.Errorf("failed to generate nonce: %w", err) } - authCodeUrl = oauthProvider.AuthCodeURL(state, oidc.Nonce(nonce)) + authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce)) + authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...) } return @@ -531,13 +544,16 @@ func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool { // OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and // fetching the user information. -func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error) { +func (a *Authenticator) OauthLoginStep2( + ctx context.Context, + providerId, nonce, code, codeVerifier string, +) (*domain.User, string, error) { oauthProvider, ok := a.oauthAuthenticators[providerId] if !ok { return nil, "", fmt.Errorf("missing oauth provider %s", providerId) } - oauth2Token, err := oauthProvider.Exchange(ctx, code) + oauth2Token, err := oauthProvider.Exchange(ctx, code, oauthProvider.PKCETokenOptions(codeVerifier)...) if err != nil { return nil, "", fmt.Errorf("unable to exchange code: %w", err) } diff --git a/internal/app/auth/auth_oauth.go b/internal/app/auth/auth_oauth.go index 55eb7e4..d7be206 100644 --- a/internal/app/auth/auth_oauth.go +++ b/internal/app/auth/auth_oauth.go @@ -30,6 +30,8 @@ type PlainOauthAuthenticator struct { sensitiveInfoLogging bool allowedDomains []string allowedUserGroups []string + usePKCE bool + pkceMethod string } func newPlainOauthAuthenticator( @@ -62,6 +64,14 @@ func newPlainOauthAuthenticator( provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.allowedDomains = cfg.AllowedDomains provider.allowedUserGroups = cfg.AllowedUserGroups + provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE + provider.pkceMethod = cfg.PKCEMethod + if provider.pkceMethod == "" { + provider.pkceMethod = pkceMethodS256 + } + if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain { + return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod) + } return provider, nil } @@ -83,6 +93,32 @@ func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) { return "", false } +// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange. +func (p PlainOauthAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) { + if !p.usePKCE { + return nil, "" + } + + verifier := oauth2.GenerateVerifier() + if p.pkceMethod == pkceMethodPlain { + return []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_challenge", verifier), + oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain), + }, verifier + } + + return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier +} + +// PKCETokenOptions returns PKCE options for the token exchange. +func (p PlainOauthAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption { + if !p.usePKCE || verifier == "" { + return nil + } + + return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)} +} + // RegistrationEnabled returns whether registration is enabled for the OAuth authenticator. func (p PlainOauthAuthenticator) RegistrationEnabled() bool { return p.registrationEnabled diff --git a/internal/app/auth/auth_oauth_test.go b/internal/app/auth/auth_oauth_test.go new file mode 100644 index 0000000..431066c --- /dev/null +++ b/internal/app/auth/auth_oauth_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "testing" + + "golang.org/x/oauth2" +) + +func TestPlainOauthAuthenticatorPKCES256Options(t *testing.T) { + authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "S256"} + + options, verifier := authenticator.PKCEAuthCodeOptions() + if verifier == "" { + t.Fatal("expected verifier") + } + + values := authCodeValues(t, options) + + if values.Get("code_challenge") == "" { + t.Fatal("expected code_challenge") + } + if values.Get("code_challenge_method") != "S256" { + t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method")) + } + + tokenOptions := authenticator.PKCETokenOptions(verifier) + if len(tokenOptions) != 1 { + t.Fatalf("expected one token option, got %d", len(tokenOptions)) + } +} + +func TestPlainOauthAuthenticatorPKCEPlainOptions(t *testing.T) { + authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "plain"} + + options, verifier := authenticator.PKCEAuthCodeOptions() + values := authCodeValues(t, options) + + if values.Get("code_challenge") != verifier { + t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge")) + } + if values.Get("code_challenge_method") != "plain" { + t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method")) + } +} + +func TestPlainOauthAuthenticatorPKCEDisabled(t *testing.T) { + authenticator := PlainOauthAuthenticator{usePKCE: false, pkceMethod: "S256"} + + options, verifier := authenticator.PKCEAuthCodeOptions() + if len(options) != 0 { + t.Fatalf("expected no auth code options, got %d", len(options)) + } + if verifier != "" { + t.Fatalf("expected empty verifier, got %q", verifier) + } + + tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier()) + if len(tokenOptions) != 0 { + t.Fatalf("expected no token options, got %d", len(tokenOptions)) + } +} diff --git a/internal/app/auth/auth_oidc.go b/internal/app/auth/auth_oidc.go index bfb0bcb..2dc0137 100644 --- a/internal/app/auth/auth_oidc.go +++ b/internal/app/auth/auth_oidc.go @@ -30,6 +30,8 @@ type OidcAuthenticator struct { allowedUserGroups []string endSessionEndpoint string logoutIdpSession bool + usePKCE bool + pkceMethod string } func newOidcAuthenticator( @@ -67,6 +69,14 @@ func newOidcAuthenticator( provider.allowedDomains = cfg.AllowedDomains provider.allowedUserGroups = cfg.AllowedUserGroups provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession + provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE + provider.pkceMethod = cfg.PKCEMethod + if provider.pkceMethod == "" { + provider.pkceMethod = pkceMethodS256 + } + if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain { + return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod) + } var providerMetadata struct { EndSessionEndpoint string `json:"end_session_endpoint"` @@ -121,6 +131,32 @@ func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri strin return logoutUrl.String(), true } +// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange. +func (o OidcAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) { + if !o.usePKCE { + return nil, "" + } + + verifier := oauth2.GenerateVerifier() + if o.pkceMethod == pkceMethodPlain { + return []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_challenge", verifier), + oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain), + }, verifier + } + + return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier +} + +// PKCETokenOptions returns PKCE options for the token exchange. +func (o OidcAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption { + if !o.usePKCE || verifier == "" { + return nil + } + + return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)} +} + // RegistrationEnabled returns whether registration is enabled for this authenticator. func (o OidcAuthenticator) RegistrationEnabled() bool { return o.registrationEnabled diff --git a/internal/app/auth/auth_oidc_test.go b/internal/app/auth/auth_oidc_test.go new file mode 100644 index 0000000..d57de35 --- /dev/null +++ b/internal/app/auth/auth_oidc_test.go @@ -0,0 +1,79 @@ +package auth + +import ( + "net/url" + "testing" + + "golang.org/x/oauth2" +) + +func authCodeValues(t *testing.T, options []oauth2.AuthCodeOption) url.Values { + t.Helper() + + config := oauth2.Config{ + ClientID: "client-id", + Endpoint: oauth2.Endpoint{AuthURL: "https://example.com/auth"}, + RedirectURL: "https://wg.example.com/callback", + } + authCodeURL, err := url.Parse(config.AuthCodeURL("state", options...)) + if err != nil { + t.Fatalf("failed to parse auth code URL: %v", err) + } + + return authCodeURL.Query() +} + +func TestOidcAuthenticatorPKCES256Options(t *testing.T) { + authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "S256"} + + options, verifier := authenticator.PKCEAuthCodeOptions() + if verifier == "" { + t.Fatal("expected verifier") + } + + values := authCodeValues(t, options) + + if values.Get("code_challenge") == "" { + t.Fatal("expected code_challenge") + } + if values.Get("code_challenge_method") != "S256" { + t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method")) + } + + tokenOptions := authenticator.PKCETokenOptions(verifier) + if len(tokenOptions) != 1 { + t.Fatalf("expected one token option, got %d", len(tokenOptions)) + } + +} + +func TestOidcAuthenticatorPKCEPlainOptions(t *testing.T) { + authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "plain"} + + options, verifier := authenticator.PKCEAuthCodeOptions() + values := authCodeValues(t, options) + + if values.Get("code_challenge") != verifier { + t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge")) + } + if values.Get("code_challenge_method") != "plain" { + t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method")) + } +} + +func TestOidcAuthenticatorPKCEDisabled(t *testing.T) { + authenticator := OidcAuthenticator{usePKCE: false, pkceMethod: "S256"} + + options, verifier := authenticator.PKCEAuthCodeOptions() + if len(options) != 0 { + t.Fatalf("expected no auth code options, got %d", len(options)) + } + if verifier != "" { + t.Fatalf("expected empty verifier, got %q", verifier) + } + + tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier()) + if len(tokenOptions) != 0 { + t.Fatalf("expected no token options, got %d", len(tokenOptions)) + } +} diff --git a/internal/config/auth.go b/internal/config/auth.go index bd2bc34..fef4422 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -279,6 +279,14 @@ type OpenIDConnectProvider struct { // This also includes OAuth tokens! Keep this disabled in production! LogSensitiveInfo bool `yaml:"log_sensitive_info"` + // UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow. + // If unset, PKCE is enabled by default. + UsePKCE *bool `yaml:"use_pkce"` + + // PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain". + // If empty, "S256" is used. + PKCEMethod string `yaml:"pkce_method"` + // LogoutIdpSession controls whether the user's session at the OIDC provider is terminated on logout. // If set to true (default), the user will be redirected to the IdP's end_session_endpoint after local logout. // If set to false, only the local wg-portal session is invalidated. @@ -332,6 +340,14 @@ type OAuthProvider struct { // If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level. // This also includes OAuth tokens! Keep this disabled in production! LogSensitiveInfo bool `yaml:"log_sensitive_info"` + + // UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow. + // If unset, PKCE is enabled by default. + UsePKCE *bool `yaml:"use_pkce"` + + // PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain". + // If empty, "S256" is used. + PKCEMethod string `yaml:"pkce_method"` } // WebauthnConfig contains the configuration for the WebAuthn authenticator. From dea358c8cf8fdc118177fcd30937d3620dec4192 Mon Sep 17 00:00:00 2001 From: Aram Akhavan Date: Fri, 29 May 2026 12:55:54 -0700 Subject: [PATCH 18/23] fix: pfsense backend (#703) * Return empty string instead of "" when a genericjsonobject key doesn't exist. * Fix pfsense backend * Fix API request parameter names and types * Refactor interface and peer creation to send the necessary parameters * Automatically call apply when interfaces or peers are changed Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com> --------- Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com> --- docs/documentation/usage/backends.md | 2 +- internal/adapters/wgcontroller/pfsense.go | 282 ++++++++++++++-------- internal/lowlevel/mikrotik.go | 3 + internal/lowlevel/pfsense.go | 15 +- 4 files changed, 187 insertions(+), 115 deletions(-) diff --git a/docs/documentation/usage/backends.md b/docs/documentation/usage/backends.md index aeac9d6..cadc8d4 100644 --- a/docs/documentation/usage/backends.md +++ b/docs/documentation/usage/backends.md @@ -61,7 +61,7 @@ backend: > :warning: The pfSense backend is currently **alpha**. Only basic interface and peer CRUD are supported. Traffic statistics (rx/tx, last handshake) are not exposed by the pfSense REST API and will show as empty. -The pfSense backend talks to the pfSense REST API (pfSense Plus / CE with the REST API package installed). Point the backend at the appliance hostname without appending `/api/v2` — the portal appends `/api/v2` automatically. +The pfSense backend talks to the pfSense REST API (pfSense Plus / CE with the REST API package installed). Point the backend at the appliance hostname without appending `/api/v2` — the portal appends `/api/v2` automatically. wg-portal is developed for and tested against REST API v2.8.0. ### Prerequisites on pfSense: - pfSense with the REST API package enabled (`System -> API`) and WireGuard configured. diff --git a/internal/adapters/wgcontroller/pfsense.go b/internal/adapters/wgcontroller/pfsense.go index 89a0a0f..872baef 100644 --- a/internal/adapters/wgcontroller/pfsense.go +++ b/internal/adapters/wgcontroller/pfsense.go @@ -140,7 +140,7 @@ func (c *PfsenseController) GetInterface(ctx context.Context, id domain.Interfac } tunnelId := wgReply.Data[0].GetString("id") - + // Query the specific tunnel endpoint to get full details including addresses // Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id} if tunnelId != "" { @@ -227,7 +227,7 @@ func (c *PfsenseController) extractAddresses( if addrObj, ok := addrItem.(map[string]any); ok { address := "" mask := 0 - + // Extract address if addrVal, ok := addrObj["address"]; ok { if addrStr, ok := addrVal.(string); ok { @@ -236,7 +236,7 @@ func (c *PfsenseController) extractAddresses( address = fmt.Sprintf("%v", addrVal) } } - + // Extract mask if maskVal, ok := addrObj["mask"]; ok { if maskInt, ok := maskVal.(int); ok { @@ -249,7 +249,7 @@ func (c *PfsenseController) extractAddresses( } } } - + // Convert to CIDR format if address != "" && mask > 0 { cidrStr := fmt.Sprintf("%s/%d", address, mask) @@ -286,11 +286,11 @@ func (c *PfsenseController) extractAddresses( // Each object has "address" and "mask" fields (similar to allowedips structure) func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr { addresses := make([]domain.Cidr, 0, len(addressArray)) - + for _, addrObj := range addressArray { address := addrObj.GetString("address") mask := addrObj.GetInt("mask") - + if address != "" && mask > 0 { cidrStr := fmt.Sprintf("%s/%d", address, mask) if cidr, err := domain.CidrFromString(cidrStr); err == nil { @@ -303,7 +303,7 @@ func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJso } } } - + return addresses } @@ -405,7 +405,7 @@ func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.Interf peerTun = peer.GetString("tunnel") } } - + // Only include peers that match the requested interface name if peerTun != string(deviceId) { if c.cfg.Debug { @@ -425,7 +425,7 @@ func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.Interf } peers = append(peers, peerModel) } - + if c.cfg.Debug { slog.Debug("filtered peers for interface", "interface", deviceId, @@ -465,7 +465,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject if itemObj, ok := item.(map[string]any); ok { address := "" mask := 0 - + // Extract address if addrVal, ok := itemObj["address"]; ok { if addrStr, ok := addrVal.(string); ok { @@ -474,7 +474,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject address = fmt.Sprintf("%v", addrVal) } } - + // Extract mask if maskVal, ok := itemObj["mask"]; ok { if maskInt, ok := maskVal.(int); ok { @@ -487,7 +487,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject } } } - + // Convert to CIDR format (e.g., "10.1.2.3/32") if address != "" && mask > 0 { cidrStr := fmt.Sprintf("%s/%d", address, mask) @@ -502,7 +502,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr) } } - + // Fallback to string parsing if array parsing didn't work if len(allowedAddresses) == 0 { allowedIPsStr := peer.GetString("allowedips") @@ -516,7 +516,7 @@ func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject endpoint := peer.GetString("endpoint") port := peer.GetString("port") - + // Combine endpoint and port if both are available if endpoint != "" && port != "" { // Check if endpoint already contains a port @@ -617,18 +617,22 @@ func (c *PfsenseController) SaveInterface( mutex.Lock() defer mutex.Unlock() - physicalInterface, err := c.getOrCreateInterface(ctx, id) + physicalInterface, err := c.getInterface(ctx, id) if err != nil { return err } - deviceId := "" - if physicalInterface.GetExtras() != nil { - if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok { - deviceId = extras.Id + if physicalInterface == nil { + physicalInterface = &domain.PhysicalInterface{ + Identifier: id, + ImportSource: domain.ControllerTypePfsense, + DeviceType: domain.ControllerTypePfsense, } + physicalInterface.SetExtras(domain.PfsenseInterfaceExtras{}) } + deviceId := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras).Id + if updateFunc != nil { physicalInterface, err = updateFunc(physicalInterface) if err != nil { @@ -643,14 +647,14 @@ func (c *PfsenseController) SaveInterface( } } - if err := c.updateInterface(ctx, physicalInterface); err != nil { + if err := c.createOrUpdateInterface(ctx, physicalInterface); err != nil { return err } return nil } -func (c *PfsenseController) getOrCreateInterface( +func (c *PfsenseController) getInterface( ctx context.Context, id domain.InterfaceIdentifier, ) (*domain.PhysicalInterface, error) { @@ -659,50 +663,84 @@ func (c *PfsenseController) getOrCreateInterface( "name": string(id), }, }) - if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 { - return c.loadInterfaceData(ctx, wgReply.Data[0]) + if wgReply.Status != lowlevel.PfsenseApiStatusOk { + return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error) } - - // create a new tunnel if it does not exist - // Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular) - createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", lowlevel.GenericJsonObject{ - "name": string(id), - }) - if createReply.Status == lowlevel.PfsenseApiStatusOk { - return c.loadInterfaceData(ctx, createReply.Data) + if len(wgReply.Data) == 0 { + return nil, nil } - - return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error) + return c.loadInterfaceData(ctx, wgReply.Data[0]) } -func (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error { +type pfsenseWireGuardAddress struct { + Address string `json:"address"` + Mask int `json:"mask"` + Descr string `json:"descr"` +} + +func cidrToPfsense(cidr *domain.Cidr) pfsenseWireGuardAddress { + return pfsenseWireGuardAddress{ + Address: cidr.Addr, + Mask: cidr.NetLength, + // supported in pfsense, but not in wg-portal GUI + Descr: "", + } +} + +func (c *PfsenseController) createOrUpdateInterface(ctx context.Context, pi *domain.PhysicalInterface) error { extras := pi.GetExtras().(domain.PfsenseInterfaceExtras) interfaceId := extras.Id payload := lowlevel.GenericJsonObject{ - "name": string(pi.Identifier), - "description": extras.Comment, - "mtu": strconv.Itoa(pi.Mtu), - "listenport": strconv.Itoa(pi.ListenPort), - "privatekey": pi.KeyPair.PrivateKey, - "disabled": strconv.FormatBool(!pi.DeviceUp), + "name": string(pi.Identifier), + "descr": extras.Comment, + "mtu": pi.Mtu, + "listenport": strconv.Itoa(pi.ListenPort), + "privatekey": pi.KeyPair.PrivateKey, + "disabled": strconv.FormatBool(!pi.DeviceUp), } - // Add addresses if present - if len(pi.Addresses) > 0 { - addresses := make([]string, 0, len(pi.Addresses)) - for _, addr := range pi.Addresses { - addresses = append(addresses, addr.String()) + addresses := make([]pfsenseWireGuardAddress, 0, len(pi.Addresses)) + for _, addr := range pi.Addresses { + addresses = append(addresses, cidrToPfsense(&addr)) + } + payload["addresses"] = addresses + + if interfaceId == "" { + // Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular) + createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", payload) + if createReply.Status != lowlevel.PfsenseApiStatusOk { + return fmt.Errorf("failed to create interface %s: %v", pi.Identifier, createReply.Error) } - payload["addresses"] = strings.Join(addresses, ",") + // Capture the newly-assigned ID so callers see it + if newId := createReply.Data.GetString("id"); newId != "" { + extras.Id = newId + pi.SetExtras(extras) + } + if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk { + return fmt.Errorf("failed to apply WireGuard changes after creating interface %s: %v", + pi.Identifier, applyReply.Error) + } + return nil } - // Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id} - wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload) + interfaceIdInt, err := strconv.Atoi(interfaceId) + if err != nil { + return fmt.Errorf("invalid pfSense interface id %q for %s: %w", interfaceId, pi.Identifier, err) + } + payload["id"] = interfaceIdInt + + // Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel + wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel", payload) if wgReply.Status != lowlevel.PfsenseApiStatusOk { return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error) } + if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk { + return fmt.Errorf("failed to apply WireGuard changes after updating interface %s: %v", + pi.Identifier, applyReply.Error) + } + return nil } @@ -726,8 +764,10 @@ func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.Inter } interfaceId := wgReply.Data[0].GetString("id") - // Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id} - deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId) + // Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}&apply=true + deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{ + Filters: map[string]string{"id": interfaceId, "apply": "true"}, + }) if deleteReply.Status != lowlevel.PfsenseApiStatusOk { return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error) } @@ -746,18 +786,22 @@ func (c *PfsenseController) SavePeer( mutex.Lock() defer mutex.Unlock() - physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id) + physicalPeer, err := c.getPeer(ctx, deviceId, id) if err != nil { return err } - peerId := "" - if physicalPeer.GetExtras() != nil { - if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok { - peerId = extras.Id + if physicalPeer == nil { + physicalPeer = &domain.PhysicalPeer{ + Identifier: id, + KeyPair: domain.KeyPair{PublicKey: string(id)}, + ImportSource: domain.ControllerTypePfsense, } + physicalPeer.SetExtras(domain.PfsensePeerExtras{}) } + peerId := physicalPeer.GetExtras().(domain.PfsensePeerExtras).Id + physicalPeer, err = updateFunc(physicalPeer) if err != nil { return err @@ -770,14 +814,14 @@ func (c *PfsenseController) SavePeer( } } - if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil { + if err := c.createOrUpdatePeer(ctx, deviceId, physicalPeer); err != nil { return err } return nil } -func (c *PfsenseController) getOrCreatePeer( +func (c *PfsenseController) getPeer( ctx context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier, @@ -787,40 +831,25 @@ func (c *PfsenseController) getOrCreatePeer( wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{ Filters: map[string]string{ "publickey": string(id), - "tun": string(deviceId), // Use "tun" field name as that's what the API uses + "tun": string(deviceId), // Use "tun" field name as that's what the API uses }, }) - if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 { - slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId) - existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0]) - if err != nil { - return nil, err - } - return &existingPeer, nil + if wgReply.Status != lowlevel.PfsenseApiStatusOk { + return nil, fmt.Errorf("failed to query peer %s for interface %s: %v", id, deviceId, wgReply.Error) + } + if len(wgReply.Data) == 0 { + return nil, nil } - // create a new peer if it does not exist - // Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular) - slog.Debug("creating new pfSense peer", "peer", id, "interface", deviceId) - createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", lowlevel.GenericJsonObject{ - "name": fmt.Sprintf("wg-%s", id[0:8]), - "interface": string(deviceId), - "publickey": string(id), - "allowedips": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer - }) - if createReply.Status == lowlevel.PfsenseApiStatusOk { - newPeer, err := c.convertWireGuardPeer(createReply.Data) - if err != nil { - return nil, err - } - slog.Debug("successfully created pfSense peer", "peer", id, "interface", deviceId) - return &newPeer, nil + slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId) + existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0]) + if err != nil { + return nil, err } - - return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error) + return &existingPeer, nil } -func (c *PfsenseController) updatePeer( +func (c *PfsenseController) createOrUpdatePeer( ctx context.Context, deviceId domain.InterfaceIdentifier, pp *domain.PhysicalPeer, @@ -828,36 +857,74 @@ func (c *PfsenseController) updatePeer( extras := pp.GetExtras().(domain.PfsensePeerExtras) peerId := extras.Id - allowedIPsStr := domain.CidrsToString(pp.AllowedIPs) - - slog.Debug("updating pfSense peer", - "peer", pp.Identifier, - "interface", deviceId, - "allowed-ips", allowedIPsStr, - "allowed-ips-count", len(pp.AllowedIPs), - "disabled", extras.Disabled) - payload := lowlevel.GenericJsonObject{ - "name": extras.Name, - "description": extras.Comment, - "presharedkey": string(pp.PresharedKey), - "publickey": pp.KeyPair.PublicKey, - "privatekey": pp.KeyPair.PrivateKey, - "persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive), - "disabled": strconv.FormatBool(extras.Disabled), - "allowedips": allowedIPsStr, + "tun": string(deviceId), + "descr": extras.Name, + "presharedkey": string(pp.PresharedKey), + "publickey": pp.KeyPair.PublicKey, + "persistentkeepalive": pp.PersistentKeepalive, + "enabled": !extras.Disabled, } if pp.Endpoint != "" { payload["endpoint"] = pp.Endpoint } - // Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id} - wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload) + allowedIps := make([]pfsenseWireGuardAddress, 0, len(pp.AllowedIPs)) + for _, addr := range pp.AllowedIPs { + allowedIps = append(allowedIps, cidrToPfsense(&addr)) + } + payload["allowedips"] = allowedIps + + if peerId == "" { + slog.Debug("creating new pfSense peer", + "peer", pp.Identifier, + "interface", deviceId, + "allowed-ips", domain.CidrsToString(pp.AllowedIPs), + "disabled", extras.Disabled) + + // Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular) + createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", payload) + if createReply.Status != lowlevel.PfsenseApiStatusOk { + return fmt.Errorf("failed to create peer %s for interface %s: %v", + pp.Identifier, deviceId, createReply.Error) + } + if newId := createReply.Data.GetString("id"); newId != "" { + extras.Id = newId + pp.SetExtras(extras) + } + if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk { + return fmt.Errorf("failed to apply WireGuard changes after creating peer %s on interface %s: %v", + pp.Identifier, deviceId, applyReply.Error) + } + slog.Debug("successfully created pfSense peer", "peer", pp.Identifier, "interface", deviceId) + return nil + } + + slog.Debug("updating pfSense peer", + "peer", pp.Identifier, + "interface", deviceId, + "allowed-ips", domain.CidrsToString(pp.AllowedIPs), + "allowed-ips-count", len(pp.AllowedIPs), + "disabled", extras.Disabled) + + peerIdInt, err := strconv.Atoi(peerId) + if err != nil { + return fmt.Errorf("invalid pfSense peer id %q for %s: %w", peerId, pp.Identifier, err) + } + payload["id"] = peerIdInt + + // Actual endpoint: PATCH /api/v2/vpn/wireguard/peer + wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer", payload) if wgReply.Status != lowlevel.PfsenseApiStatusOk { return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error) } + if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk { + return fmt.Errorf("failed to apply WireGuard changes after updating peer %s on interface %s: %v", + pp.Identifier, deviceId, applyReply.Error) + } + if extras.Disabled { slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId) } else { @@ -882,7 +949,7 @@ func (c *PfsenseController) DeletePeer( wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{ Filters: map[string]string{ "publickey": string(id), - "tun": string(deviceId), // Use "tun" field name as that's what the API uses + "tun": string(deviceId), // Use "tun" field name as that's what the API uses }, }) if wgReply.Status != lowlevel.PfsenseApiStatusOk { @@ -893,8 +960,10 @@ func (c *PfsenseController) DeletePeer( } peerId := wgReply.Data[0].GetString("id") - // Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id} - deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId) + // Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}&apply=true + deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer", &lowlevel.PfsenseRequestOptions{ + Filters: map[string]string{"id": peerId, "apply": "true"}, + }) if deleteReply.Status != lowlevel.PfsenseApiStatusOk { return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error) } @@ -976,4 +1045,3 @@ func (c *PfsenseController) PingAddresses( } // endregion statistics-related - diff --git a/internal/lowlevel/mikrotik.go b/internal/lowlevel/mikrotik.go index 0c86a13..327dec4 100644 --- a/internal/lowlevel/mikrotik.go +++ b/internal/lowlevel/mikrotik.go @@ -57,6 +57,9 @@ type EmptyResponse struct{} func (JsonObject GenericJsonObject) GetString(key string) string { if value, ok := JsonObject[key]; ok { + if value == nil { + return "" + } if strValue, ok := value.(string); ok { return strValue } else { diff --git a/internal/lowlevel/pfsense.go b/internal/lowlevel/pfsense.go index e58471a..4c65ff5 100644 --- a/internal/lowlevel/pfsense.go +++ b/internal/lowlevel/pfsense.go @@ -23,7 +23,7 @@ import ( // region models const ( - PfsenseApiStatusOk = "ok" // pfSense REST API uses "ok" in response + PfsenseApiStatusOk = "ok" // pfSense REST API uses "ok" in response PfsenseApiStatusError = "error" ) @@ -37,8 +37,8 @@ const ( type PfsenseApiResponse[T any] struct { Status string Code int - Data T `json:"data,omitempty"` - Error *PfsenseApiError `json:"error,omitempty"` + Data T `json:"data,omitempty"` + Error *PfsenseApiError `json:"error,omitempty"` } type PfsenseApiError struct { @@ -193,6 +193,7 @@ func (p *PfsenseApiClient) preparePayloadRequest( if err != nil { return nil, fmt.Errorf("failed to marshal payload: %w", err) } + p.debugLog("Prepared payload", "payload", string(payloadBytes)) req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes)) if err != nil { @@ -243,7 +244,7 @@ func parsePfsenseHttpResponse[T any](resp *http.Response, err error) PfsenseApiR if err != nil { return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed, "failed to read response body", err) } - + // Close the body after reading defer func() { if err := resp.Body.Close(); err != nil { @@ -273,7 +274,7 @@ func parsePfsenseHttpResponse[T any](resp *http.Response, err error) PfsenseApiR "method", resp.Request.Method, "body_preview", bodyPreview, "error", err) - return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed, + return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed, fmt.Sprintf("failed to decode response (status %d, content-type: %s): %v", resp.StatusCode, contentType, err), err) } @@ -405,11 +406,12 @@ func (p *PfsenseApiClient) Update( func (p *PfsenseApiClient) Delete( ctx context.Context, command string, + opts *PfsenseRequestOptions, ) PfsenseApiResponse[EmptyResponse] { apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout()) defer cancel() - fullUrl := p.getFullPath(command) + fullUrl := opts.GetPath(p.getFullPath(command)) req, err := p.prepareDeleteRequest(apiCtx, fullUrl) if err != nil { @@ -425,4 +427,3 @@ func (p *PfsenseApiClient) Delete( } // endregion API-client - From e3dc31a133234b65fc6e6e449ec37a0a2f70f72d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 21:11:31 +0200 Subject: [PATCH 19/23] chore(deps): bump the patch group with 3 updates (#699) Bumps the patch group with 3 updates: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn), [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/sys](https://github.com/golang/sys). Updates `github.com/go-webauthn/webauthn` from 0.17.3 to 0.17.4 - [Release notes](https://github.com/go-webauthn/webauthn/releases) - [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md) - [Commits](https://github.com/go-webauthn/webauthn/compare/v0.17.3...v0.17.4) Updates `golang.org/x/crypto` from 0.51.0 to 0.52.0 - [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0) Updates `golang.org/x/sys` from 0.44.0 to 0.45.0 - [Commits](https://github.com/golang/sys/compare/v0.44.0...v0.45.0) --- updated-dependencies: - dependency-name: github.com/go-webauthn/webauthn dependency-version: 0.17.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch - dependency-name: golang.org/x/crypto dependency-version: 0.52.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: patch - dependency-name: golang.org/x/sys dependency-version: 0.45.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 313b1dd..ee1c88d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-pkgz/routegroup v1.6.0 github.com/go-playground/validator/v10 v10.30.2 - github.com/go-webauthn/webauthn v0.17.3 + github.com/go-webauthn/webauthn v0.17.4 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/prometheus-community/pro-bing v0.8.0 @@ -22,9 +22,9 @@ require ( github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/writer/compressed v1.0.1 - golang.org/x/crypto v0.51.0 + golang.org/x/crypto v0.52.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/text v0.37.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gopkg.in/yaml.v3 v3.0.1 @@ -63,7 +63,7 @@ require ( github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-webauthn/x v0.2.5 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect diff --git a/go.sum b/go.sum index a7cce6d..5fbda0e 100644 --- a/go.sum +++ b/go.sum @@ -105,10 +105,10 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo= -github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ= -github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY= -github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -278,8 +278,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -338,8 +338,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/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= From 316f389f11f3129f8603fcf3014fa7f19d7b3273 Mon Sep 17 00:00:00 2001 From: h44z Date: Fri, 5 Jun 2026 20:13:18 +0200 Subject: [PATCH 20/23] Merge commit from fork * sec: do not expose traffic stats to all users, harden origin check in websocket endpoint * add tests to validate new logic --- cmd/wg-portal/main.go | 2 +- .../app/api/v0/handlers/endpoint_websocket.go | 49 +++- .../v0/handlers/endpoint_websocket_test.go | 224 ++++++++++++++++++ 3 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 internal/app/api/v0/handlers/endpoint_websocket_test.go diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 327e327..b1c6aa9 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -135,7 +135,7 @@ func main() { apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) - apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus) + apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus, apiV0BackendPeers) apiFrontend := handlersV0.NewRestApi(apiV0Session, apiV0EndpointAuth, diff --git a/internal/app/api/v0/handlers/endpoint_websocket.go b/internal/app/api/v0/handlers/endpoint_websocket.go index 5dcc35a..c3afa28 100644 --- a/internal/app/api/v0/handlers/endpoint_websocket.go +++ b/internal/app/api/v0/handlers/endpoint_websocket.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "net/url" "strings" "sync" @@ -19,23 +20,28 @@ type WebsocketEventBus interface { Unsubscribe(topic string, fn any) error } +type WebsocketPeerService interface { + GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) +} + type WebsocketEndpoint struct { authenticator Authenticator bus WebsocketEventBus + peerService WebsocketPeerService upgrader websocket.Upgrader } -func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus) *WebsocketEndpoint { +func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus, peerService WebsocketPeerService) *WebsocketEndpoint { return &WebsocketEndpoint{ authenticator: auth, bus: bus, + peerService: peerService, upgrader: websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { - origin := r.Header.Get("Origin") - return strings.HasPrefix(origin, cfg.Web.ExternalUrl) + return matchOrigin(cfg.Web.ExternalUrl, r.Header.Get("Origin")) }, }, } @@ -57,6 +63,8 @@ type wsMessage struct { func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + userInfo := domain.GetUserInfo(r.Context()) + conn, err := e.upgrader.Upgrade(w, r, nil) if err != nil { return @@ -74,9 +82,29 @@ func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { } peerStatsHandler := func(status domain.TrafficDelta) { + if !userInfo.IsAdmin { + // lookup peer user-info to validate ownership + peer, err := e.peerService.GetPeer(ctx, domain.PeerIdentifier(status.EntityId)) + if err != nil { + return + } + + if peer.UserIdentifier == "" { + return // if peer is not assigned to any user, dont send stats + } + + if peer.UserIdentifier != userInfo.Id { + return // only expose stats for own peers + } + } + _ = writeJSON(wsMessage{Type: "peer_stats", Data: status}) } interfaceStatsHandler := func(status domain.TrafficDelta) { + if !userInfo.IsAdmin { + return // interface stats will only be exposed to admins + } + _ = writeJSON(wsMessage{Type: "interface_stats", Data: status}) } @@ -98,3 +126,18 @@ func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { <-ctx.Done() } } + +func matchOrigin(externalBaseUrl, origin string) bool { + originURL, err := url.Parse(origin) + if err != nil { + return false + } + + externalURL, err := url.Parse(externalBaseUrl) + if err != nil { + return false + } + + return originURL.Scheme == externalURL.Scheme && + strings.EqualFold(originURL.Host, externalURL.Host) +} diff --git a/internal/app/api/v0/handlers/endpoint_websocket_test.go b/internal/app/api/v0/handlers/endpoint_websocket_test.go new file mode 100644 index 0000000..ad98689 --- /dev/null +++ b/internal/app/api/v0/handlers/endpoint_websocket_test.go @@ -0,0 +1,224 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/websocket" + evbus "github.com/vardius/message-bus" + + "github.com/h44z/wg-portal/internal/app" + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +// region test-helper + +type websocketTestPeerService struct { + peers map[domain.PeerIdentifier]*domain.Peer +} + +func (s websocketTestPeerService) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) { + peer, ok := s.peers[id] + if !ok { + return nil, errors.New("peer not found") + } + + return peer, nil +} + +func newTestWebsocketConnection( + t *testing.T, + bus evbus.MessageBus, + userInfo *domain.ContextUserInfo, + peers map[domain.PeerIdentifier]*domain.Peer, +) (*websocket.Conn, func()) { + t.Helper() + + cfg := &config.Config{} + endpoint := NewWebsocketEndpoint(cfg, nil, bus, websocketTestPeerService{peers: peers}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(domain.SetUserInfo(r.Context(), userInfo)) + endpoint.handleWebsocket()(w, r) + })) + cfg.Web.ExternalUrl = server.URL + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{"Origin": []string{server.URL}}) + if err != nil { + server.Close() + t.Fatalf("failed to dial websocket: %v", err) + } + + cleanup := func() { + conn.Close() + server.Close() + } + + return conn, cleanup +} + +func assertWebsocketMessage(t *testing.T, conn *websocket.Conn, messageType string, entityId string) { + t.Helper() + + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("failed to set read deadline: %v", err) + } + + var message wsMessage + if err := conn.ReadJSON(&message); err != nil { + t.Fatalf("failed to read websocket message: %v", err) + } + + if message.Type != messageType { + t.Fatalf("unexpected message type: got %q, want %q", message.Type, messageType) + } + + data, ok := message.Data.(map[string]any) + if !ok { + t.Fatalf("unexpected message data type: %T", message.Data) + } + if data["EntityId"] != entityId { + t.Fatalf("unexpected entity id: got %v, want %q", data["EntityId"], entityId) + } +} + +func assertNoWebsocketMessage(t *testing.T, conn *websocket.Conn) { + t.Helper() + + if err := conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + t.Fatalf("failed to set read deadline: %v", err) + } + + var message wsMessage + if err := conn.ReadJSON(&message); err == nil { + t.Fatalf("unexpected websocket message: %+v", message) + } +} + +// endregion test-helper + +func TestWebsocketEndpointAllowsOwnPeerStatsForNonAdmin(t *testing.T) { + bus := evbus.New(10) + conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, + map[domain.PeerIdentifier]*domain.Peer{ + "own-peer": {Identifier: "own-peer", UserIdentifier: "user-a"}, + }) + defer cleanup() + + bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "own-peer", BytesReceivedPerSecond: 1}) + assertWebsocketMessage(t, conn, "peer_stats", "own-peer") +} + +func TestWebsocketEndpointFiltersOtherPeerStatsForNonAdmin(t *testing.T) { + bus := evbus.New(10) + conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, + map[domain.PeerIdentifier]*domain.Peer{ + "other-peer": {Identifier: "other-peer", UserIdentifier: "user-b"}, + }) + defer cleanup() + + bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1}) + assertNoWebsocketMessage(t, conn) +} + +func TestWebsocketEndpointFiltersUnknownPeerStatsForNonAdmin(t *testing.T) { + bus := evbus.New(10) + conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, + map[domain.PeerIdentifier]*domain.Peer{ + "other-peer": {Identifier: "other-peer", UserIdentifier: ""}, + }) + defer cleanup() + + bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1}) + assertNoWebsocketMessage(t, conn) +} + +func TestWebsocketEndpointFiltersUnknownPeerStatsForNonAdmin2(t *testing.T) { + bus := evbus.New(10) + conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, nil) + defer cleanup() + + bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "unknown-peer", BytesReceivedPerSecond: 1}) + assertNoWebsocketMessage(t, conn) +} + +func TestWebsocketEndpointFiltersInterfaceStatsForNonAdmin(t *testing.T) { + bus := evbus.New(10) + conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, nil) + defer cleanup() + + bus.Publish(app.TopicInterfaceStatsUpdated, domain.TrafficDelta{EntityId: "wg0", BytesReceivedPerSecond: 1}) + assertNoWebsocketMessage(t, conn) +} + +func TestWebsocketEndpointAllowsAllStatsForAdmin(t *testing.T) { + bus := evbus.New(10) + conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "admin", IsAdmin: true}, nil) + defer cleanup() + + bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1}) + assertWebsocketMessage(t, conn, "peer_stats", "other-peer") + + bus.Publish(app.TopicInterfaceStatsUpdated, domain.TrafficDelta{EntityId: "wg0", BytesReceivedPerSecond: 1}) + assertWebsocketMessage(t, conn, "interface_stats", "wg0") +} + +func Test_matchOrigin(t *testing.T) { + tests := []struct { + name string + externalBaseUrl string + origin string + want bool + }{ + { + name: "matching origin", + externalBaseUrl: "https://example.com", + origin: "https://example.com", + want: true, + }, + { + name: "matching origin with path", + externalBaseUrl: "https://example.com/app1", + origin: "https://example.com/app2", + want: true, + }, + { + name: "non-matching origin with different host", + externalBaseUrl: "https://example.com", + origin: "https://example.com.malicious.com", + want: false, + }, + { + name: "non-matching origin with different scheme", + externalBaseUrl: "https://example.com", + origin: "http://example.com", + want: false, + }, + { + name: "invalid origin URL", + externalBaseUrl: "https://example.com", + origin: "://invalid-url", + want: false, + }, + { + name: "invalid externalBaseUrl", + externalBaseUrl: "://invalid-url", + origin: "https://example.com", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchOrigin(tt.externalBaseUrl, tt.origin) + if got != tt.want { + t.Errorf("matchOrigin() = %v, want %v", got, tt.want) + } + }) + } +} From d8da5ff95a57241e24d89efa1e1c4a608191a5ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:19:24 +0200 Subject: [PATCH 21/23] chore(deps): bump github.com/go-playground/validator/v10 (#708) Bumps the patch group with 1 update: [github.com/go-playground/validator/v10](https://github.com/go-playground/validator). Updates `github.com/go-playground/validator/v10` from 10.30.2 to 10.30.3 - [Release notes](https://github.com/go-playground/validator/releases) - [Commits](https://github.com/go-playground/validator/compare/v10.30.2...v10.30.3) --- updated-dependencies: - dependency-name: github.com/go-playground/validator/v10 dependency-version: 10.30.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ee1c88d..6d0a4e6 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-pkgz/routegroup v1.6.0 - github.com/go-playground/validator/v10 v10.30.2 + github.com/go-playground/validator/v10 v10.30.3 github.com/go-webauthn/webauthn v0.17.4 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum index 5fbda0e..5804675 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/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.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= From de2f7c6835b9fe7eaef98a8a74f0a51caaf5fc16 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 5 Jun 2026 20:34:25 +0200 Subject: [PATCH 22/23] doc: add section that describes how to configure OAuth2 callback URL --- docs/documentation/configuration/overview.md | 2 ++ docs/documentation/usage/authentication.md | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 10ec936..717831a 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -552,6 +552,7 @@ 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. + This name is used to derive the callback URL for the OIDC provider: `/api/v0/auth/login//callback`. #### `display_name` - **Default:** *(empty)* @@ -639,6 +640,7 @@ 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. + This name is used to derive the callback URL for the OAuth provider: `/api/v0/auth/login//callback`. #### `display_name` - **Default:** *(empty)* diff --git a/docs/documentation/usage/authentication.md b/docs/documentation/usage/authentication.md index 8902951..fbe2969 100644 --- a/docs/documentation/usage/authentication.md +++ b/docs/documentation/usage/authentication.md @@ -51,6 +51,15 @@ To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md). +When registering the OAuth2 or OIDC application with your provider, configure the callback/redirect URL as follows: + +```text +/api/v0/auth/login//callback +``` + +Replace `` with the value configured in [`external_url`](../configuration/overview.md#external_url) and +`` with the exact `provider_name` from the matching OAuth2 or OIDC provider configuration. + #### Limiting Login to Specific Domains You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers. From ea3742c193ba7b5dca8afc62486bbd03bdcad0f2 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 5 Jun 2026 20:57:43 +0200 Subject: [PATCH 23/23] feat: add short-lived cache for peer-ownership checks --- cmd/wg-portal/main.go | 1 + .../app/api/v0/handlers/endpoint_websocket.go | 96 +++++++++++++++++-- .../v0/handlers/endpoint_websocket_test.go | 25 +++++ 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index b1c6aa9..de4a49f 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -136,6 +136,7 @@ func main() { apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus, apiV0BackendPeers) + apiV0EndpointWebsocket.StartBackgroundJobs(ctx) apiFrontend := handlersV0.NewRestApi(apiV0Session, apiV0EndpointAuth, diff --git a/internal/app/api/v0/handlers/endpoint_websocket.go b/internal/app/api/v0/handlers/endpoint_websocket.go index c3afa28..b2653ad 100644 --- a/internal/app/api/v0/handlers/endpoint_websocket.go +++ b/internal/app/api/v0/handlers/endpoint_websocket.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" "sync" + "time" "github.com/go-pkgz/routegroup" "github.com/gorilla/websocket" @@ -15,6 +16,11 @@ import ( "github.com/h44z/wg-portal/internal/domain" ) +const ( + websocketPeerUserIdentifierCacheTTL = 90 * time.Second + websocketPeerUserIdentifierCacheCleanupInterval = websocketPeerUserIdentifierCacheTTL * 2 +) + type WebsocketEventBus interface { Subscribe(topic string, fn any) error Unsubscribe(topic string, fn any) error @@ -30,9 +36,17 @@ type WebsocketEndpoint struct { peerService WebsocketPeerService upgrader websocket.Upgrader + + ownershipCache map[domain.PeerIdentifier]peerUserIdentifierCacheEntry + ownershipCacheMux sync.Mutex } -func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus, peerService WebsocketPeerService) *WebsocketEndpoint { +func NewWebsocketEndpoint( + cfg *config.Config, + auth Authenticator, + bus WebsocketEventBus, + peerService WebsocketPeerService, +) *WebsocketEndpoint { return &WebsocketEndpoint{ authenticator: auth, bus: bus, @@ -44,24 +58,38 @@ func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketE return matchOrigin(cfg.Web.ExternalUrl, r.Header.Get("Origin")) }, }, + ownershipCache: make(map[domain.PeerIdentifier]peerUserIdentifierCacheEntry), + ownershipCacheMux: sync.Mutex{}, } } -func (e WebsocketEndpoint) GetName() string { +func (e *WebsocketEndpoint) GetName() string { return "WebsocketEndpoint" } -func (e WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) { +func (e *WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) { g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket()) } +// StartBackgroundJobs starts background jobs like the expired peers check. +// This method is non-blocking. +func (e *WebsocketEndpoint) StartBackgroundJobs(ctx context.Context) { + go e.startOwnerCacheCleanup(ctx) +} + // wsMessage represents a message sent over websocket to the frontend type wsMessage struct { Type string `json:"type"` // either "peer_stats" or "interface_stats" Data any `json:"data"` // domain.TrafficDelta } -func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { +// peerUserIdentifierCacheEntry is a cache entry object that reduces database load when checking peer ownership. +type peerUserIdentifierCacheEntry struct { + userIdentifier domain.UserIdentifier + expiresAt time.Time +} + +func (e *WebsocketEndpoint) handleWebsocket() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userInfo := domain.GetUserInfo(r.Context()) @@ -84,16 +112,16 @@ func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { peerStatsHandler := func(status domain.TrafficDelta) { if !userInfo.IsAdmin { // lookup peer user-info to validate ownership - peer, err := e.peerService.GetPeer(ctx, domain.PeerIdentifier(status.EntityId)) + peerUserIdentifier, err := e.getPeerUserIdentifier(ctx, domain.PeerIdentifier(status.EntityId)) if err != nil { return } - if peer.UserIdentifier == "" { + if peerUserIdentifier == "" { return // if peer is not assigned to any user, dont send stats } - if peer.UserIdentifier != userInfo.Id { + if peerUserIdentifier != userInfo.Id { return // only expose stats for own peers } } @@ -127,6 +155,60 @@ func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { } } +func (e *WebsocketEndpoint) getPeerUserIdentifier( + ctx context.Context, + peerIdentifier domain.PeerIdentifier, +) (domain.UserIdentifier, error) { + now := time.Now() + + e.ownershipCacheMux.Lock() + entry, ok := e.ownershipCache[peerIdentifier] + if ok && now.Before(entry.expiresAt) { + e.ownershipCacheMux.Unlock() + return entry.userIdentifier, nil + } + e.ownershipCacheMux.Unlock() + + peer, err := e.peerService.GetPeer(ctx, peerIdentifier) + if err != nil { + return "", err + } + + e.ownershipCacheMux.Lock() + defer e.ownershipCacheMux.Unlock() + e.ownershipCache[peerIdentifier] = peerUserIdentifierCacheEntry{ + userIdentifier: peer.UserIdentifier, + expiresAt: now.Add(websocketPeerUserIdentifierCacheTTL), + } + + return peer.UserIdentifier, nil +} + +func (e *WebsocketEndpoint) startOwnerCacheCleanup(ctx context.Context) { + ticker := time.NewTicker(websocketPeerUserIdentifierCacheCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + e.cleanupOwnerCache(now) + } + } +} + +func (e *WebsocketEndpoint) cleanupOwnerCache(now time.Time) { + e.ownershipCacheMux.Lock() + defer e.ownershipCacheMux.Unlock() + + for peerIdentifier, entry := range e.ownershipCache { + if !now.Before(entry.expiresAt) { + delete(e.ownershipCache, peerIdentifier) + } + } +} + func matchOrigin(externalBaseUrl, origin string) bool { originURL, err := url.Parse(origin) if err != nil { diff --git a/internal/app/api/v0/handlers/endpoint_websocket_test.go b/internal/app/api/v0/handlers/endpoint_websocket_test.go index ad98689..ba81901 100644 --- a/internal/app/api/v0/handlers/endpoint_websocket_test.go +++ b/internal/app/api/v0/handlers/endpoint_websocket_test.go @@ -115,6 +115,31 @@ func TestWebsocketEndpointAllowsOwnPeerStatsForNonAdmin(t *testing.T) { assertWebsocketMessage(t, conn, "peer_stats", "own-peer") } +func TestWebsocketEndpointCleansExpiredPeerUserIdentifierCache(t *testing.T) { + now := time.Now() + endpoint := &WebsocketEndpoint{ + ownershipCache: map[domain.PeerIdentifier]peerUserIdentifierCacheEntry{ + "expired-peer": { + userIdentifier: "user-a", + expiresAt: now.Add(-time.Second), + }, + "active-peer": { + userIdentifier: "user-b", + expiresAt: now.Add(time.Second), + }, + }, + } + + endpoint.cleanupOwnerCache(now) + + if _, ok := endpoint.ownershipCache["expired-peer"]; ok { + t.Fatal("expired peer cache entry was not removed") + } + if _, ok := endpoint.ownershipCache["active-peer"]; !ok { + t.Fatal("active peer cache entry was removed") + } +} + func TestWebsocketEndpointFiltersOtherPeerStatsForNonAdmin(t *testing.T) { bus := evbus.New(10) conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},