diff --git a/.env.example b/.env.example index 0ffc588..266ec9f 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,15 @@ CMS_SSO_HEADER=X-SSO-Token # Optional shared secret. Leave empty to accept any non-empty forwarded value. CMS_SSO_TOKEN= # Optional cookie fallback when the proxy sets a cookie instead of a header. -CMS_SSO_COOKIE= \ No newline at end of file +CMS_SSO_COOKIE= + +# Go Loose browser login. Leave the credentials empty to keep the proxy identity. +# Register https://tell.dev/api/auth/callback as the redirect URI of the Go Loose +# application that owns these credentials. +GO_LOOSE_TENANT=tell +GO_LOOSE_AUTH_DOMAIN=auth.dev +GO_LOOSE_APP_DOMAIN=tell.dev +GO_LOOSE_CLIENT_ID= +GO_LOOSE_CLIENT_SECRET= +# Extra certificate authority for an internally signed identity domain. +GO_LOOSE_CA_FILE= \ No newline at end of file diff --git a/.github/workflows/build-test-push.yml b/.github/workflows/build-test-push.yml index 2c0b0d9..e2fa248 100644 --- a/.github/workflows/build-test-push.yml +++ b/.github/workflows/build-test-push.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: env: - IMAGE_NAME: batty1039.startdedicated.net:5000/cms + IMAGE_NAME: batty1039.startdedicated.net:5000/go-tell FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: diff --git a/.github/workflows/deploy-rancher.yml b/.github/workflows/deploy-rancher.yml index 78c9dce..736a9c7 100644 --- a/.github/workflows/deploy-rancher.yml +++ b/.github/workflows/deploy-rancher.yml @@ -26,7 +26,7 @@ permissions: contents: read concurrency: - group: cms-${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }} + group: go-tell-${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }} cancel-in-progress: false jobs: @@ -47,6 +47,9 @@ jobs: CMS_ALLOWED_ORIGINS: ${{ vars.CMS_ALLOWED_ORIGINS }} CMS_SSO_HEADER: ${{ vars.CMS_SSO_HEADER }} CMS_SSO_COOKIE: ${{ vars.CMS_SSO_COOKIE }} + GO_LOOSE_CLIENT_ID: ${{ secrets.GO_LOOSE_CLIENT_ID }} + GO_LOOSE_CLIENT_SECRET: ${{ secrets.GO_LOOSE_CLIENT_SECRET }} + LOCAL_CA_CERT: ${{ secrets.LOCAL_CA_CERT }} RANCHER_NAMESPACE: ${{ vars.RANCHER_NAMESPACE }} steps: @@ -79,7 +82,11 @@ jobs: echo "REGISTRY_USERNAME and REGISTRY_PASSWORD are required" >&2 exit 1 fi - namespace="${RANCHER_NAMESPACE:-cms-$TARGET_ENVIRONMENT}" + if [ -z "$GO_LOOSE_CLIENT_ID" ] || [ -z "$GO_LOOSE_CLIENT_SECRET" ]; then + echo "GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET are required: they come from the Go Loose console" >&2 + exit 1 + fi + namespace="${RANCHER_NAMESPACE:-go-tell-$TARGET_ENVIRONMENT}" if [ -n "$PUBLISHED_SHA" ]; then image_tag="$PUBLISHED_SHA" else @@ -88,9 +95,9 @@ jobs: allowed_origins="$CMS_ALLOWED_ORIGINS" if [ -z "$allowed_origins" ]; then if [ "$TARGET_ENVIRONMENT" = "production" ]; then - allowed_origins="https://cms.urpi.be" + allowed_origins="https://tell.dev" else - allowed_origins="http://localhost:30081" + allowed_origins="http://localhost:30010" fi fi { @@ -112,28 +119,42 @@ jobs: set -eu kubectl create namespace "$DEPLOY_NAMESPACE" \ --dry-run=client -o yaml | kubectl apply -f - - kubectl -n "$DEPLOY_NAMESPACE" create secret docker-registry cms-registry \ + kubectl -n "$DEPLOY_NAMESPACE" create secret docker-registry go-tell-registry \ --docker-server=batty1039.startdedicated.net:5000 \ --docker-username="$REGISTRY_USERNAME" \ --docker-password="$REGISTRY_PASSWORD" \ --dry-run=client -o yaml | kubectl apply -f - + - name: Publish the internal certificate authority + if: ${{ env.LOCAL_CA_CERT != '' }} + run: | + set -eu + printf '%s' "$LOCAL_CA_CERT" | base64 --decode > "$RUNNER_TEMP/local-ca.crt" + kubectl -n "$DEPLOY_NAMESPACE" create secret generic local-ca \ + --from-file=ca.crt="$RUNNER_TEMP/local-ca.crt" \ + --dry-run=client -o yaml | kubectl apply -f - + - name: Deploy Helm release run: | set -eu - values_file="deploy/helm/cms/values-$TARGET_ENVIRONMENT.yaml" + values_file="deploy/helm/go-tell/values-$TARGET_ENVIRONMENT.yaml" set -- \ --set-string "image.tag=$IMAGE_TAG" \ - --set "imagePullSecrets[0].name=cms-registry" \ + --set "imagePullSecrets[0].name=go-tell-registry" \ --set-string "app.allowedOrigins=$RESOLVED_ALLOWED_ORIGINS" \ - --set-string "secrets.ssoToken=$CMS_SSO_TOKEN" + --set-string "secrets.ssoToken=$CMS_SSO_TOKEN" \ + --set-string "goLoose.clientID=$GO_LOOSE_CLIENT_ID" \ + --set-string "goLoose.clientSecret=$GO_LOOSE_CLIENT_SECRET" + if [ -n "$LOCAL_CA_CERT" ]; then + set -- "$@" --set-string goLoose.ca.existingSecret=local-ca + fi if [ -n "$CMS_SSO_HEADER" ]; then set -- "$@" --set-string "app.ssoHeader=$CMS_SSO_HEADER" fi if [ -n "$CMS_SSO_COOKIE" ]; then set -- "$@" --set-string "app.ssoCookie=$CMS_SSO_COOKIE" fi - helm upgrade --install cms deploy/helm/cms \ + helm upgrade --install go-tell deploy/helm/go-tell \ --namespace "$DEPLOY_NAMESPACE" \ --values "$values_file" \ --atomic \ @@ -143,6 +164,6 @@ jobs: - name: Verify deployment run: | - kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/cms \ + kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/go-tell \ --timeout=5m - helm test cms --namespace "$DEPLOY_NAMESPACE" --logs + helm test go-tell --namespace "$DEPLOY_NAMESPACE" --logs diff --git a/Dockerfile b/Dockerfile index 8bc3d10..078547d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,18 +9,19 @@ RUN npm run build -- --outDir /src/ui/dist --emptyOutDir # Compile the Go binary with the built frontend embedded. FROM golang:1.27-alpine AS backend WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ +RUN go mod download COPY cmd/ ./cmd/ COPY internal/ ./internal/ COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/ -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cms ./cmd/server +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/go-tell ./cmd/server # Runtime: one static binary, no database, no shell tooling. FROM alpine:3.20 RUN apk add --no-cache ca-certificates tzdata \ - && adduser -D -u 10001 cms -COPY --from=backend /out/cms /usr/local/bin/cms -USER cms + && adduser -D -u 10001 go-tell +COPY --from=backend /out/go-tell /usr/local/bin/go-tell +USER go-tell EXPOSE 8080 ENV CMS_HTTP_ADDR=:8080 # Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits @@ -28,4 +29,4 @@ ENV CMS_HTTP_ADDR=:8080 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ CMD wget -qO- http://127.0.0.1:8080/health || exit 1 -ENTRYPOINT ["/usr/local/bin/cms"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/go-tell"] \ No newline at end of file diff --git a/Makefile b/Makefile index a460f2d..c2bf19d 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,27 @@ SHELL := /bin/sh -IMAGE ?= cms +IMAGE ?= go-tell TAG ?= latest -REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/cms +REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell HELM ?= helm -HELM_CHART := deploy/helm/cms -HELM_RELEASE ?= cms -HELM_NAMESPACE ?= cms -KUBECONFIG_DEV ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml -KUBECONFIG_PRD ?= $(HOME)/.config/kubectl/rancher.urpi.be.yaml +HELM_CHART := deploy/helm/go-tell +HELM_RELEASE ?= go-tell +HELM_NAMESPACE ?= go-tell +# The development and production releases share one cluster, so both point at the +# same kubeconfig and only the namespace and the values profile differ. +KUBECONFIG ?= $(HOME)/.kube/config +# Secret holding the platform CA bundle the pod needs to reach auth.dev. Create it +# per namespace with kubectl create secret generic local-ca --from-file=ca.crt=... +GO_LOOSE_CA_SECRET ?= local-ca -.PHONY: help ui frontend backend demo production run test check docker docker-push docker-run clean rancher-storage helm-lint helm-deploy-development helm-deploy-production helm-down-development helm-down-production helm-proxy-development helm-proxy-production +# Helm receives the Go Loose credentials from the environment instead of a values +# file. make sources the gitignored .env first, so a configured checkout only has +# to run the target. +GO_LOOSE_SET = $(if $(GO_LOOSE_CLIENT_ID),--set-string goLoose.clientID=$(GO_LOOSE_CLIENT_ID)) \ + $(if $(GO_LOOSE_CLIENT_SECRET),--set-string goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET)) \ + $(if $(GO_LOOSE_CA_SECRET),--set-string goLoose.ca.existingSecret=$(GO_LOOSE_CA_SECRET)) + +.PHONY: help ui frontend backend demo production run test check docker docker-push docker-run clean ingress-nginx-install cert-manager-install cluster-prepare helm-lint helm-deploy-development helm-deploy-production helm-down-development helm-down-production helm-proxy-development helm-proxy-production help: @echo "make ui build the React app into cmd/server/web/dist" @@ -25,7 +36,9 @@ help: @echo "make docker-push build and push the image to the private registry" @echo "make docker-run run the image on :8080" @echo "make clean remove build output" - @echo "make rancher-storage make rancher storage" + @echo "make ingress-nginx-install install the ingress controller in the cluster" + @echo "make cert-manager-install install cert-manager in the cluster" + @echo "make cluster-prepare prepare the cluster for both releases" @echo "make helm-lint linting the helm charts" @echo "make helm-deploy-development deploy to development" @echo "make helm-deploy-production deploy to production" @@ -85,8 +98,17 @@ clean: rm -rf cmd/server/web/dist ui/dist mkdir -p cmd/server/web/dist printf 'Placeholder so the Go embed directive resolves before the frontend is built.\nRun `make ui` to replace this directory with the real React build.\n' > cmd/server/web/dist/placeholder.txt -rancher-storage: - KUBECONFIG="$(KUBECONFIG_DEV)" kubectl apply -f deploy/rancher/local-path.yaml +ingress-nginx-install: + kubectl --kubeconfig "$(KUBECONFIG)" apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.14.1/deploy/static/provider/kind/deploy.yaml + kubectl --kubeconfig "$(KUBECONFIG)" -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=5m + +cert-manager-install: + kubectl --kubeconfig "$(KUBECONFIG)" apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml + kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m + kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m + kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager --timeout=5m + +cluster-prepare: ingress-nginx-install cert-manager-install helm-lint: $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml @@ -94,27 +116,36 @@ helm-lint: $(HELM) template $(HELM_RELEASE) $(HELM_CHART) \ -f $(HELM_CHART)/values-development.yaml >/dev/null $(HELM) template $(HELM_RELEASE) $(HELM_CHART) \ - -f $(HELM_CHART)/values-production.yaml >/dev/null + -f $(HELM_CHART)/values-production.yaml \ + --set-string goLoose.clientID=lint --set-string goLoose.clientSecret=lint >/dev/null +# Go Loose credentials are never stored in the values files. Pass them on the +# command line, from a secret store, or with --set-file: +# make helm-deploy-production \ +# --set goLoose.clientID=$(GO_LOOSE_CLIENT_ID) \ +# --set goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET) \ +# --set goLoose.ca.existingSecret=local-ca helm-deploy-development: - KUBECONFIG="$(KUBECONFIG_DEV)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ + KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ --namespace $(HELM_NAMESPACE)-development --create-namespace \ -f $(HELM_CHART)/values-development.yaml \ --rollback-on-failure --wait --timeout 10m helm-deploy-production: - KUBECONFIG="$(KUBECONFIG_PRD)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ + set -a; [ ! -f .env ] || . ./.env; set +a; \ + KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ --namespace $(HELM_NAMESPACE)-production --create-namespace \ -f $(HELM_CHART)/values-production.yaml \ + $(GO_LOOSE_SET) \ --rollback-on-failure --wait --timeout 10m helm-down-production: - KUBECONFIG="$(KUBECONFIG_PRD)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production + KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production helm-down-development: - KUBECONFIG="$(KUBECONFIG_DEV)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development + KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development helm-proxy-production: - KUBECONFIG="$(KUBECONFIG_DEV)" kubectl port-forward -n $(HELM_NAMESPACE)-production deployment/$(HELM_RELEASE) 31373:8080 --address 0.0.0.0 + kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-production service/$(HELM_RELEASE) 31373:80 --address 0.0.0.0 helm-proxy-development: - KUBECONFIG="$(KUBECONFIG_PRD)" kubectl port-forward -n $(HELM_NAMESPACE)-development deployment/$(HELM_RELEASE) 30010:8080 --address 0.0.0.0 \ No newline at end of file + kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-development service/$(HELM_RELEASE) 30010:80 --address 0.0.0.0 \ No newline at end of file diff --git a/README.md b/README.md index 4e41dcf..a1f907e 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,158 @@ -# CMS project +# Go Tell -This repository contains a Go backend and React frontend for the CMS. +Go Tell is a small content CMS: one Go binary that serves the API and embeds +the React frontend, backed by a JSON file. It is served from a single host, +`https://tell.dev`, and it uses [Go Loose](https://github.com/danyel/go-loose) +to decide who is allowed to edit. ## Backend The backend targets Go 1.27.1 and uses a layered design (`internal/web`, -`service`, and `security`). Content is seeded in memory and can be mirrored to -a JSON file configured through `CMS_CONTENT_FILE`. +`internal/service`, `internal/security`, `internal/golooseauth`). Content is +seeded in memory and can be mirrored to a JSON file configured through +`CMS_CONTENT_FILE`. ```sh go run ./cmd/server ``` -Configuration is read from environment variables (see `.env`). The API includes `GET /health`, `GET /api/auth/session`, `GET /api/protected`, `GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`, and `GET|PUT /api/content/:slug`. +Configuration is read from environment variables (see `.env.example`). The API +exposes `GET /health`, `GET /api/auth/session`, `GET /api/protected`, +`GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`, +and `GET|PUT /api/content/:slug`. -## Single sign-on +| Variable | Default | Purpose | +| --- | --- | --- | +| `CMS_HTTP_ADDR` | `:8080` | Listen address | +| `CMS_PROFILE` | `demo` | `demo` enables the in-memory seed and the sudo terminal, anything else requires an authenticated editor | +| `CMS_CONTENT_FILE` | empty | Path of the JSON file that stores edits; empty keeps everything in memory | +| `CMS_POSTGRES_URL` | empty | Reserved for the PostgreSQL persistence adapter | +| `CMS_ALLOWED_ORIGINS` | `http://localhost:5173` | `Access-Control-Allow-Origin` value | +| `CMS_SSO_HEADER` | `X-SSO-Token` | Identity header set by the proxy; empty disables proxy identity | +| `CMS_SSO_TOKEN` | empty | Shared secret the proxy value must match | +| `CMS_SSO_COOKIE` | empty | Cookie carrying the same value when the proxy sets no header | +| `GO_LOOSE_TENANT` | `tell` | Go Loose tenant that owns the login | +| `GO_LOOSE_AUTH_DOMAIN` | `auth.dev` | Identity domain shared by all Go Loose tenants | +| `GO_LOOSE_APP_DOMAIN` | `tell.dev` | Public host of the CMS, which also determines the redirect URI | +| `GO_LOOSE_CLIENT_ID` | empty | Client id of the Go Loose application | +| `GO_LOOSE_CLIENT_SECRET` | empty | Client secret of the Go Loose application | +| `GO_LOOSE_CA_FILE` | empty | Extra CA bundle for an internally signed identity domain | -The CMS never signs anyone in and stores no users, admins, or sessions. An identity-aware proxy (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and forwards a header on every accepted request: +## Authentication -- **Header present** → the request is the owner and can edit content (`canEdit: true`). -- **Header absent** → the request is anonymous and content is read-only (`canEdit: false`). +Go Tell accepts an editor from three sources, in this order: the demo sudo +token, the identity-aware proxy header, and the Go Loose browser session. +Reading content is always anonymous; `PUT /api/content/:slug` answers `401` +without one of them. -Configure the header with `CMS_SSO_HEADER` (default `X-SSO-Token`). Set `CMS_SSO_TOKEN` to a shared secret to require an exact value; leave it empty to trust any non-empty forwarded value. `CMS_SSO_COOKIE` optionally names a cookie fallback. +### Go Loose -Because the backend trusts the forwarded header, it must only be reachable through the proxy. Bind it to the loopback interface or a private network, and make sure the proxy strips any client-supplied copy of that header. +Set `GO_LOOSE_CLIENT_ID` and `GO_LOOSE_CLIENT_SECRET` and the CMS grows a +browser login: -Anonymous visitors get a **Sign in with Google** button in the header. It redirects the browser to `VITE_SSO_LOGIN_URL` (frontend setting), where the provider authenticates the owner and returns to the page; the CMS itself never performs an OAuth exchange and holds no client secret. Leave the setting empty to hide the button when the proxy gates the whole app automatically. +1. `GET /api/auth/login` redirects to + `https://./connect/authorize`. +2. Go Loose authenticates the user and redirects to + `https:///api/auth/callback`, which exchanges the + authorization code and stores the session in an HTTP-only cookie. +3. `GET /api/auth/session` then reports `authenticated: true`, the editor name, + and the login and logout endpoints the frontend follows. +4. `GET|POST /api/auth/logout` revokes the session at the identity provider and + clears the cookie. + +That redirect URI has to be registered on the Go Loose application, next to the +client id and secret. Without credentials the login routes answer `404` with +the variables to set, and the CMS falls back to the proxy identity. + +The local platform application lives in the Go Loose tenant `tell`, so the login +origin is `https://tell.auth.dev` and the interview accounts are +`interview@tell.auth.dev` and `reviewer@tell.auth.dev`. The one-time client +secret is recorded in the platform's gitignored `secrets/credentials.md` and in +the gitignored `.env` of this repository. + +### Proxy identity + +Without Go Loose, an identity-aware proxy (oauth2-proxy, Authelia, Cloudflare +Access, ...) can authenticate the owner and forward a header on every accepted +request: the header present means the request can edit, the header absent means +read-only. Because the backend trusts that header, it must only be reachable +through the proxy, and the proxy must strip any client-supplied copy of it. ## Content -Content supports one reusable category per document plus any number of badges. The anonymous `GET /api/content` endpoint accepts `category=engineering` and repeated `badge=go&badge=docker` filters; selected badges are combined with AND semantics. `GET /api/content/categories` returns the category dropdown values. The frontend applies these filters immediately when the category changes or when a badge is entered. - -Editing requires the SSO identity: `PUT /api/content/:slug` returns `401` without it. `GET /api/content/:slug` and the taxonomy endpoints are public. +Content supports one reusable category per document plus any number of badges. +The anonymous `GET /api/content` endpoint accepts `category=engineering` and +repeated `badge=go&badge=docker` filters; selected badges are combined with AND +semantics. `GET /api/content/categories` returns the category dropdown values. +The frontend applies these filters immediately when the category changes or when +a badge is entered. ```sh make backend # run the Go server make frontend # run the React app make run # build the frontend, then run the server +make check # gofmt, vet, and tests +make docker # build the all-in-one image ``` -## Rancher deployment +## Kubernetes deployment -The Helm chart at `deploy/helm/cms` provides two profiles: - -- `values-development.yaml` runs the demo profile on NodePort `30081` with - ephemeral content. -- `values-production.yaml` runs the seeded demo application on NodePort - `31373` with a persistent content volume. Host Nginx proxies `cms.urpi.be` - to this port. - -Validate or deploy either profile with: +The Helm chart at `deploy/helm/go-tell` deploys the single image to the k3s +cluster behind `ingress-nginx` and `cert-manager`, both of which have to be +present first: + +```sh +make cluster-prepare # install ingress-nginx and cert-manager +make helm-lint # lint and render both profiles +make helm-deploy-production # namespace go-tell-production +make helm-deploy-development # namespace go-tell-development +``` + +Go Loose credentials are never written into a values file. The production target +reads `GO_LOOSE_CLIENT_ID`, `GO_LOOSE_CLIENT_SECRET`, and `GO_LOOSE_CA_SECRET` +from the environment, after sourcing the gitignored `.env`: ```sh -make helm-lint -make helm-deploy-development make helm-deploy-production ``` -The deploy workflow automatically deploys a successful `main` image build to -production and also supports manual development or production deployments. -Create GitHub environments named `development` and `production`. Each needs -the secrets `RANCHER_KUBE_CONFIG_BASE64`, `REGISTRY_USERNAME`, and -`REGISTRY_PASSWORD`. `CMS_SSO_TOKEN` is optional. Environment variables -`RANCHER_NAMESPACE`, `CMS_ALLOWED_ORIGINS`, `CMS_SSO_HEADER`, and -`CMS_SSO_COOKIE` can override the chart defaults. \ No newline at end of file +They end up in the release secret and in the `--set-string` flags the target +builds. Override any of them on the command line when deploying from elsewhere: + +```sh +make helm-deploy-production \ + --set-string goLoose.clientID="$GO_LOOSE_CLIENT_ID" \ + --set-string goLoose.clientSecret="$GO_LOOSE_CLIENT_SECRET" \ + --set-string goLoose.ca.existingSecret=local-ca +``` + +`goLoose.ca.existingSecret` names the Secret holding the platform CA bundle, so +the pod trusts `tell.auth.dev`. It has to exist in the namespace before the +release starts: + +```sh +kubectl -n go-tell-production create secret generic local-ca \ + --from-file=ca.crt=~/sources/go/infrastructure/certs/local-ca.crt +``` + +`go-tell-production` is the profile that matters: one host, TLS from +cert-manager, and a persistent volume for the content file. `go-tell-development` +runs the demo profile on NodePort `30010` with ephemeral content and no Go +Loose login, which is what `make frontend` talks to. + +The earlier dedicated-host deployment (`cms.urpi.be` through host Nginx on a +Rancher cluster) is retired: `deploy/nginx` and `deploy/rancher` are gone, and +every Makefile target talks to `~/.kube/config`. + +## Continuous delivery + +`build-test-push.yml` publishes `batty1039.startdedicated.net:5000/go-tell` on +every push to `main`. `deploy-rancher.yml` deploys a successful build to +production and can also be dispatched by hand for either profile. Create the +GitHub environments `development` and `production` with the secrets +`RANCHER_KUBE_CONFIG_BASE64`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, +`GO_LOOSE_CLIENT_ID`, `GO_LOOSE_CLIENT_SECRET`, and optionally +`CMS_SSO_TOKEN` and `LOCAL_CA_CERT` (the internal CA bundle mounted through +`goLoose.ca.existingSecret`). The variables `RANCHER_NAMESPACE`, +`CMS_ALLOWED_ORIGINS`, `CMS_SSO_HEADER`, and `CMS_SSO_COOKIE` override the +chart defaults. \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 2050cce..b4dfe13 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,10 +7,11 @@ import ( "net/http" "time" - "github.com/danyel/go-cms/internal/config" - "github.com/danyel/go-cms/internal/security" - "github.com/danyel/go-cms/internal/service" - "github.com/danyel/go-cms/internal/web" + "github.com/danyel/go-tell/internal/config" + "github.com/danyel/go-tell/internal/golooseauth" + "github.com/danyel/go-tell/internal/security" + "github.com/danyel/go-tell/internal/service" + "github.com/danyel/go-tell/internal/web" ) // web/dist holds the compiled React app. A placeholder file is committed so the @@ -33,6 +34,10 @@ func main() { } sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie) + browser, err := golooseauth.New(cfg.GoLoose) + if err != nil { + log.Fatal(err) + } var demo *service.DemoAuth if cfg.Profile == "demo" { demo = service.NewDemoAuth() @@ -40,9 +45,14 @@ func main() { } else { log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "") } + if browser != nil { + log.Printf("Go Loose login enabled for tenant %q", cfg.GoLoose.Tenant) + } else { + log.Print("Go Loose login disabled: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET to enable it") + } server := &http.Server{ Addr: cfg.HTTPAddr, - Handler: web.NewHandler(content, assets, sso, demo).Routes(cfg.AllowedOrigins), + Handler: web.NewHandler(content, assets, sso, browser, demo).Routes(cfg.AllowedOrigins), ReadHeaderTimeout: 10 * time.Second, } log.Printf("CMS listening on %s", cfg.HTTPAddr) diff --git a/deploy/helm/cms/Chart.yaml b/deploy/helm/cms/Chart.yaml deleted file mode 100644 index 191b973..0000000 --- a/deploy/helm/cms/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: cms -description: CMS application -type: application -version: 0.1.0 -appVersion: "0.1.0" diff --git a/deploy/helm/cms/templates/NOTES.txt b/deploy/helm/cms/templates/NOTES.txt deleted file mode 100644 index 1323af0..0000000 --- a/deploy/helm/cms/templates/NOTES.txt +++ /dev/null @@ -1,12 +0,0 @@ -CMS has been installed in namespace {{ .Release.Namespace }}. - -{{- if .Values.ingress.enabled }} -Open the host configured in ingress.hosts. -{{- else if eq .Values.service.type "NodePort" }} -The application is exposed on cluster node port {{ .Values.service.nodePort }}. -Open it through the configured external reverse proxy. -{{- else }} -Run: - kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "cms.fullname" . }} 8080:{{ .Values.service.port }} -Then open http://127.0.0.1:8080. -{{- end }} diff --git a/deploy/helm/cms/templates/_helpers.tpl b/deploy/helm/cms/templates/_helpers.tpl deleted file mode 100644 index 1a79b2b..0000000 --- a/deploy/helm/cms/templates/_helpers.tpl +++ /dev/null @@ -1,33 +0,0 @@ -{{- define "cms.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{- define "cms.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{- define "cms.labels" -}} -helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} -app.kubernetes.io/name: {{ include "cms.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{- define "cms.selectorLabels" -}} -app.kubernetes.io/name: {{ include "cms.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{- define "cms.secretName" -}} -{{- printf "%s-secrets" (include "cms.fullname" .) }} -{{- end }} diff --git a/deploy/helm/cms/templates/ingress.yaml b/deploy/helm/cms/templates/ingress.yaml deleted file mode 100644 index 830fb50..0000000 --- a/deploy/helm/cms/templates/ingress.yaml +++ /dev/null @@ -1,35 +0,0 @@ -{{- if .Values.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ include "cms.fullname" . }} - labels: - {{- include "cms.labels" . | nindent 4 }} - {{- with .Values.ingress.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- with .Values.ingress.className }} - ingressClassName: {{ . }} - {{- end }} - {{- with .Values.ingress.tls }} - tls: - {{- toYaml . | nindent 4 }} - {{- end }} - rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host | quote }} - http: - paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ .pathType }} - backend: - service: - name: {{ include "cms.fullname" $ }} - port: - number: {{ $.Values.service.port }} - {{- end }} - {{- end }} -{{- end }} diff --git a/deploy/helm/cms/templates/secret.yaml b/deploy/helm/cms/templates/secret.yaml deleted file mode 100644 index 151a838..0000000 --- a/deploy/helm/cms/templates/secret.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "cms.secretName" . }} - labels: - {{- include "cms.labels" . | nindent 4 }} -type: Opaque -stringData: - sso-token: {{ .Values.secrets.ssoToken | quote }} diff --git a/deploy/helm/cms/values.yaml b/deploy/helm/cms/values.yaml deleted file mode 100644 index 6182449..0000000 --- a/deploy/helm/cms/values.yaml +++ /dev/null @@ -1,76 +0,0 @@ -nameOverride: "" -fullnameOverride: "" - -replicaCount: 1 - -image: - repository: batty1039.startdedicated.net:5000/cms - tag: latest - pullPolicy: IfNotPresent - -imagePullSecrets: [] - -app: - environment: production - allowedOrigins: http://localhost:8080 - contentFile: /data/content.json - postgresURL: "" - ssoHeader: X-SSO-Token - ssoCookie: "" - -secrets: - ssoToken: "" - -service: - type: ClusterIP - port: 80 - nodePort: null - -ingress: - enabled: false - className: nginx - annotations: {} - hosts: - - host: cms.local - paths: - - path: / - pathType: Prefix - tls: [] - -resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - -podAnnotations: {} -podLabels: {} - -podSecurityContext: - fsGroup: 10001 - fsGroupChangePolicy: OnRootMismatch - -securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 10001 - -strategy: - type: Recreate - -persistence: - enabled: true - size: 2Gi - accessModes: - - ReadWriteOnce - storageClass: "" - -nodeSelector: {} -tolerations: [] -affinity: {} diff --git a/deploy/helm/cms/.helmignore b/deploy/helm/go-tell/.helmignore similarity index 100% rename from deploy/helm/cms/.helmignore rename to deploy/helm/go-tell/.helmignore diff --git a/deploy/helm/go-tell/Chart.yaml b/deploy/helm/go-tell/Chart.yaml new file mode 100644 index 0000000..ccf3939 --- /dev/null +++ b/deploy/helm/go-tell/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: go-tell +description: Go Tell, a single-host content CMS behind Go Loose +type: application +version: 0.2.0 +appVersion: "0.1.0" \ No newline at end of file diff --git a/deploy/helm/go-tell/README.md b/deploy/helm/go-tell/README.md new file mode 100644 index 0000000..6417930 --- /dev/null +++ b/deploy/helm/go-tell/README.md @@ -0,0 +1,221 @@ +# Go Tell Helm chart + +This chart deploys Go Tell as one image: a Go binary that serves the API and +embeds the React frontend, with a persistent volume for the content file. It +publishes a single HTTPS host, `https://tell.dev`, behind ingress-nginx with a +certificate from cert-manager. + +## 1. What the chart creates + +| Resource | Name | Purpose | +|---|---|---| +| Deployment | `go-tell` | CMS binary, API and web UI in one container | +| Service | `go-tell` | Cluster-internal service, ingress backend | +| Ingress | `go-tell` | One rule and one TLS SAN for `tell.dev` | +| Issuer | `go-tell-selfsigned` | cert-manager self-signed issuer | +| Certificate | `go-tell-ca` | Chart CA that signs the leaf certificate | +| Certificate | `go-tell` | Leaf certificate for `tell.dev` | +| PersistentVolumeClaim | `go-tell` | Content file storage, `emptyDir` when disabled | +| Secret | `go-tell-secrets` | Go Loose client credentials and the proxy token | + +There are no tenants and no per-tenant hosts: one release, one host, one +content file. + +## 2. Prerequisites + +- A Kubernetes cluster reachable through `KUBECONFIG`, by default + `~/.kube/config`. +- A `StorageClass` when `persistence.enabled` is `true`. +- ingress-nginx with the `nginx` ingress class: `make ingress-nginx-install`. +- cert-manager when `ingress.tls.enabled` is `true`: `make cert-manager-install`. +- Cluster nodes trusting `batty1039.startdedicated.net:5000` as an insecure + registry, or an HTTPS registry. + +Run everything the chart depends on in one step: + +```bash +make cluster-prepare +``` + +## 3. Deploy + +```bash +make helm-lint +make helm-deploy-development +``` + +The development profile runs the demo profile on NodePort `30010` with +ephemeral content and Go Loose disabled, which is what `make frontend` talks to. +Production runs the persisted content file with browser sign-in: + +```bash +export GO_LOOSE_CLIENT_ID=... +export GO_LOOSE_CLIENT_SECRET=... +make helm-deploy-production +``` + +Credentials are never written into a values file. They reach the release +secret through `--set`, or through the `GO_LOOSE_CLIENT_ID` and +`GO_LOOSE_CLIENT_SECRET` secrets of the deploy workflow. The chart refuses to +render when `goLoose.enabled` is true and either of them is missing, because a +release without credentials would have no way to sign anybody in. + +After a successful install the release notes list the URL and the command that +exports the CA certificate. With the local platform, prefer +`docs/KUBERNETES-DEPLOYMENTS.md` in the infrastructure repository, which covers +`/etc/hosts` entries and CA trust for the whole platform. + +## 4. The single host + +`host` is the only host the release serves: + +```yaml +host: tell.dev +``` + +The chart derives, without any extra configuration: + +- One ingress rule for that host. +- One certificate SAN for that host, so a single secret covers the release. +- `GO_LOOSE_APP_DOMAIN`, which the API turns into the redirect URI + `https://tell.dev/api/auth/callback`. + +That redirect URI has to be registered on the Go Loose application together with +the client id and secret. `goLoose.tenant` names the Go Loose tenant that owns +the login, which decides where the browser is sent first: + +``` +https://tell.dev/api/auth/login + -> https://tell.auth.dev/connect/authorize + -> https://tell.dev/api/auth/callback +``` + +## 5. Values + +### Top level + +| Key | Default | Description | +|---|---|---| +| `nameOverride` | `""` | Overrides the chart name in resource names and labels | +| `fullnameOverride` | `""` | Replaces the generated resource name prefix | +| `host` | `tell.dev` | Public host of the CMS | +| `replicaCount` | `1` | CMS replicas, the content file is shared through the volume | +| `imagePullSecrets` | `[]` | Pull secrets added to the pod | +| `nodeSelector`, `tolerations`, `affinity` | empty | Scheduling of the deployment | + +### image and app + +| Key | Default | Description | +|---|---|---| +| `image.repository` | `batty1039.startdedicated.net:5000/go-tell` | CMS image | +| `image.tag` | `latest` | CMS image tag | +| `image.pullPolicy` | `IfNotPresent` | CMS pull policy | +| `app.profile` | `production` | `CMS_PROFILE`, `demo` enables the in-memory seed and the sudo terminal | +| `app.allowedOrigins` | `https://tell.dev` | `CMS_ALLOWED_ORIGINS` | +| `app.contentFile` | `/data/content.json` | `CMS_CONTENT_FILE`, ignored when persistence is disabled | +| `app.postgresURL` | `""` | `CMS_POSTGRES_URL`, reserved for the PostgreSQL adapter | +| `app.ssoHeader` | `X-SSO-Token` | `CMS_SSO_HEADER`, empty disables the proxy identity | +| `app.ssoCookie` | `""` | `CMS_SSO_COOKIE`, cookie carrying the same value | +| `app.resources` | `100m`/`128Mi` to `500m`/`512Mi` | Requests and limits | +| `app.podAnnotations`, `app.podLabels` | `{}` | Extra pod metadata | + +### goLoose + +| Key | Default | Description | +|---|---|---| +| `goLoose.enabled` | `true` | Injects the Go Loose environment into the pod | +| `goLoose.tenant` | `tell` | Go Loose tenant that owns the login, one DNS label | +| `goLoose.authDomain` | `auth.dev` | Identity domain shared by every Go Loose tenant | +| `goLoose.appDomain` | `tell.dev` | Public host, which determines the redirect URI | +| `goLoose.clientID` | `""` | Client id of the Go Loose application, required when enabled | +| `goLoose.clientSecret` | `""` | Client secret of the Go Loose application, required when enabled | +| `goLoose.ca.existingSecret` | `""` | Secret holding the CA bundle of an internally signed identity domain | +| `goLoose.ca.key` | `ca.crt` | Key projected from that secret | +| `goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target | +| `secrets.ssoToken` | `""` | Shared secret the proxy header value must match | + +The CA bundle is only mounted when `goLoose.ca.existingSecret` is set. A +publicly signed identity domain therefore needs no extra configuration, while an +internally signed one, such as the local platform, has to publish its +certificate first: + +```bash +kubectl -n go-tell-production create secret generic local-ca --from-file=ca.crt=certs/local-ca.crt +``` + +### ingress and certManager + +| Key | Default | Description | +|---|---|---| +| `ingress.enabled` | `true` | Creates the ingress | +| `ingress.className` | `nginx` | Ingress class of the controller | +| `ingress.annotations` | TLS redirect | Controller annotations | +| `ingress.path` | `/` | Path routed to the service | +| `ingress.pathType` | `Prefix` | Path type of that route | +| `ingress.tls.enabled` | `true` | Terminates TLS on the controller | +| `ingress.tls.secretName` | `""` | Defaults to `-tls` | +| `ingress.tls.existingSecret` | `""` | Uses a certificate managed outside the chart | +| `certManager.enabled` | `true` | Chart creates the certificates | +| `certManager.issuerRef` | `{}` | Uses an existing issuer instead of the chart CA | +| `certManager.duration` | `8760h` | Certificate lifetime | +| `certManager.renewBefore` | `720h` | Renewal window | +| `certManager.caSecretName` | `""` | Defaults to `-ca` | + +Four certificate objects are created: a self-signed `Issuer`, a CA +`Certificate` stored in `caSecretName`, an `Issuer` that serves that CA, and the +leaf `Certificate` stored in the ingress TLS secret for `host`. Set +`certManager.issuerRef` to sign with a cluster issuer instead: + +```yaml +certManager: + issuerRef: + name: internal-acme + kind: ClusterIssuer + group: cert-manager.io +``` + +Set `ingress.tls.existingSecret` to use a certificate the chart does not own, +and `certManager.enabled: false` to stop emitting cert-manager objects. + +### Storage and pod security + +| Key | Default | Description | +|---|---|---| +| `persistence.enabled` | `true` | Persistent volume for the content file | +| `persistence.size` | `2Gi` | Volume size | +| `persistence.accessModes` | `[ReadWriteOnce]` | Volume access modes | +| `persistence.storageClass` | `""` | Storage class, the cluster default when empty | +| `podSecurityContext` | `fsGroup: 10001` | Pod security context, gives the volume to the image user | +| `securityContext` | non-root `10001`, read-only root filesystem | Container security context | +| `strategy.type` | `Recreate` | The old pod releases the volume before the new one starts | + +The image runs as its own `go-tell` user (uid `10001`) and declares no +`HOME`, so `readOnlyRootFilesystem` needs the writable `/tmp` mount the chart +adds. + +## 6. Upgrade and remove + +```bash +make helm-deploy-production +helm -n go-tell-production history go-tell +helm -n go-tell-production rollback go-tell 1 +make helm-down-production +``` + +Keep the Go Loose credentials outside the cluster: uninstalling removes the +release secret, and Go Loose shows a new secret only once. + +## 7. Verify + +```bash +kubectl -n go-tell-production get ingress go-tell +kubectl -n go-tell-production get certificate +kubectl -n go-tell-production rollout status deployment/go-tell +curl --resolve tell.dev:443:127.0.0.1 --cacert go-tell-ca.crt https://tell.dev/health +curl -I --cacert go-tell-ca.crt https://tell.dev/api/auth/login +``` + +The login request must answer `302` towards +`https://tell.auth.dev/connect/authorize`; a `404` means the release has no Go +Loose credentials. The test pod installed by `helm test` checks the health +endpoint from inside the cluster. \ No newline at end of file diff --git a/deploy/helm/go-tell/templates/NOTES.txt b/deploy/helm/go-tell/templates/NOTES.txt new file mode 100644 index 0000000..921dc9e --- /dev/null +++ b/deploy/helm/go-tell/templates/NOTES.txt @@ -0,0 +1,35 @@ +Go Tell is installed in namespace {{ .Release.Namespace }}. + +{{- if .Values.ingress.enabled }} + +Open https://{{ .Values.host }}/. + +{{- if .Values.goLoose.enabled }} + +Signing in goes through Go Loose: + 1. https://{{ .Values.goLoose.tenant }}.{{ .Values.goLoose.authDomain }}/connect/authorize redirects to + https://{{ .Values.goLoose.appDomain }}/api/auth/callback. + 2. That redirect URI has to be registered on the Go Loose application, next + to client id {{ .Values.goLoose.clientID }}. +{{- end }} +{{- if .Values.ingress.tls.enabled }} + +{{- if .Values.certManager.enabled }} +{{- if not .Values.certManager.issuerRef }} +The certificate is issued by the self-signed chain in this release. Export the +root once so the browser trusts it: + kubectl -n {{ .Release.Namespace }} get secret {{ include "go-tell.certManagerCASecretName" . }} \ + -o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt +{{- end }} +{{- end }} +{{- end }} +{{- else if eq .Values.service.type "NodePort" }} + +The application is exposed on cluster node port {{ .Values.service.nodePort }}. +Open it through the configured external reverse proxy. +{{- else }} + +Run: + kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-tell.fullname" . }} 8080:{{ .Values.service.port }} +Then open http://127.0.0.1:8080. +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-tell/templates/_helpers.tpl b/deploy/helm/go-tell/templates/_helpers.tpl new file mode 100644 index 0000000..93e0776 --- /dev/null +++ b/deploy/helm/go-tell/templates/_helpers.tpl @@ -0,0 +1,77 @@ +{{- define "go-tell.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "go-tell.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "go-tell.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "go-tell.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "go-tell.selectorLabels" -}} +app.kubernetes.io/name: {{ include "go-tell.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "go-tell.secretName" -}} +{{- printf "%s-secrets" (include "go-tell.fullname" .) }} +{{- end }} + +{{- define "go-tell.certManagerCASecretName" -}} +{{- default (printf "%s-ca" (include "go-tell.fullname" .)) .Values.certManager.caSecretName }} +{{- end }} + +{{- define "go-tell.tlsSecretName" -}} +{{- default (printf "%s-tls" (include "go-tell.fullname" .)) .Values.ingress.tls.secretName }} +{{- end }} + +{{- define "go-tell.certManagerIssuerRef" -}} +{{- if kindIs "map" .Values.certManager.issuerRef }} +name: {{ required "certManager.issuerRef.name is required when certManager.issuerRef is set" .Values.certManager.issuerRef.name }} +kind: {{ default "Issuer" .Values.certManager.issuerRef.kind }} +group: {{ default "cert-manager.io" .Values.certManager.issuerRef.group }} +{{- else }} +name: {{ .Values.certManager.issuerRef }} +kind: Issuer +group: cert-manager.io +{{- end }} +{{- end }} + +{{/* +CA bundle used to verify the identity domain. Mounted only when the operator +points goLoose.ca.existingSecret at a certificate, so a publicly signed identity +domain needs no extra configuration. +*/}} +{{- define "go-tell.caVolume" -}} +{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }} +- name: go-loose-ca + secret: + secretName: {{ .Values.goLoose.ca.existingSecret }} + items: + - key: {{ .Values.goLoose.ca.key }} + path: {{ base .Values.goLoose.ca.mountPath }} +{{- end }} +{{- end }} + +{{- define "go-tell.caVolumeMount" -}} +{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }} +- name: go-loose-ca + mountPath: {{ dir .Values.goLoose.ca.mountPath }} + readOnly: true +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/cms/templates/app-deployment.yaml b/deploy/helm/go-tell/templates/app-deployment.yaml similarity index 67% rename from deploy/helm/cms/templates/app-deployment.yaml rename to deploy/helm/go-tell/templates/app-deployment.yaml index bee05cb..2949aac 100644 --- a/deploy/helm/cms/templates/app-deployment.yaml +++ b/deploy/helm/go-tell/templates/app-deployment.yaml @@ -1,16 +1,16 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "cms.fullname" . }} + name: {{ include "go-tell.fullname" . }} labels: - {{- include "cms.labels" . | nindent 4 }} + {{- include "go-tell.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} strategy: type: {{ .Values.strategy.type }} selector: matchLabels: - {{- include "cms.selectorLabels" . | nindent 6 }} + {{- include "go-tell.selectorLabels" . | nindent 6 }} app.kubernetes.io/component: application template: metadata: @@ -20,7 +20,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} labels: - {{- include "cms.selectorLabels" . | nindent 8 }} + {{- include "go-tell.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: application {{- with .Values.podLabels }} {{- toYaml . | nindent 8 }} @@ -34,14 +34,14 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - - name: cms + - name: go-tell image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} env: - name: CMS_PROFILE - value: {{ .Values.app.environment | quote }} + value: {{ .Values.app.profile | quote }} - name: CMS_ALLOWED_ORIGINS value: {{ .Values.app.allowedOrigins | quote }} - name: CMS_CONTENT_FILE @@ -55,8 +55,30 @@ spec: - name: CMS_SSO_TOKEN valueFrom: secretKeyRef: - name: {{ include "cms.secretName" . }} + name: {{ include "go-tell.secretName" . }} key: sso-token + {{- if .Values.goLoose.enabled }} + - name: GO_LOOSE_TENANT + value: {{ .Values.goLoose.tenant | quote }} + - name: GO_LOOSE_AUTH_DOMAIN + value: {{ .Values.goLoose.authDomain | quote }} + - name: GO_LOOSE_APP_DOMAIN + value: {{ .Values.goLoose.appDomain | quote }} + - name: GO_LOOSE_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ include "go-tell.secretName" . }} + key: go-loose-client-id + - name: GO_LOOSE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "go-tell.secretName" . }} + key: go-loose-client-secret + {{- if .Values.goLoose.ca.existingSecret }} + - name: GO_LOOSE_CA_FILE + value: {{ .Values.goLoose.ca.mountPath | quote }} + {{- end }} + {{- end }} ports: - name: http containerPort: 8080 @@ -86,14 +108,16 @@ spec: - name: content mountPath: /data {{- end }} + {{- include "go-tell.caVolumeMount" . | nindent 12 }} volumes: - name: temporary-files emptyDir: {} {{- if .Values.persistence.enabled }} - name: content persistentVolumeClaim: - claimName: {{ include "cms.fullname" . }} + claimName: {{ include "go-tell.fullname" . }} {{- end }} + {{- include "go-tell.caVolume" . | nindent 8 }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -105,4 +129,4 @@ spec: {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/cms/templates/app-service.yaml b/deploy/helm/go-tell/templates/app-service.yaml similarity index 72% rename from deploy/helm/cms/templates/app-service.yaml rename to deploy/helm/go-tell/templates/app-service.yaml index 659ed29..ddc717d 100644 --- a/deploy/helm/cms/templates/app-service.yaml +++ b/deploy/helm/go-tell/templates/app-service.yaml @@ -1,13 +1,13 @@ apiVersion: v1 kind: Service metadata: - name: {{ include "cms.fullname" . }} + name: {{ include "go-tell.fullname" . }} labels: - {{- include "cms.labels" . | nindent 4 }} + {{- include "go-tell.labels" . | nindent 4 }} spec: type: {{ .Values.service.type }} selector: - {{- include "cms.selectorLabels" . | nindent 4 }} + {{- include "go-tell.selectorLabels" . | nindent 4 }} app.kubernetes.io/component: application ports: - name: http diff --git a/deploy/helm/go-tell/templates/certificate.yaml b/deploy/helm/go-tell/templates/certificate.yaml new file mode 100644 index 0000000..1a75edb --- /dev/null +++ b/deploy/helm/go-tell/templates/certificate.yaml @@ -0,0 +1,74 @@ +{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled (not .Values.ingress.tls.existingSecret) }} +{{- $fullname := include "go-tell.fullname" . }} +{{- $caSecret := include "go-tell.certManagerCASecretName" . }} +{{- $tlsSecret := include "go-tell.tlsSecretName" . }} +{{- if not .Values.certManager.issuerRef }} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ $fullname }}-selfsigned + labels: + {{- include "go-tell.labels" . | nindent 4 }} + app.kubernetes.io/component: certificate +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $fullname }}-ca + labels: + {{- include "go-tell.labels" . | nindent 4 }} + app.kubernetes.io/component: certificate +spec: + secretName: {{ $caSecret }} + commonName: {{ printf "%s-ca" $fullname }} + duration: {{ .Values.certManager.duration }} + renewBefore: {{ .Values.certManager.renewBefore }} + privateKey: + algorithm: ECDSA + size: 256 + issuerRef: + name: {{ $fullname }}-selfsigned + kind: Issuer + group: cert-manager.io +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ $fullname }}-ca + labels: + {{- include "go-tell.labels" . | nindent 4 }} + app.kubernetes.io/component: certificate +spec: + ca: + secretName: {{ $caSecret }} +--- +{{- end }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $fullname }} + labels: + {{- include "go-tell.labels" . | nindent 4 }} + app.kubernetes.io/component: certificate +spec: + secretName: {{ $tlsSecret }} + duration: {{ .Values.certManager.duration }} + renewBefore: {{ .Values.certManager.renewBefore }} + privateKey: + algorithm: ECDSA + size: 256 + usages: + - server auth + dnsNames: + - {{ .Values.host | quote }} + issuerRef: + {{- if .Values.certManager.issuerRef }} + {{- include "go-tell.certManagerIssuerRef" . | nindent 4 }} + {{- else }} + name: {{ $fullname }}-ca + kind: Issuer + group: cert-manager.io + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-tell/templates/ingress.yaml b/deploy/helm/go-tell/templates/ingress.yaml new file mode 100644 index 0000000..295d54a --- /dev/null +++ b/deploy/helm/go-tell/templates/ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "go-tell.fullname" . }} + labels: + {{- include "go-tell.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.host | quote }} + secretName: {{ .Values.ingress.tls.existingSecret | default (include "go-tell.tlsSecretName" .) }} + {{- end }} + rules: + - host: {{ .Values.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "go-tell.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/cms/templates/persistent-volume-claim.yaml b/deploy/helm/go-tell/templates/persistent-volume-claim.yaml similarity index 79% rename from deploy/helm/cms/templates/persistent-volume-claim.yaml rename to deploy/helm/go-tell/templates/persistent-volume-claim.yaml index 6f04017..b05a0e5 100644 --- a/deploy/helm/cms/templates/persistent-volume-claim.yaml +++ b/deploy/helm/go-tell/templates/persistent-volume-claim.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: {{ include "cms.fullname" . }} + name: {{ include "go-tell.fullname" . }} labels: - {{- include "cms.labels" . | nindent 4 }} + {{- include "go-tell.labels" . | nindent 4 }} spec: accessModes: {{- toYaml .Values.persistence.accessModes | nindent 4 }} diff --git a/deploy/helm/go-tell/templates/secret.yaml b/deploy/helm/go-tell/templates/secret.yaml new file mode 100644 index 0000000..5c1f159 --- /dev/null +++ b/deploy/helm/go-tell/templates/secret.yaml @@ -0,0 +1,20 @@ +{{- $name := include "go-tell.secretName" . -}} +{{- if and .Values.goLoose.enabled (not .Values.goLoose.clientID) }} +{{- fail "goLoose.clientID is required when goLoose.enabled is true" }} +{{- end }} +{{- if and .Values.goLoose.enabled (not .Values.goLoose.clientSecret) }} +{{- fail "goLoose.clientSecret is required when goLoose.enabled is true" }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "go-tell.labels" . | nindent 4 }} +type: Opaque +data: + sso-token: {{ .Values.secrets.ssoToken | b64enc | quote }} + {{- if .Values.goLoose.enabled }} + go-loose-client-id: {{ .Values.goLoose.clientID | b64enc | quote }} + go-loose-client-secret: {{ .Values.goLoose.clientSecret | b64enc | quote }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/cms/templates/tests/test-connection.yaml b/deploy/helm/go-tell/templates/tests/test-connection.yaml similarity index 61% rename from deploy/helm/cms/templates/tests/test-connection.yaml rename to deploy/helm/go-tell/templates/tests/test-connection.yaml index 10c7b51..b2d324a 100644 --- a/deploy/helm/cms/templates/tests/test-connection.yaml +++ b/deploy/helm/go-tell/templates/tests/test-connection.yaml @@ -1,9 +1,9 @@ apiVersion: v1 kind: Pod metadata: - name: "{{ include "cms.fullname" . }}-test-connection" + name: "{{ include "go-tell.fullname" . }}-test-connection" labels: - {{- include "cms.labels" . | nindent 4 }} + {{- include "go-tell.labels" . | nindent 4 }} annotations: helm.sh/hook: test helm.sh/hook-delete-policy: before-hook-creation @@ -17,4 +17,4 @@ spec: - wget args: - -qO- - - "http://{{ include "cms.fullname" . }}:{{ .Values.service.port }}/health" + - "http://{{ include "go-tell.fullname" . }}:{{ .Values.service.port }}/health" diff --git a/deploy/helm/cms/values-development.yaml b/deploy/helm/go-tell/values-development.yaml similarity index 87% rename from deploy/helm/cms/values-development.yaml rename to deploy/helm/go-tell/values-development.yaml index 0e2fe0f..51eebc2 100644 --- a/deploy/helm/cms/values-development.yaml +++ b/deploy/helm/go-tell/values-development.yaml @@ -1,12 +1,14 @@ image: - tag: latest pullPolicy: Always app: - environment: demo + profile: demo allowedOrigins: http://localhost:30010 contentFile: "" +goLoose: + enabled: false + service: type: NodePort nodePort: 30010 @@ -23,4 +25,4 @@ resources: memory: 384Mi persistence: - enabled: false + enabled: false \ No newline at end of file diff --git a/deploy/helm/cms/values-production.yaml b/deploy/helm/go-tell/values-production.yaml similarity index 59% rename from deploy/helm/cms/values-production.yaml rename to deploy/helm/go-tell/values-production.yaml index 41baa39..6145d5a 100644 --- a/deploy/helm/cms/values-production.yaml +++ b/deploy/helm/go-tell/values-production.yaml @@ -1,18 +1,13 @@ image: - tag: latest pullPolicy: IfNotPresent app: - environment: demo - allowedOrigins: https://cms.urpi.be + profile: production + allowedOrigins: https://tell.dev contentFile: /data/content.json service: - type: NodePort - nodePort: 31373 - -ingress: - enabled: false + type: ClusterIP resources: requests: @@ -24,4 +19,4 @@ resources: persistence: enabled: true - size: 5Gi + size: 5Gi \ No newline at end of file diff --git a/deploy/helm/go-tell/values.yaml b/deploy/helm/go-tell/values.yaml new file mode 100644 index 0000000..ba9a6a8 --- /dev/null +++ b/deploy/helm/go-tell/values.yaml @@ -0,0 +1,116 @@ +nameOverride: "" +fullnameOverride: "" + +# Public host of the CMS. There is one release, one host, and no tenant +# routing: every visitor reaches the same content and the same editor. +host: tell.dev + +replicaCount: 1 + +image: + repository: batty1039.startdedicated.net:5000/go-tell + tag: latest + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +app: + # Demo keeps content in memory and offers the sudo terminal. Production serves + # the persisted file and requires an authenticated editor. + profile: production + allowedOrigins: https://tell.dev + contentFile: /data/content.json + postgresURL: "" + # Single-sign-on proxy identity, kept as a fallback when Go Loose is not + # configured. An empty header disables it. + ssoHeader: X-SSO-Token + ssoCookie: "" + +goLoose: + enabled: true + # Go Loose tenant that owns the login, the shared identity domain, and the + # public host of the CMS. Together they produce the base URL + # https://. and the redirect URI + # https:///api/auth/callback that has to be registered in Go Loose. + tenant: tell + authDomain: auth.dev + appDomain: tell.dev + ca: + # Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it when the + # identity domain is signed by a private authority. + existingSecret: "" + key: ca.crt + mountPath: /certs/local-ca.crt + clientID: "" + clientSecret: "" + +secrets: + # Shared secret the identity-aware proxy must present in the SSO header. Empty + # accepts any non-empty forwarded value. + ssoToken: "" + +service: + type: ClusterIP + port: 80 + nodePort: null + +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + path: / + pathType: Prefix + tls: + enabled: true + # Use a certificate you already manage instead of the chart-issued one. + existingSecret: "" + # Defaults to -tls when empty and certManager.enabled is true. + secretName: "" + +certManager: + # Requires cert-manager in the cluster: make cert-manager-install. + enabled: true + # Reuse an existing cert-manager issuer instead of creating a self-signed one. + issuerRef: {} + duration: 8760h + renewBefore: 720h + caSecretName: "" + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + +strategy: + type: Recreate + +persistence: + enabled: true + size: 2Gi + accessModes: + - ReadWriteOnce + storageClass: "" + +nodeSelector: {} +tolerations: [] +affinity: {} \ No newline at end of file diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md deleted file mode 100644 index 4754e2f..0000000 --- a/deploy/nginx/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Dedicated-server reverse proxy - -Production exposes the CMS application through Kubernetes NodePort `31373`. - -## DNS - -In one.com, create an `A` record for `cms.urpi.be` pointing to the dedicated -server's public IPv4 address. Add an `AAAA` record only when the server and its -firewall are configured for public IPv6. Wait for: - -```bash -dig +short cms.urpi.be -``` - -to return the dedicated server address before requesting a certificate. - -## Nginx and Certbot - -The included virtual host assumes Nginx runs on the Kubernetes node and can -reach the NodePort at `127.0.0.1:31373`. - -```bash -sudo install -m 0644 deploy/nginx/cms.urpi.be.conf \ - /etc/nginx/sites-available/cms.urpi.be.conf -sudo ln -s /etc/nginx/sites-available/cms.urpi.be.conf \ - /etc/nginx/sites-enabled/cms.urpi.be.conf -sudo nginx -t -sudo systemctl reload nginx -sudo certbot --nginx -d cms.urpi.be -sudo certbot renew --dry-run -``` - -Certbot adds the HTTPS listener, certificate paths, and HTTP-to-HTTPS redirect -to this virtual host. Allow inbound TCP ports `80` and `443`. Do not expose -NodePort `31373` to the public internet. If Nginx is not on a Kubernetes node, replace -`127.0.0.1` in `proxy_pass` with a private cluster-node address and allow -`31373` only from the Nginx server. - -## Deploy and verify - -Deploy the production profile: - -```bash -make helm-deploy-production -kubectl -n cms-production get pods -kubectl -n cms-production get service cms -curl --fail http://127.0.0.1:31373/health -curl --fail https://cms.urpi.be/health -``` - -The `cms` service should show `80:31373/TCP`. diff --git a/deploy/nginx/cms.urpi.be.conf b/deploy/nginx/cms.urpi.be.conf deleted file mode 100644 index ea0edae..0000000 --- a/deploy/nginx/cms.urpi.be.conf +++ /dev/null @@ -1,21 +0,0 @@ -server { - listen 80; - listen [::]:80; - server_name cms.urpi.be; - - client_max_body_size 10m; - - location / { - proxy_pass http://127.0.0.1:31373; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Connection ""; - proxy_buffering off; - proxy_request_buffering off; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - } -} diff --git a/deploy/rancher/local-path.yaml b/deploy/rancher/local-path.yaml deleted file mode 100644 index 6cf033d..0000000 --- a/deploy/rancher/local-path.yaml +++ /dev/null @@ -1,188 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: local-path-storage - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: local-path-provisioner-service-account - namespace: local-path-storage - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: local-path-provisioner-role - namespace: local-path-storage -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch", "create", "patch", "update", "delete"] - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: local-path-provisioner-role -rules: - - apiGroups: [""] - resources: ["nodes", "persistentvolumeclaims", "configmaps", "pods", "pods/log"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["persistentvolumes"] - verbs: ["get", "list", "watch", "create", "patch", "update", "delete"] - - apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch"] - - apiGroups: ["storage.k8s.io"] - resources: ["storageclasses"] - verbs: ["get", "list", "watch"] - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: local-path-provisioner-bind - namespace: local-path-storage -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: local-path-provisioner-role -subjects: - - kind: ServiceAccount - name: local-path-provisioner-service-account - namespace: local-path-storage - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: local-path-provisioner-bind -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: local-path-provisioner-role -subjects: - - kind: ServiceAccount - name: local-path-provisioner-service-account - namespace: local-path-storage - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: local-path-provisioner - namespace: local-path-storage -spec: - replicas: 1 - selector: - matchLabels: - app: local-path-provisioner - template: - metadata: - labels: - app: local-path-provisioner - spec: - serviceAccountName: local-path-provisioner-service-account - containers: - - name: local-path-provisioner - image: docker.io/rancher/local-path-provisioner:v0.0.37 - imagePullPolicy: IfNotPresent - command: - - local-path-provisioner - - --debug - - start - - --config - - /etc/config/config.json - ports: - - name: health - containerPort: 8080 - protocol: TCP - startupProbe: - httpGet: - path: /health - port: health - initialDelaySeconds: 5 - periodSeconds: 5 - failureThreshold: 12 - livenessProbe: - httpGet: - path: /health - port: health - initialDelaySeconds: 10 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /ready - port: health - initialDelaySeconds: 5 - periodSeconds: 5 - volumeMounts: - - name: config-volume - mountPath: /etc/config/ - env: - - name: POD_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: CONFIG_MOUNT_PATH - value: /etc/config/ - - name: HEALTH_PORT - value: "8080" - volumes: - - name: config-volume - configMap: - name: local-path-config - ---- -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: local-path - annotations: - storageclass.kubernetes.io/is-default-class: "true" -provisioner: rancher.io/local-path -volumeBindingMode: WaitForFirstConsumer -reclaimPolicy: Retain - ---- -kind: ConfigMap -apiVersion: v1 -metadata: - name: local-path-config - namespace: local-path-storage -data: - config.json: |- - { - "nodePathMap":[ - { - "node":"DEFAULT_PATH_FOR_NON_LISTED_NODES", - "paths":["/var/lib/rancher/k3s/storage"] - } - ] - } - setup: |- - #!/bin/sh - set -eu - mkdir -m 0777 -p "$VOL_DIR" - teardown: |- - #!/bin/sh - set -eu - rm -rf "$VOL_DIR" - helperPod.yaml: |- - apiVersion: v1 - kind: Pod - metadata: - name: helper-pod - spec: - priorityClassName: system-node-critical - tolerations: - - key: node.kubernetes.io/disk-pressure - operator: Exists - effect: NoSchedule - containers: - - name: helper-pod - image: docker.io/library/busybox - imagePullPolicy: IfNotPresent diff --git a/deploy/rancher/registries.yaml b/deploy/rancher/registries.yaml deleted file mode 100644 index da1e7e4..0000000 --- a/deploy/rancher/registries.yaml +++ /dev/null @@ -1,9 +0,0 @@ -mirrors: - "batty1039.startdedicated.net:5000": - endpoint: - - "http://batty1039.startdedicated.net:5000" - -configs: - "batty1039.startdedicated.net:5000": - tls: - insecure_skip_verify: true diff --git a/go.mod b/go.mod index 2f04374..b74740b 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,13 @@ -module github.com/danyel/go-cms +module github.com/danyel/go-tell go 1.27.1 require ( + github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1 github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.9.2 + github.com/jackc/pgx/v5 v5.11.0 github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 github.com/pressly/goose/v3 v3.26.0 github.com/rabbitmq/amqp091-go v1.15.0 @@ -72,8 +73,8 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index db3a4bc..4d575ca 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1 h1:hGywTGxVZoxwpSovWdzPHU6oRcQvbCwOJXaBpnynXeM= +github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= @@ -26,6 +28,7 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -69,12 +72,14 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUS go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 83835ad..d2f87ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ -// Package config carries the runtime settings for the demo CMS. There is no +// Package config carries the runtime settings for the CMS. There is no // database to configure: content is seeded in memory and optionally mirrored to -// a single JSON file. +// a single JSON file. Editing rights come from Go Loose, from the single-sign-on +// proxy, or from the demo profile, depending on what is configured. package config import "os" @@ -8,8 +9,26 @@ import "os" type Config struct { HTTPAddr, Profile, ContentFile, PostgresURL string AllowedOrigins, SSOHeader, SSOToken, SSOCookie string + GoLoose GoLooseConfig } +// GoLooseConfig describes the Go Loose hosted-login integration. Go Tell is +// served from a single host, so one client application covers every request: +// Tenant picks the Go Loose tenant that owns the login, AuthDomain is the +// identity domain shared by all tenants, and AppDomain is the public host of +// the CMS, which also determines the registered redirect URI. +type GoLooseConfig struct { + Tenant string + AuthDomain string + AppDomain string + CAFile string + ClientID string + ClientSecret string +} + +// Enabled reports whether Go Loose client credentials are configured. +func (g GoLooseConfig) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" } + func Load() Config { return Config{ HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), @@ -20,6 +39,14 @@ func Load() Config { SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"), SSOToken: get("CMS_SSO_TOKEN", ""), SSOCookie: get("CMS_SSO_COOKIE", ""), + GoLoose: GoLooseConfig{ + Tenant: get("GO_LOOSE_TENANT", "tell"), + AuthDomain: get("GO_LOOSE_AUTH_DOMAIN", "auth.dev"), + AppDomain: get("GO_LOOSE_APP_DOMAIN", "tell.dev"), + CAFile: os.Getenv("GO_LOOSE_CA_FILE"), + ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"), + ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"), + }, } } diff --git a/internal/golooseauth/browser.go b/internal/golooseauth/browser.go new file mode 100644 index 0000000..54319af --- /dev/null +++ b/internal/golooseauth/browser.go @@ -0,0 +1,142 @@ +// Package golooseauth builds the Go Loose hosted-login client used by the CMS. +package golooseauth + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + goloose "github.com/danyel/go-loose/client" + + "github.com/danyel/go-tell/internal/config" +) + +const ( + // SessionCookie holds the Go Loose session token after a successful login. + SessionCookie = "go_loose_user_session" + // LoginPath starts the browser login flow. + LoginPath = "/api/auth/login" + // CallbackPath is the redirect URI registered with Go Loose. + CallbackPath = "/api/auth/callback" + // LogoutPath ends the browser session, locally and at the identity provider. + LogoutPath = "/api/auth/logout" +) + +// Auth is the Go Loose browser client plus the session lookup the API needs. +// The login, callback, and logout handlers come from the Go Loose client; this +// type only adds a non-redirecting way to turn a request into a user, because +// the client middleware answers anonymous requests with a redirect and the CMS +// has to answer its own API with a status code. +type Auth struct { + *goloose.BrowserAuth + baseURL string + httpClient *http.Client +} + +// New returns the browser client for the configured Go Loose application, or a +// nil client when no credentials are set, which leaves the CMS with its other +// identity sources. ClientID and ClientSecret are the values Go Loose shows once +// when a client application is configured. +func New(cfg config.GoLooseConfig) (*Auth, error) { + if !cfg.Enabled() { + return nil, nil + } + tenant := domain(cfg.Tenant) + if tenant == "" || strings.Contains(tenant, ".") { + return nil, fmt.Errorf("GO_LOOSE_TENANT must be a single lowercase tenant slug") + } + authDomain := domain(cfg.AuthDomain) + if authDomain == "" { + return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured") + } + appDomain := domain(cfg.AppDomain) + if appDomain == "" { + return nil, fmt.Errorf("GO_LOOSE_APP_DOMAIN is required when client credentials are configured") + } + httpClient, err := client(cfg.CAFile) + if err != nil { + return nil, err + } + baseURL := "https://" + tenant + "." + authDomain + browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{ + BaseURL: baseURL, + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: "https://" + appDomain + CallbackPath, + AfterLoginURL: "/", + AfterLogoutURL: "/", + LoginPath: LoginPath, + CookieName: SessionCookie, + CookieSecure: true, + HTTPClient: httpClient, + }) + if err != nil { + return nil, err + } + return &Auth{BrowserAuth: browser, baseURL: baseURL, httpClient: httpClient}, nil +} + +// Tenant reports the Go Loose tenant that owns the login. +func (a *Auth) Tenant() string { return a.baseURL } + +// SessionUser resolves the Go Loose session cookie of a request into a user. +// Requests without a cookie, with a rejected session, or without a reachable +// identity provider are simply not signed in. +func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) { + if a == nil { + return goloose.User{}, false + } + cookie, err := r.Cookie(SessionCookie) + if err != nil || cookie.Value == "" { + return goloose.User{}, false + } + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.baseURL+"/connect/userinfo", nil) + if err != nil { + return goloose.User{}, false + } + request.Header.Set("Authorization", "Bearer "+cookie.Value) + response, err := a.httpClient.Do(request) + if err != nil { + return goloose.User{}, false + } + defer response.Body.Close() + var user goloose.User + if response.StatusCode != http.StatusOK || + json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil { + return goloose.User{}, false + } + return user, true +} + +// domain normalizes a configured host or tenant slug. +func domain(value string) string { + return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".") +} + +// client returns the HTTP client used to talk to Go Loose, trusting the extra +// certificate authorities in caFile when one is configured. It is needed while +// the identity domain is served by an internal certificate authority. +func client(caFile string) (*http.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + if caFile != "" { + pem, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read Go Loose CA: %w", err) + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("parse Go Loose CA %s", caFile) + } + transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + } + return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil +} diff --git a/internal/golooseauth/browser_test.go b/internal/golooseauth/browser_test.go new file mode 100644 index 0000000..07b357a --- /dev/null +++ b/internal/golooseauth/browser_test.go @@ -0,0 +1,150 @@ +package golooseauth + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + goloose "github.com/danyel/go-loose/client" + + "github.com/danyel/go-tell/internal/config" +) + +func TestNewWithoutCredentialsIsDisabled(t *testing.T) { + auth, err := New(config.GoLooseConfig{Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev"}) + if err != nil || auth != nil { + t.Fatalf("New() = %#v, %v; want a disabled client", auth, err) + } + auth, err = New(config.GoLooseConfig{ClientID: "client"}) + if err != nil || auth != nil { + t.Fatalf("New() with a half-configured client = %#v, %v; want a disabled client", auth, err) + } +} + +func TestNewRejectsInvalidDomains(t *testing.T) { + cases := map[string]config.GoLooseConfig{ + "tenant with a dot": {Tenant: "tell.dev", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"}, + "empty tenant": {Tenant: " ", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"}, + "empty auth domain": {Tenant: "tell", ClientID: "c", ClientSecret: "s"}, + "empty app domain": {Tenant: "tell", AuthDomain: "auth.dev", ClientID: "c", ClientSecret: "s"}, + } + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + if _, err := New(cfg); err == nil { + t.Fatal("New() error = nil, want a configuration error") + } + }) + } +} + +func TestNewBuildsSingleHostBrowserClient(t *testing.T) { + auth, err := New(config.GoLooseConfig{ + Tenant: " Tell ", AuthDomain: ".auth.dev.", AppDomain: "Tell.Dev", + ClientID: "client", ClientSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + if auth.Tenant() != "https://tell.auth.dev" { + t.Fatalf("Tenant() = %q", auth.Tenant()) + } + + recorder := httptest.NewRecorder() + auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, LoginPath, nil)) + if recorder.Code != http.StatusFound { + t.Fatalf("login status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + target, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if target.Scheme+"://"+target.Host != "https://tell.auth.dev" || target.Path != "/connect/authorize" { + t.Fatalf("unexpected login target: %s", target) + } + if target.Query().Get("client_id") != "client" || + target.Query().Get("redirect_uri") != "https://tell.dev"+CallbackPath || + target.Query().Get("state") == "" { + t.Fatalf("unexpected login query: %s", target.RawQuery) + } + cookies := recorder.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name == SessionCookie || !cookies[0].HttpOnly { + t.Fatalf("expected an HTTP-only state cookie scoped to the callback, got %#v", cookies) + } +} + +func TestSessionUserResolvesCookie(t *testing.T) { + identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer session-token" { + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`)) + })) + defer identity.Close() + + auth := testAuth(t, identity) + request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil) + if _, ok := auth.SessionUser(request); ok { + t.Fatal("SessionUser() without a cookie = true, want false") + } + if _, ok := auth.SessionUser(httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)); ok { + t.Fatal("SessionUser() without a cookie = true, want false") + } +} + +func TestSessionUserRejectsRejectedSession(t *testing.T) { + identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no such session", http.StatusUnauthorized) + })) + defer identity.Close() + + auth := testAuth(t, identity) + request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil) + request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "expired"}) + if user, ok := auth.SessionUser(request); ok { + t.Fatalf("SessionUser() with a rejected session = %#v, want no user", user) + } +} + +func TestSessionUserReturnsIdentity(t *testing.T) { + identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`)) + })) + defer identity.Close() + + auth := testAuth(t, identity) + request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil) + request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "session-token"}) + user, ok := auth.SessionUser(request) + if !ok { + t.Fatal("SessionUser() = false, want the session user") + } + if user.Email != "owner@tell.dev" || user.DisplayName != "Owner" || user.TenantSlug != "tell" { + t.Fatalf("unexpected user: %#v", user) + } +} + +func TestNilAuthHasNoSession(t *testing.T) { + var auth *Auth + if user, ok := auth.SessionUser(httptest.NewRequest(http.MethodGet, "/", nil)); ok { + t.Fatalf("SessionUser() on a disabled client = %#v, want no user", user) + } +} + +// testAuth wires an Auth to a local identity server, which is what the client +// would talk to once the base URL points somewhere reachable in a test. +func testAuth(t *testing.T, identity *httptest.Server) *Auth { + t.Helper() + browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{ + BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret", + RedirectURL: "https://tell.dev" + CallbackPath, LoginPath: LoginPath, + CookieName: SessionCookie, HTTPClient: identity.Client(), + }) + if err != nil { + t.Fatal(err) + } + return &Auth{BrowserAuth: browser, baseURL: identity.URL, httpClient: identity.Client()} +} diff --git a/internal/security/token_test.go b/internal/security/token_test.go index eaedb61..5ce6bc7 100644 --- a/internal/security/token_test.go +++ b/internal/security/token_test.go @@ -33,9 +33,9 @@ func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) { } func TestSSOTokenVerifierCookieFallback(t *testing.T) { - v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session") + v := NewSSOTokenVerifier("X-SSO-Token", "", "go_tell_session") r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "cms_session", Value: "cookie-token"}) + r.AddCookie(&http.Cookie{Name: "go_tell_session", Value: "cookie-token"}) if !v.Authenticated(r) { t.Fatal("cookie token must be authenticated") } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 42afa6f..fa0fd66 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -8,22 +8,27 @@ import ( "net/http" "strings" - "github.com/danyel/go-cms/internal/security" - "github.com/danyel/go-cms/internal/service" + goloose "github.com/danyel/go-loose/client" + + "github.com/danyel/go-tell/internal/golooseauth" + "github.com/danyel/go-tell/internal/security" + "github.com/danyel/go-tell/internal/service" ) // Handler serves the CMS API and the embedded React app from one binary. -// Reading content is anonymous; every mutation requires the identity asserted by -// the upstream SSO proxy. +// Reading content is anonymous; every mutation requires an authenticated +// editor: a Go Loose browser session, the identity asserted by the upstream +// SSO proxy, or a demo user. type Handler struct { content service.IContentService assets fs.FS sso security.ITokenVerifier + browser *golooseauth.Auth demo *service.DemoAuth } -func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler { - h := &Handler{content: content, assets: assets, sso: sso} +func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler { + h := &Handler{content: content, assets: assets, sso: sso, browser: browser} if len(demo) > 0 { h.demo = demo[0] } @@ -35,6 +40,9 @@ func (h *Handler) Routes(origins string) http.Handler { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"}) }) + mux.HandleFunc(golooseauth.LoginPath, h.login) + mux.HandleFunc(golooseauth.CallbackPath, h.callback) + mux.HandleFunc(golooseauth.LogoutPath, h.logout) mux.HandleFunc("/api/auth/session", h.session) mux.HandleFunc("/api/auth/sudo", h.sudo) mux.HandleFunc("/api/auth/users", h.users) @@ -48,9 +56,51 @@ func (h *Handler) Routes(origins string) http.Handler { return cors(mux, origins, h.sso.HeaderName()) } +// session reports who the caller is. When Go Loose is configured the response +// also carries the login and logout endpoints so the UI can offer the browser +// login instead of a proxy redirect. func (h *Handler) session(w http.ResponseWriter, r *http.Request) { username, authenticated := h.identity(r) - jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}) + response := map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username} + if user, ok := h.browserUser(r); ok { + response["email"] = user.Email + response["displayName"] = user.DisplayName + } + if h.browser != nil { + response["loginUrl"] = golooseauth.LoginPath + response["logoutUrl"] = golooseauth.LogoutPath + } + jsonWrite(w, http.StatusOK, response) +} + +func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + if h.browser == nil { + writeAuthUnavailable(w) + return + } + h.browser.LoginHandler(w, r) +} + +func (h *Handler) callback(w http.ResponseWriter, r *http.Request) { + if h.browser == nil { + writeAuthUnavailable(w) + return + } + h.browser.CallbackHandler(w, r) +} + +func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { + if h.browser == nil { + writeAuthUnavailable(w) + return + } + h.browser.LogoutHandler(w, r) +} + +func writeAuthUnavailable(w http.ResponseWriter) { + jsonWrite(w, http.StatusNotFound, map[string]string{ + "error": "Go Loose login is not configured: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET, then restart the server", + }) } func (h *Handler) protected(w http.ResponseWriter, r *http.Request) { @@ -182,6 +232,9 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) { } } +// identity resolves the editor behind a request. The demo profile wins, then +// the SSO proxy, then the Go Loose browser session: the session is checked last +// because it costs a call to the identity provider. func (h *Handler) identity(r *http.Request) (string, bool) { if h.demo != nil { token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) @@ -192,9 +245,31 @@ func (h *Handler) identity(r *http.Request) (string, bool) { if h.sso.Authenticated(r) { return "sso-owner", true } + if user, ok := h.browserUser(r); ok { + return editorName(user), true + } return "", false } +// browserUser returns the Go Loose user carried by the request session, if any. +func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) { + if h.browser == nil { + return goloose.User{}, false + } + user, ok := h.browser.SessionUser(r) + if !ok { + return goloose.User{}, false + } + return user, true +} + +func editorName(user goloose.User) string { + if name := strings.TrimSpace(user.DisplayName); name != "" { + return name + } + return user.Email +} + // singlePageApp serves the embedded build and falls back to index.html so the // client-side router owns every non-API path. func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go index 500d68b..b48f9c9 100644 --- a/internal/web/handlers_test.go +++ b/internal/web/handlers_test.go @@ -8,8 +8,10 @@ import ( "testing" "testing/fstest" - "github.com/danyel/go-cms/internal/security" - "github.com/danyel/go-cms/internal/service" + "github.com/danyel/go-tell/internal/config" + "github.com/danyel/go-tell/internal/golooseauth" + "github.com/danyel/go-tell/internal/security" + "github.com/danyel/go-tell/internal/service" ) type fakeContent struct{ updated bool } @@ -31,12 +33,16 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont return c, nil } -func newTestRoutes(content *fakeContent) http.Handler { +func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler { assets := fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("
")}, - "assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")}, + "assets/index.js": &fstest.MapFile{Data: []byte("console.log('go-tell')")}, } - return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173") + var auth *golooseauth.Auth + if len(browser) > 0 { + auth = browser[0] + } + return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", ""), auth).Routes("http://localhost:5173") } func TestAnonymousCanReadButNotEdit(t *testing.T) { @@ -89,6 +95,68 @@ func TestProxyIdentityCanEdit(t *testing.T) { } } +func TestGoLooseLoginRedirectsToTheIdentityProvider(t *testing.T) { + routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t)) + + response := httptest.NewRecorder() + routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, golooseauth.LoginPath, nil)) + if response.Code != http.StatusFound { + t.Fatalf("login status = %d, body = %s", response.Code, response.Body.String()) + } + location := response.Header().Get("Location") + if !strings.HasPrefix(location, "https://tell.auth.dev/connect/authorize?") || + !strings.Contains(location, "redirect_uri=https%3A%2F%2Ftell.dev%2Fapi%2Fauth%2Fcallback") { + t.Fatalf("unexpected login redirect: %s", location) + } +} + +func TestSessionAdvertisesGoLooseEndpoints(t *testing.T) { + routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t)) + + response := httptest.NewRecorder() + routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)) + if response.Code != http.StatusOK { + t.Fatalf("session status = %d", response.Code) + } + body := response.Body.String() + for _, want := range []string{`"authenticated":false`, `"loginUrl":"/api/auth/login"`, `"logoutUrl":"/api/auth/logout"`} { + if !strings.Contains(body, want) { + t.Fatalf("session = %s, want %s", body, want) + } + } +} + +func TestSessionWithoutGoLooseHasNoLoginURL(t *testing.T) { + response := httptest.NewRecorder() + newTestRoutes(&fakeContent{}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)) + if strings.Contains(response.Body.String(), "loginUrl") { + t.Fatalf("session = %s, want no login endpoint", response.Body.String()) + } +} + +func TestGoLooseRoutesAreNotFoundWhenUnconfigured(t *testing.T) { + routes := newTestRoutes(&fakeContent{}) + for _, path := range []string{golooseauth.LoginPath, golooseauth.CallbackPath, golooseauth.LogoutPath} { + response := httptest.NewRecorder() + routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) + if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "GO_LOOSE_CLIENT_ID") { + t.Fatalf("%s without Go Loose = %d %s", path, response.Code, response.Body.String()) + } + } +} + +func testGoLooseAuth(t *testing.T) *golooseauth.Auth { + t.Helper() + auth, err := golooseauth.New(config.GoLooseConfig{ + Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev", + ClientID: "glc_tell", ClientSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + return auth +} + func TestServesEmbeddedAppForClientRoutes(t *testing.T) { routes := newTestRoutes(&fakeContent{}) diff --git a/ui/app/package.json b/ui/app/package.json index 8765b58..067a95b 100644 --- a/ui/app/package.json +++ b/ui/app/package.json @@ -1,5 +1,5 @@ { - "name": "cms-ui", + "name": "go-tell-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/ui/app/src/api.ts b/ui/app/src/api.ts index 6b1bedd..0e79f65 100644 --- a/ui/app/src/api.ts +++ b/ui/app/src/api.ts @@ -54,7 +54,7 @@ export async function requestSession(): Promise { } function demoAuthHeaders(): Record { - const token = window.sessionStorage.getItem('cms-token') + const token = window.sessionStorage.getItem('go-tell-token') return token ? {Authorization: `Bearer ${token}`} : {} } @@ -99,7 +99,7 @@ async function contentRequest(path = '', init?: RequestInit): Promise { headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers }, }) if (!response.ok) { - if (response.status === 401) throw new Error('Your SSO session is required to edit content.') + if (response.status === 401) throw new Error('You have to sign in to edit content.') throw new Error((await response.text()) || 'Unable to load content.') } return (await response.json()) as T diff --git a/ui/app/src/auth.tsx b/ui/app/src/auth.tsx index 0bab508..bde60a1 100644 --- a/ui/app/src/auth.tsx +++ b/ui/app/src/auth.tsx @@ -1,9 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' -import { apiConfig, requestSession, signInWithGoogle, sudoLogin } from './api' +import { apiConfig, apiUrl, requestSession, signInWithGoogle, sudoLogin } from './api' -// There is no sign-in or sign-out: the upstream SSO proxy decides whether the -// current request carries the owner's identity. Signing in just sends the -// browser to the configured Google/IdP URL. +// Signing in is either the Go Loose browser login or a redirect to the +// identity-aware proxy, depending on what the API advertises: /api/auth/session +// returns loginUrl and logoutUrl when Go Loose is configured. The demo profile +// keeps using its own token in sessionStorage. type AuthState = { loading: boolean authenticated: boolean @@ -12,6 +13,7 @@ type AuthState = { error: string | null refresh: () => Promise canSignIn: boolean + canSignOut: boolean signIn: () => void sudo: (username: string, password: string) => Promise signOut: () => void @@ -23,6 +25,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [authenticated, setAuthenticated] = useState(false) const [canEdit, setCanEdit] = useState(false) const [username, setUsername] = useState() + const [loginUrl, setLoginUrl] = useState('') + const [logoutUrl, setLogoutUrl] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -33,10 +37,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAuthenticated(session?.authenticated === true) setCanEdit(session?.canEdit === true) setUsername(session?.username) + setLoginUrl(session?.loginUrl ?? '') + setLogoutUrl(session?.logoutUrl ?? '') } catch (err) { setAuthenticated(false) setCanEdit(false) - setError(err instanceof Error ? err.message : 'Unable to check your SSO session.') + setError(err instanceof Error ? err.message : 'Unable to check your session.') } finally { setLoading(false) } @@ -44,25 +50,36 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { void refresh() }, [refresh]) - const signIn = useCallback(() => { signInWithGoogle() }, []) + const signIn = useCallback(() => { + if (loginUrl) { + window.location.assign(apiUrl(loginUrl)) + return + } + signInWithGoogle() + }, [loginUrl]) const sudo = useCallback(async (username: string, password: string) => { const result = await sudoLogin(username, password) - window.sessionStorage.setItem('cms-token', result.token) + window.sessionStorage.setItem('go-tell-token', result.token) setAuthenticated(true) setCanEdit(true) setUsername(result.user.username) }, []) const signOut = useCallback(() => { - window.sessionStorage.removeItem('cms-token') + if (logoutUrl) { + window.location.assign(apiUrl(logoutUrl)) + return + } + window.sessionStorage.removeItem('go-tell-token') setAuthenticated(false) setCanEdit(false) setUsername(undefined) - }, []) - const canSignIn = apiConfig.signIn !== '' + }, [logoutUrl]) + const canSignIn = apiConfig.signIn !== '' || loginUrl !== '' + const canSignOut = logoutUrl !== '' const value = useMemo( - () => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut }), - [loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut], + () => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }), + [loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut], ) return {children} } diff --git a/ui/app/src/components/Shell.tsx b/ui/app/src/components/Shell.tsx index a984a7d..526c4b5 100644 --- a/ui/app/src/components/Shell.tsx +++ b/ui/app/src/components/Shell.tsx @@ -6,7 +6,7 @@ import {type DemoUser} from '../models' import {SudoTerminal} from './SudoTerminal' export function Shell({children}: {children: ReactNode}) { - const {authenticated, loading, canSignIn, signIn, username, sudo} = useAuth() + const {authenticated, loading, canSignIn, canSignOut, signIn, signOut, username, sudo} = useAuth() const [sudoOpen, setSudoOpen] = useState(false) const [helpOpen, setHelpOpen] = useState(false) const [users, setUsers] = useState([]) @@ -24,7 +24,10 @@ export function Shell({children}: {children: ReactNode}) { _linux · go · java · ideas
{!loading &&