From 744e82d6d0f7ac13ffa249ff4ea0f5462fabfbac Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Tue, 20 Jan 2026 11:48:14 -0800 Subject: [PATCH 1/8] feat(helm): add Keycloak ingress support Add optional ingress for Keycloak to enable OIDC browser flow when deployed in Kubernetes. When keycloak.ingress.enabled is true, the coordinator will use the public Keycloak URL instead of the internal service URL. Changes: - Add templates/ingress-keycloak.yaml for Keycloak ingress - Add keycloak.ingress.* values (enabled, className, annotations, host, tls) - Update deployment.yaml to use public URL when ingress is enabled - Update values.schema.json with ingress schema --- .../wonder-mesh-net/templates/deployment.yaml | 14 +++++--- .../templates/ingress-keycloak.yaml | 36 +++++++++++++++++++ charts/wonder-mesh-net/values.schema.json | 20 +++++++++++ charts/wonder-mesh-net/values.yaml | 6 ++++ 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 charts/wonder-mesh-net/templates/ingress-keycloak.yaml diff --git a/charts/wonder-mesh-net/templates/deployment.yaml b/charts/wonder-mesh-net/templates/deployment.yaml index 282c62b..f4c295b 100644 --- a/charts/wonder-mesh-net/templates/deployment.yaml +++ b/charts/wonder-mesh-net/templates/deployment.yaml @@ -61,11 +61,17 @@ spec: value: {{ .Values.headscale.config.unix_socket | quote }} {{- if .Values.keycloak.enabled }} - name: KEYCLOAK_URL - # If Keycloak is enabled in chart, point to the internal service. - # Note: For OIDC browser flow to work, this internal URL must be reachable by browser too (e.g. tunneling/VPN), - # OR the user must manually set coordinator.oidc.url to a public URL and keycloak.service.type/Ingress accordingly. - # Here we default to the service DNS for backend communication. + {{- if .Values.keycloak.ingress.enabled }} + {{- if .Values.keycloak.ingress.tls }} + value: "https://{{ .Values.keycloak.ingress.host }}" + {{- else }} + value: "http://{{ .Values.keycloak.ingress.host }}" + {{- end }} + {{- else }} + # Keycloak ingress not enabled, using internal service URL. + # Note: For OIDC browser flow to work, enable keycloak.ingress or use port-forwarding/VPN. value: "http://{{ include "wonder-mesh-net.fullname" . }}-keycloak:{{ .Values.keycloak.service.port }}" + {{- end }} - name: KEYCLOAK_REALM value: {{ .Values.coordinator.oidc.realm | quote }} - name: KEYCLOAK_CLIENT_ID diff --git a/charts/wonder-mesh-net/templates/ingress-keycloak.yaml b/charts/wonder-mesh-net/templates/ingress-keycloak.yaml new file mode 100644 index 0000000..0eebc7a --- /dev/null +++ b/charts/wonder-mesh-net/templates/ingress-keycloak.yaml @@ -0,0 +1,36 @@ +{{- if and .Values.keycloak.enabled .Values.keycloak.ingress.enabled -}} +{{- $fullName := include "wonder-mesh-net.fullname" . -}} +{{- $svcPort := .Values.keycloak.service.port -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }}-keycloak + labels: + {{- include "wonder-mesh-net.labels" . | nindent 4 }} + app.kubernetes.io/component: keycloak + {{- with .Values.keycloak.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.keycloak.ingress.className }} + ingressClassName: {{ .Values.keycloak.ingress.className }} + {{- end }} + {{- if .Values.keycloak.ingress.tls }} + tls: + - hosts: + - {{ .Values.keycloak.ingress.host | quote }} + secretName: {{ .Values.keycloak.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.keycloak.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ $fullName }}-keycloak + port: + number: {{ $svcPort }} +{{- end }} diff --git a/charts/wonder-mesh-net/values.schema.json b/charts/wonder-mesh-net/values.schema.json index 74b837d..d03926e 100644 --- a/charts/wonder-mesh-net/values.schema.json +++ b/charts/wonder-mesh-net/values.schema.json @@ -123,6 +123,26 @@ "port": { "type": "integer", "minimum": 1, "maximum": 65535 } } }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "host": { "type": "string" }, + "tls": { + "type": "object", + "additionalProperties": false, + "properties": { + "secretName": { "type": "string" } + } + } + } + }, "persistence": { "type": "object", "additionalProperties": false, diff --git a/charts/wonder-mesh-net/values.yaml b/charts/wonder-mesh-net/values.yaml index c3ed0a4..31b54aa 100644 --- a/charts/wonder-mesh-net/values.yaml +++ b/charts/wonder-mesh-net/values.yaml @@ -62,6 +62,12 @@ keycloak: service: type: ClusterIP port: 8080 + ingress: + enabled: false + className: "" + annotations: {} + host: auth.example.com + tls: {} persistence: enabled: false size: 1Gi From 4a36668f524e6d98cdf254ec411a1cf117a56a7a Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 20:59:12 -0800 Subject: [PATCH 2/8] feat(keycloak): add identity providers support with enhanced static serving Add support for configuring Keycloak identity providers (GitHub, Google, etc.) through Helm values. Includes schema validation and realm import configuration. Also improves coordinator stability and frontend experience: - Add retry logic for ACL policy initialization on startup - Enhance static file serving with explicit content-type headers - Fix post-login redirect path to /ui/ instead of / - Update join token command example with full coordinator URL Co-Authored-By: Claude --- .../templates/configmap-keycloak.yaml | 18 +++++ charts/wonder-mesh-net/values.schema.json | 48 +++++++++++++ charts/wonder-mesh-net/values.yaml | 20 ++++++ internal/app/coordinator/controller/oidc.go | 2 +- internal/app/coordinator/server.go | 16 ++++- internal/app/coordinator/webui/embed.go | 69 ++++++++++++++++--- webui/src/api/client.ts | 2 +- webui/src/pages/JoinToken.tsx | 2 +- 8 files changed, 162 insertions(+), 15 deletions(-) diff --git a/charts/wonder-mesh-net/templates/configmap-keycloak.yaml b/charts/wonder-mesh-net/templates/configmap-keycloak.yaml index 24ebfaf..cb7034e 100644 --- a/charts/wonder-mesh-net/templates/configmap-keycloak.yaml +++ b/charts/wonder-mesh-net/templates/configmap-keycloak.yaml @@ -50,6 +50,24 @@ data: } ] } + ], + "identityProviders": [ + {{- range $index, $idp := .Values.keycloak.identityProviders }} + {{- if $index }},{{ end }} + { + "alias": {{ $idp.alias | quote }}, + "providerId": {{ $idp.providerId | quote }}, + "enabled": {{ $idp.enabled | default true }}, + "trustEmail": {{ $idp.trustEmail | default false }}, + "config": { + "clientId": {{ $idp.config.clientId | quote }}, + "clientSecret": {{ $idp.config.clientSecret | quote }} + {{- if $idp.config.defaultScope }}, + "defaultScope": {{ $idp.config.defaultScope | quote }} + {{- end }} + } + } + {{- end }} ] } {{- end }} diff --git a/charts/wonder-mesh-net/values.schema.json b/charts/wonder-mesh-net/values.schema.json index d03926e..5d50741 100644 --- a/charts/wonder-mesh-net/values.schema.json +++ b/charts/wonder-mesh-net/values.schema.json @@ -181,6 +181,54 @@ }, { "type": "null" } ] + }, + "identityProviders": { + "type": "array", + "description": "Identity providers for social login (GitHub, Google, etc.)", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["alias", "providerId", "config"], + "properties": { + "alias": { + "type": "string", + "description": "Unique identifier for this identity provider (e.g., 'github', 'google')" + }, + "providerId": { + "type": "string", + "description": "The provider type (e.g., 'github', 'google', 'oidc')" + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether this identity provider is enabled" + }, + "trustEmail": { + "type": "boolean", + "default": false, + "description": "Whether to trust email addresses from this provider" + }, + "config": { + "type": "object", + "additionalProperties": false, + "required": ["clientId", "clientSecret"], + "properties": { + "clientId": { + "type": "string", + "description": "OAuth client ID" + }, + "clientSecret": { + "type": "string", + "description": "OAuth client secret" + }, + "defaultScope": { + "type": "string", + "description": "Default OAuth scopes to request" + } + } + } + } + } } } }, diff --git a/charts/wonder-mesh-net/values.yaml b/charts/wonder-mesh-net/values.yaml index 31b54aa..f11a5bc 100644 --- a/charts/wonder-mesh-net/values.yaml +++ b/charts/wonder-mesh-net/values.yaml @@ -99,6 +99,26 @@ keycloak: # periodSeconds: 10 # timeoutSeconds: 5 + # Identity providers for social login (GitHub, Google, etc.) + # These are imported into the Keycloak realm on startup + identityProviders: [] + # Example: + # identityProviders: + # - alias: github + # providerId: github + # enabled: true + # trustEmail: true + # config: + # clientId: "your-github-client-id" + # clientSecret: "your-github-client-secret" + # - alias: google + # providerId: google + # enabled: true + # trustEmail: true + # config: + # clientId: "your-google-client-id" + # clientSecret: "your-google-client-secret" + serviceAccount: create: true annotations: {} diff --git a/internal/app/coordinator/controller/oidc.go b/internal/app/coordinator/controller/oidc.go index 88cb597..2fe847c 100644 --- a/internal/app/coordinator/controller/oidc.go +++ b/internal/app/coordinator/controller/oidc.go @@ -26,7 +26,7 @@ func isSafeRedirectPath(path string) bool { } const ( - defaultPostLoginRedirect = "/" + defaultPostLoginRedirect = "/ui/" ) // OIDCController handles OIDC authentication endpoints. diff --git a/internal/app/coordinator/server.go b/internal/app/coordinator/server.go index cc02810..c1ee79e 100644 --- a/internal/app/coordinator/server.go +++ b/internal/app/coordinator/server.go @@ -443,10 +443,20 @@ func (s *Server) Run() error { slog.Info("initializing ACL policy") ctx := context.Background() - if err := s.wonderNetService.InitializeACLPolicy(ctx); err != nil { - slog.Warn("initialize ACL policy", "error", err) - } else { + var aclErr error + for i := 0; i < 10; i++ { + if err := s.wonderNetService.InitializeACLPolicy(ctx); err != nil { + aclErr = err + slog.Warn("initialize ACL policy, retrying", "error", err, "attempt", i+1) + time.Sleep(time.Duration(i+1) * time.Second) + continue + } slog.Info("ACL policy initialized successfully") + aclErr = nil + break + } + if aclErr != nil { + slog.Error("initialize ACL policy, giving up after retries", "error", aclErr) } httpServer := &http.Server{ diff --git a/internal/app/coordinator/webui/embed.go b/internal/app/coordinator/webui/embed.go index fbb297a..68c8a15 100644 --- a/internal/app/coordinator/webui/embed.go +++ b/internal/app/coordinator/webui/embed.go @@ -3,8 +3,10 @@ package webui import ( "embed" "fmt" + "io" "io/fs" "net/http" + "path" "strings" ) @@ -19,21 +21,70 @@ func Handler() (http.Handler, error) { if err != nil { return nil, fmt.Errorf("embed static fs: %w", err) } - fileServer := http.FileServer(http.FS(subFS)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - if path == "" || path == "/" { - path = "/index.html" + urlPath := r.URL.Path + if urlPath == "" || urlPath == "/" { + urlPath = "/index.html" } - cleanPath := strings.TrimPrefix(path, "/") + cleanPath := strings.TrimPrefix(urlPath, "/") if _, err := fs.Stat(subFS, cleanPath); err != nil { - path = "/index.html" + cleanPath = "index.html" } - req := r.Clone(r.Context()) - req.URL.Path = path - fileServer.ServeHTTP(w, req) + f, err := subFS.Open(cleanPath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if stat.IsDir() { + cleanPath = path.Join(cleanPath, "index.html") + f.Close() + f, err = subFS.Open(cleanPath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + stat, _ = f.Stat() + } + + w.Header().Set("Content-Type", contentType(cleanPath)) + if rs, ok := f.(io.ReadSeeker); ok { + http.ServeContent(w, r, cleanPath, stat.ModTime(), rs) + } else { + w.WriteHeader(http.StatusOK) + io.Copy(w, f) + } }), nil } + +func contentType(name string) string { + switch { + case strings.HasSuffix(name, ".html"): + return "text/html; charset=utf-8" + case strings.HasSuffix(name, ".css"): + return "text/css; charset=utf-8" + case strings.HasSuffix(name, ".js"): + return "application/javascript" + case strings.HasSuffix(name, ".json"): + return "application/json" + case strings.HasSuffix(name, ".svg"): + return "image/svg+xml" + case strings.HasSuffix(name, ".png"): + return "image/png" + case strings.HasSuffix(name, ".ico"): + return "image/x-icon" + default: + return "application/octet-stream" + } +} diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index e1ef8b5..ae0ade7 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -42,7 +42,7 @@ class ApiClient { } async createJoinToken(): Promise { - return this.fetch('/coordinator/api/v1/join-token', { method: 'POST' }) + return this.fetch('/coordinator/api/v1/join-token') } async getApiKeys(): Promise { diff --git a/webui/src/pages/JoinToken.tsx b/webui/src/pages/JoinToken.tsx index e6bc21d..faec44d 100644 --- a/webui/src/pages/JoinToken.tsx +++ b/webui/src/pages/JoinToken.tsx @@ -57,7 +57,7 @@ export default function JoinToken() {

Usage:

- wonder worker join --token YOUR_TOKEN + wonder worker join --coordinator-url https://wonder-net.strrl.dev {''}
From f73de44f5bf024ae0a862fb7b5a0a5d34b439043 Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 21:07:55 -0800 Subject: [PATCH 3/8] build: switch runtime image from Alpine to Debian Debian includes /etc/mime.types by default, allowing us to simplify the static file handler by using http.FileServer instead of manually setting Content-Type headers. --- Dockerfile | 7 ++- internal/app/coordinator/webui/embed.go | 69 ++++--------------------- 2 files changed, 14 insertions(+), 62 deletions(-) diff --git a/Dockerfile b/Dockerfile index b0bcbc8..a82ccc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,14 +29,17 @@ COPY --from=frontend /app/webui/dist/ ./internal/app/coordinator/webui/static/ RUN CGO_ENABLED=1 go build -ldflags "-s -w -X github.com/strrl/wonder-mesh-net/cmd/wonder/commands.version=${VERSION} -X github.com/strrl/wonder-mesh-net/cmd/wonder/commands.gitSHA=${GIT_SHA}" -o /wonder ./cmd/wonder # Stage 3: Runtime -FROM alpine:3.20 +FROM debian:bookworm LABEL org.opencontainers.image.source="https://github.com/STRRL/wonder-mesh-net" \ org.opencontainers.image.url="https://github.com/STRRL/wonder-mesh-net" \ org.opencontainers.image.title="wonder-mesh-net" \ org.opencontainers.image.description="PaaS bootstrapper turning homelab/edge machines into BYO compute" -RUN apk add --no-cache ca-certificates tzdata +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + tzdata \ + && rm -rf /var/lib/apt/lists/* COPY --from=builder /wonder /wonder diff --git a/internal/app/coordinator/webui/embed.go b/internal/app/coordinator/webui/embed.go index 68c8a15..fbb297a 100644 --- a/internal/app/coordinator/webui/embed.go +++ b/internal/app/coordinator/webui/embed.go @@ -3,10 +3,8 @@ package webui import ( "embed" "fmt" - "io" "io/fs" "net/http" - "path" "strings" ) @@ -21,70 +19,21 @@ func Handler() (http.Handler, error) { if err != nil { return nil, fmt.Errorf("embed static fs: %w", err) } + fileServer := http.FileServer(http.FS(subFS)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlPath := r.URL.Path - if urlPath == "" || urlPath == "/" { - urlPath = "/index.html" + path := r.URL.Path + if path == "" || path == "/" { + path = "/index.html" } - cleanPath := strings.TrimPrefix(urlPath, "/") + cleanPath := strings.TrimPrefix(path, "/") if _, err := fs.Stat(subFS, cleanPath); err != nil { - cleanPath = "index.html" + path = "/index.html" } - f, err := subFS.Open(cleanPath) - if err != nil { - http.NotFound(w, r) - return - } - defer f.Close() - - stat, err := f.Stat() - if err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - if stat.IsDir() { - cleanPath = path.Join(cleanPath, "index.html") - f.Close() - f, err = subFS.Open(cleanPath) - if err != nil { - http.NotFound(w, r) - return - } - defer f.Close() - stat, _ = f.Stat() - } - - w.Header().Set("Content-Type", contentType(cleanPath)) - if rs, ok := f.(io.ReadSeeker); ok { - http.ServeContent(w, r, cleanPath, stat.ModTime(), rs) - } else { - w.WriteHeader(http.StatusOK) - io.Copy(w, f) - } + req := r.Clone(r.Context()) + req.URL.Path = path + fileServer.ServeHTTP(w, req) }), nil } - -func contentType(name string) string { - switch { - case strings.HasSuffix(name, ".html"): - return "text/html; charset=utf-8" - case strings.HasSuffix(name, ".css"): - return "text/css; charset=utf-8" - case strings.HasSuffix(name, ".js"): - return "application/javascript" - case strings.HasSuffix(name, ".json"): - return "application/json" - case strings.HasSuffix(name, ".svg"): - return "image/svg+xml" - case strings.HasSuffix(name, ".png"): - return "image/png" - case strings.HasSuffix(name, ".ico"): - return "image/x-icon" - default: - return "application/octet-stream" - } -} From 35e4c03a93bca7e7992b9730c2d136ecc76e899d Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 21:11:11 -0800 Subject: [PATCH 4/8] fix: address PR review comments - Use window.location.origin for dynamic coordinator URL in join token example - Fix TLS check to use tls.secretName instead of tls object (empty object is truthy) --- charts/wonder-mesh-net/templates/deployment.yaml | 2 +- charts/wonder-mesh-net/templates/ingress-keycloak.yaml | 2 +- webui/src/pages/JoinToken.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/wonder-mesh-net/templates/deployment.yaml b/charts/wonder-mesh-net/templates/deployment.yaml index f4c295b..8c8b5c9 100644 --- a/charts/wonder-mesh-net/templates/deployment.yaml +++ b/charts/wonder-mesh-net/templates/deployment.yaml @@ -62,7 +62,7 @@ spec: {{- if .Values.keycloak.enabled }} - name: KEYCLOAK_URL {{- if .Values.keycloak.ingress.enabled }} - {{- if .Values.keycloak.ingress.tls }} + {{- if .Values.keycloak.ingress.tls.secretName }} value: "https://{{ .Values.keycloak.ingress.host }}" {{- else }} value: "http://{{ .Values.keycloak.ingress.host }}" diff --git a/charts/wonder-mesh-net/templates/ingress-keycloak.yaml b/charts/wonder-mesh-net/templates/ingress-keycloak.yaml index 0eebc7a..67d4c7f 100644 --- a/charts/wonder-mesh-net/templates/ingress-keycloak.yaml +++ b/charts/wonder-mesh-net/templates/ingress-keycloak.yaml @@ -16,7 +16,7 @@ spec: {{- if .Values.keycloak.ingress.className }} ingressClassName: {{ .Values.keycloak.ingress.className }} {{- end }} - {{- if .Values.keycloak.ingress.tls }} + {{- if .Values.keycloak.ingress.tls.secretName }} tls: - hosts: - {{ .Values.keycloak.ingress.host | quote }} diff --git a/webui/src/pages/JoinToken.tsx b/webui/src/pages/JoinToken.tsx index faec44d..1cde096 100644 --- a/webui/src/pages/JoinToken.tsx +++ b/webui/src/pages/JoinToken.tsx @@ -57,7 +57,7 @@ export default function JoinToken() {

Usage:

- wonder worker join --coordinator-url https://wonder-net.strrl.dev {''} + wonder worker join --coordinator-url {window.location.origin} {''}
From 6119852db1b967beb3f35ec86e46ba3af08de9b7 Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 21:14:11 -0800 Subject: [PATCH 5/8] feat(webui): include token in join command example Show the full ready-to-use command with actual token value and add a copy button for the complete command. --- webui/src/pages/JoinToken.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/webui/src/pages/JoinToken.tsx b/webui/src/pages/JoinToken.tsx index 1cde096..43696a8 100644 --- a/webui/src/pages/JoinToken.tsx +++ b/webui/src/pages/JoinToken.tsx @@ -55,9 +55,12 @@ export default function JoinToken() {
-

Usage:

+
+

Usage:

+ +
- wonder worker join --coordinator-url {window.location.origin} {''} + wonder worker join --coordinator-url {window.location.origin} {token.token}
From 2f58eee4441816fcf7da41f605c0aa87d1bd813f Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 21:34:56 -0800 Subject: [PATCH 6/8] fix: use Debian-based Go builder to match runtime The builder was using Alpine (musl libc) but runtime uses Debian (glibc). This caused "exec: no such file or directory" because the dynamic linker was missing. --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a82ccc7..5f44db5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,15 @@ COPY webui/ ./ RUN npm run build # Stage 2: Build Go binary -FROM golang:1.25-alpine AS builder +FROM golang:1.25-bookworm AS builder ARG VERSION=dev ARG GIT_SHA=unknown -RUN apk add --no-cache gcc musl-dev +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app From a303488ab4fd61326fce6331190ca25b37408028 Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 21:51:30 -0800 Subject: [PATCH 7/8] fix: update OIDC tests for /ui/ default redirect - Update test expectations to use /ui/ as default redirect - Fix HandleLogout to use defaultPostLoginRedirect constant --- internal/app/coordinator/controller/oidc.go | 2 +- .../app/coordinator/controller/oidc_test.go | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/app/coordinator/controller/oidc.go b/internal/app/coordinator/controller/oidc.go index 2fe847c..2569c2c 100644 --- a/internal/app/coordinator/controller/oidc.go +++ b/internal/app/coordinator/controller/oidc.go @@ -170,7 +170,7 @@ func (c *OIDCController) HandleLogout(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, expiredCookie) - http.Redirect(w, r, "/", http.StatusFound) + http.Redirect(w, r, defaultPostLoginRedirect, http.StatusFound) } func (c *OIDCController) determinePostLoginRedirect(r *http.Request) string { diff --git a/internal/app/coordinator/controller/oidc_test.go b/internal/app/coordinator/controller/oidc_test.go index 3210c40..3a02fd0 100644 --- a/internal/app/coordinator/controller/oidc_test.go +++ b/internal/app/coordinator/controller/oidc_test.go @@ -164,8 +164,8 @@ func TestOIDCController_HandleLogout(t *testing.T) { } location := rec.Header().Get("Location") - if location != "/" { - t.Errorf("Location = %q, want %q", location, "/") + if location != "/ui/" { + t.Errorf("Location = %q, want %q", location, "/ui/") } cookies := rec.Result().Cookies() @@ -203,16 +203,16 @@ func TestOIDCController_DeterminePostLoginRedirect(t *testing.T) { query string want string }{ - {"no redirect_to", "", "/"}, + {"no redirect_to", "", "/ui/"}, {"valid path", "?redirect_to=/dashboard", "/dashboard"}, {"valid nested path", "?redirect_to=/api/v1/nodes", "/api/v1/nodes"}, {"valid path with query", "?redirect_to=/dashboard?tab=settings", "/dashboard?tab=settings"}, - {"absolute URL same host", "?redirect_to=https://coordinator.example.com/settings", "/"}, - {"absolute URL different host", "?redirect_to=https://evil.com/phish", "/"}, - {"protocol-relative URL", "?redirect_to=//evil.com/phish", "/"}, - {"javascript scheme", "?redirect_to=javascript:alert(1)", "/"}, - {"data scheme", "?redirect_to=data:text/html,", "/"}, - {"empty path", "?redirect_to=", "/"}, + {"absolute URL same host", "?redirect_to=https://coordinator.example.com/settings", "/ui/"}, + {"absolute URL different host", "?redirect_to=https://evil.com/phish", "/ui/"}, + {"protocol-relative URL", "?redirect_to=//evil.com/phish", "/ui/"}, + {"javascript scheme", "?redirect_to=javascript:alert(1)", "/ui/"}, + {"data scheme", "?redirect_to=data:text/html,", "/ui/"}, + {"empty path", "?redirect_to=", "/ui/"}, {"just slash", "?redirect_to=/", "/"}, } From 2f0bb237d076a7dfa67b645886c18e27a5dd64e6 Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Mon, 26 Jan 2026 22:01:10 -0800 Subject: [PATCH 8/8] ci: add test workflow --- .github/workflows/test.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a57ae99 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Run tests + run: make test