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
diff --git a/Dockerfile b/Dockerfile
index b0bcbc8..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
@@ -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
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/templates/deployment.yaml b/charts/wonder-mesh-net/templates/deployment.yaml
index 282c62b..8c8b5c9 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.secretName }}
+ 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..67d4c7f
--- /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.secretName }}
+ 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..5d50741 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,
@@ -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"
+ }
+ }
+ }
+ }
+ }
}
}
},
diff --git a/charts/wonder-mesh-net/values.yaml b/charts/wonder-mesh-net/values.yaml
index c3ed0a4..f11a5bc 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
@@ -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: {}
diff --git a/internal/app/coordinator/controller/oidc.go b/internal/app/coordinator/controller/oidc.go
index 88cb597..2569c2c 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.
@@ -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=/", "/"},
}
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/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..43696a8 100644
--- a/webui/src/pages/JoinToken.tsx
+++ b/webui/src/pages/JoinToken.tsx
@@ -55,9 +55,12 @@ export default function JoinToken() {
-
Usage:
+
- wonder worker join --token YOUR_TOKEN
+ wonder worker join --coordinator-url {window.location.origin} {token.token}