Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions charts/wonder-mesh-net/templates/configmap-keycloak.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Helm templates, the default function treats false as an empty value. If .Values.keycloak.identityProviders[].enabled is explicitly set to false, it will be overridden by true. Since the schema already provides a default value of true, you can safely remove | default true or use a conditional check like {{ if hasKey $idp "enabled" }}{{ $idp.enabled }}{{ else }}true{{ end }}.

"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 }}
14 changes: 10 additions & 4 deletions charts/wonder-mesh-net/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If keycloak.ingress.host is not provided, this will result in an invalid URL (https://). Consider adding a check to ensure the host is set when ingress is enabled.

{{- 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
Expand Down
36 changes: 36 additions & 0 deletions charts/wonder-mesh-net/templates/ingress-keycloak.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
Comment on lines +19 to +24
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template is accessing .Values.keycloak.ingress.tls.secretName, but in values.yaml line 70, tls is defined as an empty object. While this works in Helm templates (returns empty string when the field doesn't exist), it would be more consistent and clear to either:

  1. Document that secretName should be set within the tls object (e.g., tls: {secretName: ""})
  2. Or add a comment in values.yaml showing the expected structure

This makes it easier for users to understand how to configure TLS for the Keycloak ingress.

Suggested change
{{- if .Values.keycloak.ingress.tls.secretName }}
tls:
- hosts:
- {{ .Values.keycloak.ingress.host | quote }}
secretName: {{ .Values.keycloak.ingress.tls.secretName }}
{{- end }}
{{- with .Values.keycloak.ingress.tls }}
{{- if .secretName }}
tls:
- hosts:
- {{ $.Values.keycloak.ingress.host | quote }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}

Copilot uses AI. Check for mistakes.
rules:
- host: {{ .Values.keycloak.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $fullName }}-keycloak
port:
number: {{ $svcPort }}
{{- end }}
68 changes: 68 additions & 0 deletions charts/wonder-mesh-net/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -161,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"
}
}
}
}
}
}
}
},
Expand Down
26 changes: 26 additions & 0 deletions charts/wonder-mesh-net/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -93,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: {}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/coordinator/controller/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func isSafeRedirectPath(path string) bool {
}

const (
defaultPostLoginRedirect = "/"
defaultPostLoginRedirect = "/ui/"
)

// OIDCController handles OIDC authentication endpoints.
Expand Down
16 changes: 13 additions & 3 deletions internal/app/coordinator/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +447 to +457
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This retry loop can block the server startup for up to 55 seconds. If the coordinator is running in Kubernetes, this might cause liveness or readiness probes to fail and restart the pod before it can successfully initialize. Consider using a shorter total timeout or ensuring that health checks can still pass during this initialization phase.

if aclErr != nil {
slog.Error("initialize ACL policy, giving up after retries", "error", aclErr)
}

httpServer := &http.Server{
Expand Down
69 changes: 60 additions & 9 deletions internal/app/coordinator/webui/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package webui
import (
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"strings"
)

Expand All @@ -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"
}
}
2 changes: 1 addition & 1 deletion webui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ApiClient {
}

async createJoinToken(): Promise<JoinTokenResponse> {
return this.fetch('/coordinator/api/v1/join-token', { method: 'POST' })
return this.fetch('/coordinator/api/v1/join-token')
}

async getApiKeys(): Promise<ApiKeyInfo[]> {
Expand Down
2 changes: 1 addition & 1 deletion webui/src/pages/JoinToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default function JoinToken() {
<div style={{ marginTop: '1.5rem', padding: '1rem', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ marginBottom: '0.5rem', fontWeight: 500 }}>Usage:</p>
<code style={{ display: 'block', wordBreak: 'break-all' }}>
wonder worker join --token YOUR_TOKEN
wonder worker join --coordinator-url https://wonder-net.strrl.dev {'<token>'}
</code>
</div>
</div>
Expand Down
Loading