Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 10 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -29,14 +32,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

Expand Down
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.secretName }}
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 deployment template is accessing .Values.keycloak.ingress.tls.secretName directly, but in values.yaml, tls is defined as an empty object (line 70). This will work in Helm templates when the field is not set (returns empty string), but the inconsistency between checking for .secretName existence here versus treating tls as a simple object in values.yaml could be confusing. Consider either:

  1. Keeping tls as an object with a secretName field (tls: {secretName: ""})
  2. Or checking if .Values.keycloak.ingress.tls is not empty before accessing .secretName

The same pattern is used in ingress-keycloak.yaml line 19.

Suggested change
{{- if .Values.keycloak.ingress.tls.secretName }}
{{- if and .Values.keycloak.ingress.tls .Values.keycloak.ingress.tls.secretName }}

Copilot uses AI. Check for mistakes.
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.secretName }}
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
4 changes: 2 additions & 2 deletions 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 Expand Up @@ -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 {
Expand Down
18 changes: 9 additions & 9 deletions internal/app/coordinator/controller/oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,<script>alert(1)</script>", "/"},
{"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,<script>alert(1)</script>", "/ui/"},
{"empty path", "?redirect_to=", "/ui/"},
{"just slash", "?redirect_to=/", "/"},
}

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
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
7 changes: 5 additions & 2 deletions webui/src/pages/JoinToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ export default function JoinToken() {
</div>

<div style={{ marginTop: '1.5rem', padding: '1rem', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ marginBottom: '0.5rem', fontWeight: 500 }}>Usage:</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<p style={{ fontWeight: 500, margin: 0 }}>Usage:</p>
<CopyButton text={`wonder worker join --coordinator-url ${window.location.origin} ${token.token}`} />
</div>
<code style={{ display: 'block', wordBreak: 'break-all' }}>
wonder worker join --token YOUR_TOKEN
wonder worker join --coordinator-url {window.location.origin} {token.token}
</code>
</div>
</div>
Expand Down
Loading