deploy configuration

This commit is contained in:
Daniel Noulet 2026-10-03 04:41:31 +02:00
parent 9ba3c47d82
commit dac02c61c3
45 changed files with 1405 additions and 582 deletions

View file

@ -21,3 +21,14 @@ CMS_SSO_HEADER=X-SSO-Token
CMS_SSO_TOKEN= CMS_SSO_TOKEN=
# Optional cookie fallback when the proxy sets a cookie instead of a header. # Optional cookie fallback when the proxy sets a cookie instead of a header.
CMS_SSO_COOKIE= 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=

View file

@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
IMAGE_NAME: batty1039.startdedicated.net:5000/cms IMAGE_NAME: batty1039.startdedicated.net:5000/go-tell
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:

View file

@ -26,7 +26,7 @@ permissions:
contents: read contents: read
concurrency: 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 cancel-in-progress: false
jobs: jobs:
@ -47,6 +47,9 @@ jobs:
CMS_ALLOWED_ORIGINS: ${{ vars.CMS_ALLOWED_ORIGINS }} CMS_ALLOWED_ORIGINS: ${{ vars.CMS_ALLOWED_ORIGINS }}
CMS_SSO_HEADER: ${{ vars.CMS_SSO_HEADER }} CMS_SSO_HEADER: ${{ vars.CMS_SSO_HEADER }}
CMS_SSO_COOKIE: ${{ vars.CMS_SSO_COOKIE }} 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 }} RANCHER_NAMESPACE: ${{ vars.RANCHER_NAMESPACE }}
steps: steps:
@ -79,7 +82,11 @@ jobs:
echo "REGISTRY_USERNAME and REGISTRY_PASSWORD are required" >&2 echo "REGISTRY_USERNAME and REGISTRY_PASSWORD are required" >&2
exit 1 exit 1
fi 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 if [ -n "$PUBLISHED_SHA" ]; then
image_tag="$PUBLISHED_SHA" image_tag="$PUBLISHED_SHA"
else else
@ -88,9 +95,9 @@ jobs:
allowed_origins="$CMS_ALLOWED_ORIGINS" allowed_origins="$CMS_ALLOWED_ORIGINS"
if [ -z "$allowed_origins" ]; then if [ -z "$allowed_origins" ]; then
if [ "$TARGET_ENVIRONMENT" = "production" ]; then if [ "$TARGET_ENVIRONMENT" = "production" ]; then
allowed_origins="https://cms.urpi.be" allowed_origins="https://tell.dev"
else else
allowed_origins="http://localhost:30081" allowed_origins="http://localhost:30010"
fi fi
fi fi
{ {
@ -112,28 +119,42 @@ jobs:
set -eu set -eu
kubectl create namespace "$DEPLOY_NAMESPACE" \ kubectl create namespace "$DEPLOY_NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f - --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-server=batty1039.startdedicated.net:5000 \
--docker-username="$REGISTRY_USERNAME" \ --docker-username="$REGISTRY_USERNAME" \
--docker-password="$REGISTRY_PASSWORD" \ --docker-password="$REGISTRY_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f - --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 - name: Deploy Helm release
run: | run: |
set -eu set -eu
values_file="deploy/helm/cms/values-$TARGET_ENVIRONMENT.yaml" values_file="deploy/helm/go-tell/values-$TARGET_ENVIRONMENT.yaml"
set -- \ set -- \
--set-string "image.tag=$IMAGE_TAG" \ --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 "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 if [ -n "$CMS_SSO_HEADER" ]; then
set -- "$@" --set-string "app.ssoHeader=$CMS_SSO_HEADER" set -- "$@" --set-string "app.ssoHeader=$CMS_SSO_HEADER"
fi fi
if [ -n "$CMS_SSO_COOKIE" ]; then if [ -n "$CMS_SSO_COOKIE" ]; then
set -- "$@" --set-string "app.ssoCookie=$CMS_SSO_COOKIE" set -- "$@" --set-string "app.ssoCookie=$CMS_SSO_COOKIE"
fi fi
helm upgrade --install cms deploy/helm/cms \ helm upgrade --install go-tell deploy/helm/go-tell \
--namespace "$DEPLOY_NAMESPACE" \ --namespace "$DEPLOY_NAMESPACE" \
--values "$values_file" \ --values "$values_file" \
--atomic \ --atomic \
@ -143,6 +164,6 @@ jobs:
- name: Verify deployment - name: Verify deployment
run: | run: |
kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/cms \ kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/go-tell \
--timeout=5m --timeout=5m
helm test cms --namespace "$DEPLOY_NAMESPACE" --logs helm test go-tell --namespace "$DEPLOY_NAMESPACE" --logs

View file

@ -9,18 +9,19 @@ RUN npm run build -- --outDir /src/ui/dist --emptyOutDir
# Compile the Go binary with the built frontend embedded. # Compile the Go binary with the built frontend embedded.
FROM golang:1.27-alpine AS backend FROM golang:1.27-alpine AS backend
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/ COPY cmd/ ./cmd/
COPY internal/ ./internal/ COPY internal/ ./internal/
COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/ 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. # Runtime: one static binary, no database, no shell tooling.
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \ RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 10001 cms && adduser -D -u 10001 go-tell
COPY --from=backend /out/cms /usr/local/bin/cms COPY --from=backend /out/go-tell /usr/local/bin/go-tell
USER cms USER go-tell
EXPOSE 8080 EXPOSE 8080
ENV CMS_HTTP_ADDR=:8080 ENV CMS_HTTP_ADDR=:8080
# Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits # 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"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1 CMD wget -qO- http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/cms"] ENTRYPOINT ["/usr/local/bin/go-tell"]

View file

@ -1,16 +1,27 @@
SHELL := /bin/sh SHELL := /bin/sh
IMAGE ?= cms IMAGE ?= go-tell
TAG ?= latest TAG ?= latest
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/cms REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
HELM ?= helm HELM ?= helm
HELM_CHART := deploy/helm/cms HELM_CHART := deploy/helm/go-tell
HELM_RELEASE ?= cms HELM_RELEASE ?= go-tell
HELM_NAMESPACE ?= cms HELM_NAMESPACE ?= go-tell
KUBECONFIG_DEV ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml # The development and production releases share one cluster, so both point at the
KUBECONFIG_PRD ?= $(HOME)/.config/kubectl/rancher.urpi.be.yaml # 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: help:
@echo "make ui build the React app into cmd/server/web/dist" @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-push build and push the image to the private registry"
@echo "make docker-run run the image on :8080" @echo "make docker-run run the image on :8080"
@echo "make clean remove build output" @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-lint linting the helm charts"
@echo "make helm-deploy-development deploy to development" @echo "make helm-deploy-development deploy to development"
@echo "make helm-deploy-production deploy to production" @echo "make helm-deploy-production deploy to production"
@ -85,8 +98,17 @@ clean:
rm -rf cmd/server/web/dist ui/dist rm -rf cmd/server/web/dist ui/dist
mkdir -p cmd/server/web/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 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: ingress-nginx-install:
KUBECONFIG="$(KUBECONFIG_DEV)" kubectl apply -f deploy/rancher/local-path.yaml 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) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
@ -94,27 +116,36 @@ helm-lint:
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \ $(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
-f $(HELM_CHART)/values-development.yaml >/dev/null -f $(HELM_CHART)/values-development.yaml >/dev/null
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \ $(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: 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 \ --namespace $(HELM_NAMESPACE)-development --create-namespace \
-f $(HELM_CHART)/values-development.yaml \ -f $(HELM_CHART)/values-development.yaml \
--rollback-on-failure --wait --timeout 10m --rollback-on-failure --wait --timeout 10m
helm-deploy-production: 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 \ --namespace $(HELM_NAMESPACE)-production --create-namespace \
-f $(HELM_CHART)/values-production.yaml \ -f $(HELM_CHART)/values-production.yaml \
$(GO_LOOSE_SET) \
--rollback-on-failure --wait --timeout 10m --rollback-on-failure --wait --timeout 10m
helm-down-production: 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: 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: 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: helm-proxy-development:
KUBECONFIG="$(KUBECONFIG_PRD)" kubectl port-forward -n $(HELM_NAMESPACE)-development deployment/$(HELM_RELEASE) 30010:8080 --address 0.0.0.0 kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-development service/$(HELM_RELEASE) 30010:80 --address 0.0.0.0

160
README.md
View file

@ -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 ## Backend
The backend targets Go 1.27.1 and uses a layered design (`internal/web`, 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 `internal/service`, `internal/security`, `internal/golooseauth`). Content is
a JSON file configured through `CMS_CONTENT_FILE`. seeded in memory and can be mirrored to a JSON file configured through
`CMS_CONTENT_FILE`.
```sh ```sh
go run ./cmd/server 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`). Go Tell accepts an editor from three sources, in this order: the demo sudo
- **Header absent** → the request is anonymous and content is read-only (`canEdit: false`). 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://<GO_LOOSE_TENANT>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`.
2. Go Loose authenticates the user and redirects to
`https://<GO_LOOSE_APP_DOMAIN>/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
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. Content supports one reusable category per document plus any number of badges.
The anonymous `GET /api/content` endpoint accepts `category=engineering` and
Editing requires the SSO identity: `PUT /api/content/:slug` returns `401` without it. `GET /api/content/:slug` and the taxonomy endpoints are public. 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 ```sh
make backend # run the Go server make backend # run the Go server
make frontend # run the React app make frontend # run the React app
make run # build the frontend, then run the server 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: 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
- `values-development.yaml` runs the demo profile on NodePort `30081` with present first:
ephemeral content.
- `values-production.yaml` runs the seeded demo application on NodePort ```sh
`31373` with a persistent content volume. Host Nginx proxies `cms.urpi.be` make cluster-prepare # install ingress-nginx and cert-manager
to this port. make helm-lint # lint and render both profiles
make helm-deploy-production # namespace go-tell-production
Validate or deploy either profile with: 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 ```sh
make helm-lint
make helm-deploy-development
make helm-deploy-production make helm-deploy-production
``` ```
The deploy workflow automatically deploys a successful `main` image build to They end up in the release secret and in the `--set-string` flags the target
production and also supports manual development or production deployments. builds. Override any of them on the command line when deploying from elsewhere:
Create GitHub environments named `development` and `production`. Each needs
the secrets `RANCHER_KUBE_CONFIG_BASE64`, `REGISTRY_USERNAME`, and ```sh
`REGISTRY_PASSWORD`. `CMS_SSO_TOKEN` is optional. Environment variables make helm-deploy-production \
`RANCHER_NAMESPACE`, `CMS_ALLOWED_ORIGINS`, `CMS_SSO_HEADER`, and --set-string goLoose.clientID="$GO_LOOSE_CLIENT_ID" \
`CMS_SSO_COOKIE` can override the chart defaults. --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.

View file

@ -7,10 +7,11 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/danyel/go-cms/internal/config" "github.com/danyel/go-tell/internal/config"
"github.com/danyel/go-cms/internal/security" "github.com/danyel/go-tell/internal/golooseauth"
"github.com/danyel/go-cms/internal/service" "github.com/danyel/go-tell/internal/security"
"github.com/danyel/go-cms/internal/web" "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 // 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) 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 var demo *service.DemoAuth
if cfg.Profile == "demo" { if cfg.Profile == "demo" {
demo = service.NewDemoAuth() demo = service.NewDemoAuth()
@ -40,9 +45,14 @@ func main() {
} else { } else {
log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "") 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{ server := &http.Server{
Addr: cfg.HTTPAddr, 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, ReadHeaderTimeout: 10 * time.Second,
} }
log.Printf("CMS listening on %s", cfg.HTTPAddr) log.Printf("CMS listening on %s", cfg.HTTPAddr)

View file

@ -1,6 +0,0 @@
apiVersion: v2
name: cms
description: CMS application
type: application
version: 0.1.0
appVersion: "0.1.0"

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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: {}

View file

@ -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"

View file

@ -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 `<fullname>-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 `<fullname>-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.

View file

@ -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 }}

View file

@ -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 }}

View file

@ -1,16 +1,16 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: {{ include "cms.fullname" . }} name: {{ include "go-tell.fullname" . }}
labels: labels:
{{- include "cms.labels" . | nindent 4 }} {{- include "go-tell.labels" . | nindent 4 }}
spec: spec:
replicas: {{ .Values.replicaCount }} replicas: {{ .Values.replicaCount }}
strategy: strategy:
type: {{ .Values.strategy.type }} type: {{ .Values.strategy.type }}
selector: selector:
matchLabels: matchLabels:
{{- include "cms.selectorLabels" . | nindent 6 }} {{- include "go-tell.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: application app.kubernetes.io/component: application
template: template:
metadata: metadata:
@ -20,7 +20,7 @@ spec:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
labels: labels:
{{- include "cms.selectorLabels" . | nindent 8 }} {{- include "go-tell.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: application app.kubernetes.io/component: application
{{- with .Values.podLabels }} {{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
@ -34,14 +34,14 @@ spec:
securityContext: securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }} {{- toYaml .Values.podSecurityContext | nindent 8 }}
containers: containers:
- name: cms - name: go-tell
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext: securityContext:
{{- toYaml .Values.securityContext | nindent 12 }} {{- toYaml .Values.securityContext | nindent 12 }}
env: env:
- name: CMS_PROFILE - name: CMS_PROFILE
value: {{ .Values.app.environment | quote }} value: {{ .Values.app.profile | quote }}
- name: CMS_ALLOWED_ORIGINS - name: CMS_ALLOWED_ORIGINS
value: {{ .Values.app.allowedOrigins | quote }} value: {{ .Values.app.allowedOrigins | quote }}
- name: CMS_CONTENT_FILE - name: CMS_CONTENT_FILE
@ -55,8 +55,30 @@ spec:
- name: CMS_SSO_TOKEN - name: CMS_SSO_TOKEN
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ include "cms.secretName" . }} name: {{ include "go-tell.secretName" . }}
key: sso-token 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: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
@ -86,14 +108,16 @@ spec:
- name: content - name: content
mountPath: /data mountPath: /data
{{- end }} {{- end }}
{{- include "go-tell.caVolumeMount" . | nindent 12 }}
volumes: volumes:
- name: temporary-files - name: temporary-files
emptyDir: {} emptyDir: {}
{{- if .Values.persistence.enabled }} {{- if .Values.persistence.enabled }}
- name: content - name: content
persistentVolumeClaim: persistentVolumeClaim:
claimName: {{ include "cms.fullname" . }} claimName: {{ include "go-tell.fullname" . }}
{{- end }} {{- end }}
{{- include "go-tell.caVolume" . | nindent 8 }}
{{- with .Values.nodeSelector }} {{- with .Values.nodeSelector }}
nodeSelector: nodeSelector:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}

View file

@ -1,13 +1,13 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: {{ include "cms.fullname" . }} name: {{ include "go-tell.fullname" . }}
labels: labels:
{{- include "cms.labels" . | nindent 4 }} {{- include "go-tell.labels" . | nindent 4 }}
spec: spec:
type: {{ .Values.service.type }} type: {{ .Values.service.type }}
selector: selector:
{{- include "cms.selectorLabels" . | nindent 4 }} {{- include "go-tell.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: application app.kubernetes.io/component: application
ports: ports:
- name: http - name: http

View file

@ -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 }}

View file

@ -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 }}

View file

@ -2,9 +2,9 @@
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: {{ include "cms.fullname" . }} name: {{ include "go-tell.fullname" . }}
labels: labels:
{{- include "cms.labels" . | nindent 4 }} {{- include "go-tell.labels" . | nindent 4 }}
spec: spec:
accessModes: accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }} {{- toYaml .Values.persistence.accessModes | nindent 4 }}

View file

@ -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 }}

View file

@ -1,9 +1,9 @@
apiVersion: v1 apiVersion: v1
kind: Pod kind: Pod
metadata: metadata:
name: "{{ include "cms.fullname" . }}-test-connection" name: "{{ include "go-tell.fullname" . }}-test-connection"
labels: labels:
{{- include "cms.labels" . | nindent 4 }} {{- include "go-tell.labels" . | nindent 4 }}
annotations: annotations:
helm.sh/hook: test helm.sh/hook: test
helm.sh/hook-delete-policy: before-hook-creation helm.sh/hook-delete-policy: before-hook-creation
@ -17,4 +17,4 @@ spec:
- wget - wget
args: args:
- -qO- - -qO-
- "http://{{ include "cms.fullname" . }}:{{ .Values.service.port }}/health" - "http://{{ include "go-tell.fullname" . }}:{{ .Values.service.port }}/health"

View file

@ -1,12 +1,14 @@
image: image:
tag: latest
pullPolicy: Always pullPolicy: Always
app: app:
environment: demo profile: demo
allowedOrigins: http://localhost:30010 allowedOrigins: http://localhost:30010
contentFile: "" contentFile: ""
goLoose:
enabled: false
service: service:
type: NodePort type: NodePort
nodePort: 30010 nodePort: 30010

View file

@ -1,18 +1,13 @@
image: image:
tag: latest
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
app: app:
environment: demo profile: production
allowedOrigins: https://cms.urpi.be allowedOrigins: https://tell.dev
contentFile: /data/content.json contentFile: /data/content.json
service: service:
type: NodePort type: ClusterIP
nodePort: 31373
ingress:
enabled: false
resources: resources:
requests: requests:

View file

@ -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://<tenant>.<authDomain> and the redirect URI
# https://<appDomain>/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 <fullname>-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: {}

View file

@ -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`.

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

9
go.mod
View file

@ -1,12 +1,13 @@
module github.com/danyel/go-cms module github.com/danyel/go-tell
go 1.27.1 go 1.27.1
require ( require (
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.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/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
github.com/rabbitmq/amqp091-go v1.15.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/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.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/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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )

5
go.sum
View file

@ -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/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/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/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.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/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= 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/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/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.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/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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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= 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/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.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-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-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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -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 // 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 package config
import "os" import "os"
@ -8,8 +9,26 @@ import "os"
type Config struct { type Config struct {
HTTPAddr, Profile, ContentFile, PostgresURL string HTTPAddr, Profile, ContentFile, PostgresURL string
AllowedOrigins, SSOHeader, SSOToken, SSOCookie 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 { func Load() Config {
return Config{ return Config{
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
@ -20,6 +39,14 @@ func Load() Config {
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"), SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
SSOToken: get("CMS_SSO_TOKEN", ""), SSOToken: get("CMS_SSO_TOKEN", ""),
SSOCookie: get("CMS_SSO_COOKIE", ""), 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"),
},
} }
} }

View file

@ -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
}

View file

@ -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()}
}

View file

@ -33,9 +33,9 @@ func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) {
} }
func TestSSOTokenVerifierCookieFallback(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 := 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) { if !v.Authenticated(r) {
t.Fatal("cookie token must be authenticated") t.Fatal("cookie token must be authenticated")
} }

View file

@ -8,22 +8,27 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/danyel/go-cms/internal/security" goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-cms/internal/service"
"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. // Handler serves the CMS API and the embedded React app from one binary.
// Reading content is anonymous; every mutation requires the identity asserted by // Reading content is anonymous; every mutation requires an authenticated
// the upstream SSO proxy. // editor: a Go Loose browser session, the identity asserted by the upstream
// SSO proxy, or a demo user.
type Handler struct { type Handler struct {
content service.IContentService content service.IContentService
assets fs.FS assets fs.FS
sso security.ITokenVerifier sso security.ITokenVerifier
browser *golooseauth.Auth
demo *service.DemoAuth demo *service.DemoAuth
} }
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler { 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} h := &Handler{content: content, assets: assets, sso: sso, browser: browser}
if len(demo) > 0 { if len(demo) > 0 {
h.demo = 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) { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"}) 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/session", h.session)
mux.HandleFunc("/api/auth/sudo", h.sudo) mux.HandleFunc("/api/auth/sudo", h.sudo)
mux.HandleFunc("/api/auth/users", h.users) 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()) 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) { func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
username, authenticated := h.identity(r) 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) { 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) { func (h *Handler) identity(r *http.Request) (string, bool) {
if h.demo != nil { if h.demo != nil {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) 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) { if h.sso.Authenticated(r) {
return "sso-owner", true return "sso-owner", true
} }
if user, ok := h.browserUser(r); ok {
return editorName(user), true
}
return "", false 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 // singlePageApp serves the embedded build and falls back to index.html so the
// client-side router owns every non-API path. // client-side router owns every non-API path.
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) { func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {

View file

@ -8,8 +8,10 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"github.com/danyel/go-cms/internal/security" "github.com/danyel/go-tell/internal/config"
"github.com/danyel/go-cms/internal/service" "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 } type fakeContent struct{ updated bool }
@ -31,12 +33,16 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
return c, nil return c, nil
} }
func newTestRoutes(content *fakeContent) http.Handler { func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
assets := fstest.MapFS{ assets := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")}, "index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"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) { 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) { func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
routes := newTestRoutes(&fakeContent{}) routes := newTestRoutes(&fakeContent{})

View file

@ -1,5 +1,5 @@
{ {
"name": "cms-ui", "name": "go-tell-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View file

@ -54,7 +54,7 @@ export async function requestSession(): Promise<SessionResponse | null> {
} }
function demoAuthHeaders(): Record<string, string> { function demoAuthHeaders(): Record<string, string> {
const token = window.sessionStorage.getItem('cms-token') const token = window.sessionStorage.getItem('go-tell-token')
return token ? {Authorization: `Bearer ${token}`} : {} return token ? {Authorization: `Bearer ${token}`} : {}
} }
@ -99,7 +99,7 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers }, headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
}) })
if (!response.ok) { 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.') throw new Error((await response.text()) || 'Unable to load content.')
} }
return (await response.json()) as T return (await response.json()) as T

View file

@ -1,9 +1,10 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' 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 // Signing in is either the Go Loose browser login or a redirect to the
// current request carries the owner's identity. Signing in just sends the // identity-aware proxy, depending on what the API advertises: /api/auth/session
// browser to the configured Google/IdP URL. // returns loginUrl and logoutUrl when Go Loose is configured. The demo profile
// keeps using its own token in sessionStorage.
type AuthState = { type AuthState = {
loading: boolean loading: boolean
authenticated: boolean authenticated: boolean
@ -12,6 +13,7 @@ type AuthState = {
error: string | null error: string | null
refresh: () => Promise<void> refresh: () => Promise<void>
canSignIn: boolean canSignIn: boolean
canSignOut: boolean
signIn: () => void signIn: () => void
sudo: (username: string, password: string) => Promise<void> sudo: (username: string, password: string) => Promise<void>
signOut: () => void signOut: () => void
@ -23,6 +25,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authenticated, setAuthenticated] = useState(false) const [authenticated, setAuthenticated] = useState(false)
const [canEdit, setCanEdit] = useState(false) const [canEdit, setCanEdit] = useState(false)
const [username, setUsername] = useState<string | undefined>() const [username, setUsername] = useState<string | undefined>()
const [loginUrl, setLoginUrl] = useState('')
const [logoutUrl, setLogoutUrl] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -33,10 +37,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthenticated(session?.authenticated === true) setAuthenticated(session?.authenticated === true)
setCanEdit(session?.canEdit === true) setCanEdit(session?.canEdit === true)
setUsername(session?.username) setUsername(session?.username)
setLoginUrl(session?.loginUrl ?? '')
setLogoutUrl(session?.logoutUrl ?? '')
} catch (err) { } catch (err) {
setAuthenticated(false) setAuthenticated(false)
setCanEdit(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 { } finally {
setLoading(false) setLoading(false)
} }
@ -44,25 +50,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { void refresh() }, [refresh]) 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 sudo = useCallback(async (username: string, password: string) => {
const result = await sudoLogin(username, password) const result = await sudoLogin(username, password)
window.sessionStorage.setItem('cms-token', result.token) window.sessionStorage.setItem('go-tell-token', result.token)
setAuthenticated(true) setAuthenticated(true)
setCanEdit(true) setCanEdit(true)
setUsername(result.user.username) setUsername(result.user.username)
}, []) }, [])
const signOut = useCallback(() => { const signOut = useCallback(() => {
window.sessionStorage.removeItem('cms-token') if (logoutUrl) {
window.location.assign(apiUrl(logoutUrl))
return
}
window.sessionStorage.removeItem('go-tell-token')
setAuthenticated(false) setAuthenticated(false)
setCanEdit(false) setCanEdit(false)
setUsername(undefined) setUsername(undefined)
}, []) }, [logoutUrl])
const canSignIn = apiConfig.signIn !== '' const canSignIn = apiConfig.signIn !== '' || loginUrl !== ''
const canSignOut = logoutUrl !== ''
const value = useMemo( const value = useMemo(
() => ({ 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, signIn, sudo, signOut], [loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }

View file

@ -6,7 +6,7 @@ import {type DemoUser} from '../models'
import {SudoTerminal} from './SudoTerminal' import {SudoTerminal} from './SudoTerminal'
export function Shell({children}: {children: ReactNode}) { 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 [sudoOpen, setSudoOpen] = useState(false)
const [helpOpen, setHelpOpen] = useState(false) const [helpOpen, setHelpOpen] = useState(false)
const [users, setUsers] = useState<DemoUser[]>([]) const [users, setUsers] = useState<DemoUser[]>([])
@ -24,7 +24,10 @@ export function Shell({children}: {children: ReactNode}) {
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link> <span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
<div className="header-actions"> <div className="header-actions">
{!loading && <button className="mode-badge" type="button" onClick={() => { {!loading && <button className="mode-badge" type="button" onClick={() => {
if (authenticated) return if (authenticated) {
if (canSignOut) signOut()
return
}
if (users.length > 0) setSudoOpen(true) if (users.length > 0) setSudoOpen(true)
else if (canSignIn) signIn() else if (canSignIn) signIn()
}}> }}>

View file

@ -10,6 +10,12 @@ export type SessionResponse = {
authenticated?: boolean authenticated?: boolean
canEdit?: boolean canEdit?: boolean
username?: string username?: string
email?: string
displayName?: string
// Go Loose advertises its own login and logout endpoints; the UI follows them
// instead of sending the browser to a single-sign-on proxy.
loginUrl?: string
logoutUrl?: string
} }
export type DemoUser = { username: string; name: string } export type DemoUser = { username: string; name: string }