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:

+
+

Usage:

+ +
- wonder worker join --token YOUR_TOKEN + wonder worker join --coordinator-url {window.location.origin} {token.token}