diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..402b044 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Build context for the container image: only what the Astro build needs. +.git +.github +.vscode +.astro +node_modules +dist +deploy +Dockerfile +.dockerignore +README.md +LICENSE +.env +.env.* +*.log +.DS_Store diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..d0e6d1e --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,66 @@ +name: Container image + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/mrguato/gitops-notes + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + # Every build is tagged sha-; main also moves latest. + tags: | + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + # Pull requests build both architectures to prove the Dockerfile still + # works, but nothing is pushed until the change lands on main. + # The cluster mixes Raspberry Pi 4 (arm64) and x86_64 nodes. + # CLUSTER_SITE_URL is an optional repository variable for the public + # hostname the cluster serves; the default matches deploy/k8s. + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + ASTRO_BASE=/ + ASTRO_SITE=${{ vars.CLUSTER_SITE_URL || 'https://notes.deleontech.net' }} + cache-from: type=gha + cache-to: type=gha,mode=max + # Keep the manifest list to the two platform images so the package + # page and the kubelet see only amd64 and arm64 entries. + provenance: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40fb0da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +# Stage 1: build the static site. +# --platform=$BUILDPLATFORM runs the build once on the runner's native +# architecture. The output is plain HTML, CSS and JS, so it is copied into +# the amd64 and arm64 images alike instead of being rebuilt under emulation. +FROM --platform=$BUILDPLATFORM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY . . +# The cluster serves the site at the root of its own hostname. +ARG ASTRO_BASE=/ +ARG ASTRO_SITE=https://notes.deleontech.net +ENV ASTRO_BASE=$ASTRO_BASE \ + ASTRO_SITE=$ASTRO_SITE +RUN npm run build + +# Stage 2: serve it with nginx as a non-root user on port 8080. +# nginx-unprivileged runs as uid 101, keeps its pid file and temp +# directories under /tmp and listens on 8080, so no root is needed anywhere. +FROM nginxinc/nginx-unprivileged:1.31-alpine +LABEL org.opencontainers.image.source="https://github.com/MrGuato/gitops-notes" +LABEL org.opencontainers.image.description="GitOps Notes, a static Astro site served by nginx" +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 8080 +USER 101 diff --git a/README.md b/README.md index da5051d..1d6df1e 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,21 @@ flowchart LR A[[local editor]] --> B[[git commit]] B --> C[[git push]] C --> D[[ci build]] - D --> E[[deploy to host]] + D --> E[[github pages]] + D --> H[[container image]] + H --> I[[flux on k3s]] + I --> J[[cloudflare tunnel]] A --> F[[src/content/posts]] F --> D D --> G[[dist output]] ``` + +## Where it runs + +The site has two deployment targets built from the same source. + +GitHub Pages is the original home. A push to `main` runs `.github/workflows/deploy.yml`, which builds with the default base path `/gitops-notes` and publishes to https://mrguato.github.io/gitops-notes. Nothing about that path changed. + +The k3s homelab is the second target. The same push runs `.github/workflows/image.yml`, which builds the site with `ASTRO_BASE=/`, packages it with nginx in a multi-arch container image, and pushes it to `ghcr.io/mrguato/gitops-notes`. The manifests under `deploy/k8s` run that image in the cluster behind a Cloudflare Tunnel. The setup steps for the cluster repo are in [deploy/k8s/README.md](deploy/k8s/README.md). + +The cluster hostname is public on purpose. Every other app in the cluster sits behind Cloudflare Access, but a blog is meant to be read by anyone, so there is no login in front of it. The protection comes from the layers around it instead. The container serves static files only, runs nginx as a non-root user on a read-only filesystem with every capability dropped, and sends a strict Content-Security-Policy. A NetworkPolicy lets only the tunnel pods reach nginx and lets nginx reach nothing but cluster DNS, so a compromised web pod cannot touch other namespaces. Cloudflare caches the whole site at the edge, runs Bot Fight Mode and rate limits the hostname, so a traffic spike is absorbed before it reaches the home connection. diff --git a/astro.config.mjs b/astro.config.mjs index f8644c6..d33458f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,14 +1,28 @@ // @ts-check import { defineConfig } from "astro/config"; +// Two deployment targets share this config. +// GitHub Pages serves the site under /gitops-notes, which is the default. +// The container build passes ASTRO_BASE=/ and ASTRO_SITE= +// so the same source produces root-relative links for the cluster. +const site = process.env.ASTRO_SITE || "https://mrguato.github.io"; +const base = process.env.ASTRO_BASE || "/gitops-notes"; + // https://astro.build/config export default defineConfig({ - site: "https://mrguato.github.io", - base: "/gitops-notes", + site, + base, markdown: { shikiConfig: { themes: { light: "github-light", dark: "github-dark" }, defaultColor: false, }, }, + vite: { + build: { + // Never inline small scripts or stylesheets. The container sends a + // Content-Security-Policy that forbids inline scripts. + assetsInlineLimit: 0, + }, + }, }); diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..a167532 --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,190 @@ +# Running the site in the k3s cluster + +These manifests serve the GitOps Notes container in the pi-cluster behind a +Cloudflare Tunnel on a public hostname. The folder mirrors the pi-cluster +layout so it can be copied straight into that repo: + +```text +deploy/k8s/apps/ + base/gitops-notes/ -> pi-cluster apps/base/gitops-notes/ + namespace.yaml namespace with Pod Security Admission "restricted" + deployment.yaml 2 nginx replicas, non-root, read-only root fs, probes, limits + service.yaml ClusterIP on 8080 + networkpolicy.yaml nginx: ingress from cloudflared only, egress to DNS only + pdb.yaml PodDisruptionBudgets for nginx and cloudflared + cloudflared.yaml per-namespace tunnel, reads Secret cloudflared-tunnel + staging/gitops-notes/ -> pi-cluster apps/staging/gitops-notes/ + kustomization.yaml base plus the encrypted secret + secret.yaml.example shape of the tunnel secret, no real values +``` + +The hostname used throughout is `notes.deleontech.net`. To use another one, +change it in `secret.yaml`, set the `CLUSTER_SITE_URL` repository variable in +the gitops-notes repo to the matching `https://` URL, and rebuild the image. + +## Why the hostname is public + +Every other app in the cluster sits behind Cloudflare Access with email OTP. +A blog is meant to be read by anyone, so this hostname gets no Access policy. +The protection comes from the layers around the pod instead: + +- The container serves static files only, answers GET and HEAD, runs nginx as + uid 101 on a read-only root filesystem with every capability dropped, and + sends a Content-Security-Policy with no inline scripts. +- The namespace enforces the restricted Pod Security profile. +- The NetworkPolicy lets only the cloudflared pods reach nginx and lets nginx + reach nothing but cluster DNS. The cloudflared pods may reach the Cloudflare + edge, DNS and nginx, and nothing in private address space. +- Cloudflare caches the whole site at the edge, runs Bot Fight Mode and rate + limits the hostname, so a burst of traffic is absorbed before it reaches the + home connection. + +## Steps in the pi-cluster repo + +### 1. Publish the image + +Merge the gitops-notes change to `main`. The "Container image" workflow builds +`linux/amd64` and `linux/arm64` and pushes `ghcr.io/mrguato/gitops-notes` with +two tags: `sha-` and `latest`. + +The cluster pulls without an image pull secret, so the package must be public. +On GitHub open your profile, Packages, `gitops-notes`, Package settings, and +change the visibility to public. This is a one-time step. + +Copy the `sha-` tag from the workflow run (the metadata step prints it) into +`apps/base/gitops-notes/deployment.yaml` in place of `sha-0000000`. + +### 2. Create the tunnel + +On a machine with `cloudflared` and access to the Cloudflare account: + +```bash +cloudflared tunnel login +``` + +```bash +cloudflared tunnel create gitops-notes +``` + +Note the tunnel ID it prints. The credentials file lands in +`~/.cloudflared/.json`. + +### 3. Route the public hostname, with no Access application + +```bash +cloudflared tunnel route dns gitops-notes notes.deleontech.net +``` + +This creates a proxied CNAME to `.cfargotunnel.com`. Do not create +an Access application for it. In Zero Trust, open Access, Applications, and +confirm nothing there matches `notes.deleontech.net`, including wildcard +applications for the zone. + +### 4. Copy the manifests and encrypt the secret + +From the pi-cluster checkout, with `GITOPS_NOTES` pointing at this repo: + +```bash +mkdir -p apps/base/gitops-notes apps/staging/gitops-notes +``` + +```bash +cp "$GITOPS_NOTES"/deploy/k8s/apps/base/gitops-notes/*.yaml apps/base/gitops-notes/ +``` + +```bash +cp "$GITOPS_NOTES"/deploy/k8s/apps/staging/gitops-notes/kustomization.yaml apps/staging/gitops-notes/ +``` + +```bash +cp "$GITOPS_NOTES"/deploy/k8s/apps/staging/gitops-notes/secret.yaml.example apps/staging/gitops-notes/secret.yaml +``` + +Edit `apps/staging/gitops-notes/secret.yaml`: paste the contents of +`~/.cloudflared/.json` into `credentials.json`, and put the tunnel +ID into `config.yaml`. Then encrypt it in place with the cluster's SOPS config, +which lives under `clusters/staging/` and not at the repo root: + +```bash +sops --config clusters/staging/.sops.yaml --encrypt --in-place apps/staging/gitops-notes/secret.yaml +``` + +Check that both values are encrypted before committing: + +```bash +grep -c 'ENC\[' apps/staging/gitops-notes/secret.yaml +``` + +The count should be 2. Delete `~/.cloudflared/.json` once the +encrypted file is committed, or move it somewhere outside any git checkout. + +### 5. Add the Flux Kustomization + +The Flux Kustomization `apps` in `clusters/staging/apps.yaml` already +reconciles `./apps/staging` with SOPS decryption, and Flux picks up any +subfolder that has its own `kustomization.yaml`. Commit the two new folders +and push: + +```bash +git add apps/base/gitops-notes apps/staging/gitops-notes +``` + +```bash +git commit -m "feat: gitops-notes site behind a public cloudflare tunnel" +``` + +Then reconcile and check: + +```bash +flux reconcile kustomization apps --with-source +``` + +```bash +kubectl -n gitops-notes get pods,svc,networkpolicy,pdb +``` + +```bash +kubectl -n gitops-notes logs deploy/cloudflared | grep -i registered +``` + +```bash +curl -sI https://notes.deleontech.net | grep -i -e '^HTTP' -e content-security-policy -e cf-cache-status +``` + +### 6. Cloudflare dashboard + +All of this is on the zone, under the `deleontech.net` site. + +Caching. Open Caching, Cache Rules, Create rule. Name it, match +`Hostname equals notes.deleontech.net`, set Cache eligibility to +"Eligible for cache", Edge TTL to "Use cache-control header if present, +bypass cache if not", and Browser TTL to "Respect origin TTL". Deploy. The +origin sends a one-year `Cache-Control` for `/_astro/` and five minutes for +pages, so the edge serves almost every request itself. A second +`curl -sI` of the hostname should show `cf-cache-status: HIT`. + +Bot Fight Mode. Open Security, Bots, and turn Bot Fight Mode on. On the newer +dashboard it is under Security, Settings, Bot traffic. + +Rate limiting. Open Security, WAF, Rate limiting rules, Create rule. Match +`Hostname equals notes.deleontech.net`, count by IP, allow 100 requests per 10 +seconds, and block for 10 seconds when exceeded. Those are the limits the free +plan allows for its one rule. On the newer dashboard it is under Security, +Security rules, Create rule, Rate limiting rule. + +Also confirm Always Use HTTPS is on under SSL/TLS, Edge Certificates. The +origin already sends Strict-Transport-Security. + +## Updating the site + +Merge a new post. The image workflow prints a new `sha-` tag. Change the tag +in `apps/base/gitops-notes/deployment.yaml`, commit, and Flux rolls it out +one replica at a time with `maxUnavailable: 0`, so the site stays up. Cached +pages refresh within five minutes, or purge the hostname under Caching, +Configuration, Purge Cache. + +## Checking the manifests locally + +```bash +kubectl apply --dry-run=client -k deploy/k8s/apps/base/gitops-notes +``` diff --git a/deploy/k8s/apps/base/gitops-notes/cloudflared.yaml b/deploy/k8s/apps/base/gitops-notes/cloudflared.yaml new file mode 100644 index 0000000..3519307 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/cloudflared.yaml @@ -0,0 +1,113 @@ +--- +# Per-namespace Cloudflare Tunnel, same shape as the headlamp one. +# The tunnel config and credentials come from the cloudflared-tunnel Secret, +# which lives SOPS-encrypted in the staging overlay. Nothing here is secret. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloudflared + namespace: gitops-notes + labels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes + app.kubernetes.io/component: ingress-tunnel +spec: + replicas: 2 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes + template: + metadata: + labels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes + app.kubernetes.io/component: ingress-tunnel + spec: + automountServiceAccountToken: false + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: [amd64, arm64] + containers: + - name: cloudflared + image: cloudflare/cloudflared:2026.10.0 + imagePullPolicy: IfNotPresent + args: + - tunnel + - --config + - /etc/cloudflared/config.yaml + - --no-autoupdate + - run + ports: + - name: metrics + containerPort: 2000 + protocol: TCP + livenessProbe: + httpGet: + path: /ready + port: metrics + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: metrics + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: tunnel-config + mountPath: /etc/cloudflared + readOnly: true + volumes: + - name: tunnel-config + secret: + secretName: cloudflared-tunnel + defaultMode: 0400 + items: + - key: config.yaml + path: config.yaml + - key: credentials.json + path: credentials.json diff --git a/deploy/k8s/apps/base/gitops-notes/deployment.yaml b/deploy/k8s/apps/base/gitops-notes/deployment.yaml new file mode 100644 index 0000000..475a4a4 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/deployment.yaml @@ -0,0 +1,110 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitops-notes + namespace: gitops-notes + labels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + app.kubernetes.io/part-of: gitops-notes +spec: + replicas: 2 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + template: + metadata: + labels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + app.kubernetes.io/part-of: gitops-notes + spec: + # nginx never talks to the Kubernetes API, so it gets no token and no + # service environment variables. + automountServiceAccountToken: false + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + fsGroup: 101 + seccompProfile: + type: RuntimeDefault + # The image is multi-arch, so spread the two replicas across nodes. + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: [amd64, arm64] + containers: + - name: nginx + # Pin to the sha- tag that the "Container image" + # workflow in the gitops-notes repo prints for the commit you want + # to serve. Replace the placeholder before committing. + image: ghcr.io/mrguato/gitops-notes:sha-0000000 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 200m + memory: 64Mi + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + # The only paths nginx-unprivileged writes: the pid file and temp + # directories under /tmp, and the proxy cache directory. + volumeMounts: + - name: tmp + mountPath: /tmp + - name: cache + mountPath: /var/cache/nginx + volumes: + - name: tmp + emptyDir: + sizeLimit: 32Mi + - name: cache + emptyDir: + sizeLimit: 32Mi diff --git a/deploy/k8s/apps/base/gitops-notes/kustomization.yaml b/deploy/k8s/apps/base/gitops-notes/kustomization.yaml new file mode 100644 index 0000000..14dc404 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - deployment.yaml + - service.yaml + - networkpolicy.yaml + - pdb.yaml + - cloudflared.yaml diff --git a/deploy/k8s/apps/base/gitops-notes/namespace.yaml b/deploy/k8s/apps/base/gitops-notes/namespace.yaml new file mode 100644 index 0000000..0a71138 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/namespace.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gitops-notes + labels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/part-of: gitops-notes + # Pod Security Admission: nothing in this namespace may run outside the + # restricted profile. The hostname is public, so the pods get no slack. + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted diff --git a/deploy/k8s/apps/base/gitops-notes/networkpolicy.yaml b/deploy/k8s/apps/base/gitops-notes/networkpolicy.yaml new file mode 100644 index 0000000..4925477 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/networkpolicy.yaml @@ -0,0 +1,92 @@ +--- +# The nginx pods face the internet through cloudflared, so they are boxed in. +# Only cloudflared in this namespace may talk to them, and the only thing +# they may talk to is cluster DNS. A compromised web pod cannot reach other +# namespaces, the Kubernetes API, the home LAN or the internet. +# k3s enforces NetworkPolicy with its embedded kube-router controller. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gitops-notes-web + namespace: gitops-notes +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + policyTypes: ["Ingress", "Egress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# cloudflared only dials out: to the Cloudflare edge, to cluster DNS and to +# the nginx pods. Private ranges are excluded from the internet rule so the +# tunnel pod cannot be used as a hop into other namespaces or the home LAN. +# Ingress is left open so the kubelet can probe port 2000. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gitops-notes-cloudflared + namespace: gitops-notes +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes + policyTypes: ["Egress"] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + ports: + - protocol: TCP + port: 8080 + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.0.0/16 + ports: + # QUIC and HTTP/2 to the Cloudflare edge, plus HTTPS for the API. + - protocol: UDP + port: 7844 + - protocol: TCP + port: 7844 + - protocol: TCP + port: 443 diff --git a/deploy/k8s/apps/base/gitops-notes/pdb.yaml b/deploy/k8s/apps/base/gitops-notes/pdb.yaml new file mode 100644 index 0000000..c6abcd4 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/pdb.yaml @@ -0,0 +1,25 @@ +--- +# Two replicas each, and a node drain may take down only one at a time. +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: gitops-notes-web + namespace: gitops-notes +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: gitops-notes-cloudflared + namespace: gitops-notes +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: cloudflared + app.kubernetes.io/part-of: gitops-notes diff --git a/deploy/k8s/apps/base/gitops-notes/service.yaml b/deploy/k8s/apps/base/gitops-notes/service.yaml new file mode 100644 index 0000000..5bfa590 --- /dev/null +++ b/deploy/k8s/apps/base/gitops-notes/service.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: gitops-notes + namespace: gitops-notes + labels: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + app.kubernetes.io/part-of: gitops-notes +spec: + # Only reachable inside the cluster. cloudflared in this namespace is the + # one client, and the NetworkPolicy enforces that. + type: ClusterIP + selector: + app.kubernetes.io/name: gitops-notes + app.kubernetes.io/component: web + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP diff --git a/deploy/k8s/apps/staging/gitops-notes/kustomization.yaml b/deploy/k8s/apps/staging/gitops-notes/kustomization.yaml new file mode 100644 index 0000000..0d9b5d1 --- /dev/null +++ b/deploy/k8s/apps/staging/gitops-notes/kustomization.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../base/gitops-notes + # Create this from secret.yaml.example and SOPS-encrypt it before the first + # commit. cloudflared cannot start without it. + - secret.yaml diff --git a/deploy/k8s/apps/staging/gitops-notes/secret.yaml.example b/deploy/k8s/apps/staging/gitops-notes/secret.yaml.example new file mode 100644 index 0000000..d05339a --- /dev/null +++ b/deploy/k8s/apps/staging/gitops-notes/secret.yaml.example @@ -0,0 +1,27 @@ +--- +# 1. cp secret.yaml.example secret.yaml and fill in both keys +# 2. sops --config clusters/staging/.sops.yaml --encrypt --in-place apps/staging/gitops-notes/secret.yaml +# 3. commit and push; Flux decrypts it at reconcile time +# Never commit secret.yaml unencrypted. Never kubectl apply it directly. +apiVersion: v1 +kind: Secret +metadata: + name: cloudflared-tunnel + namespace: gitops-notes +type: Opaque +stringData: + # The JSON that "cloudflared tunnel create gitops-notes" writes to + # ~/.cloudflared/.json, pasted as one line. + credentials.json: | + {"AccountTag":"REPLACE_ME","TunnelSecret":"REPLACE_ME","TunnelID":"REPLACE_WITH_TUNNEL_ID"} + # Public hostname and in-cluster target. No Access policy is attached to + # this hostname on purpose; see the README. + config.yaml: | + tunnel: REPLACE_WITH_TUNNEL_ID + credentials-file: /etc/cloudflared/credentials.json + metrics: 0.0.0.0:2000 + no-autoupdate: true + ingress: + - hostname: notes.deleontech.net + service: http://gitops-notes:8080 + - service: http_status:404 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..cab4a34 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,75 @@ +# nginx server block for the gitops-notes container. +# Installed as /etc/nginx/conf.d/default.conf inside nginx-unprivileged, +# whose main nginx.conf already keeps the pid file and temp directories +# under /tmp, so the container runs with a read-only root filesystem. + +# Cache lifetime by path. Astro writes hashed assets under /_astro/, which +# can be cached for a year. Everything else is revalidated often so a new +# post shows up without a purge. Cloudflare honours these at the edge once +# the hostname's cache rule is set to respect origin cache headers. +map $uri $cache_control { + default "public, max-age=300"; + ~^/_astro/ "public, max-age=31536000, immutable"; + ~*\.(?:png|jpe?g|gif|svg|webp|avif|ico|woff2?)$ "public, max-age=86400"; +} + +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Hide the nginx version and never list directories. + server_tokens off; + autoindex off; + + # cloudflared forwards the public Host header. Keep redirects relative + # so nginx never writes its own port 8080 into a Location header. + absolute_redirect off; + port_in_redirect off; + + # A static site only answers GET and HEAD. + if ($request_method !~ ^(GET|HEAD)$) { + return 405; + } + + # Security headers. Every add_header lives in this one block because + # nginx drops inherited add_header lines as soon as a location adds its own. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Cache-Control $cache_control always; + # No inline scripts: the theme script is served from /theme.js. + # style-src keeps 'unsafe-inline' because Shiki puts inline style + # attributes on highlighted code and Astro may inline small stylesheets. + add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'" always; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml; + + # Probe endpoint for Kubernetes. Nothing to log. + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Never serve dotfiles. + location ~ /\. { + return 404; + } + + location / { + try_files $uri $uri/ =404; + } + + # Astro builds src/pages/404.astro into dist/404.html. + error_page 404 /404.html; +} diff --git a/public/theme.js b/public/theme.js new file mode 100644 index 0000000..a6c4dea --- /dev/null +++ b/public/theme.js @@ -0,0 +1,45 @@ +// Color theme for the site. +// Loaded synchronously from the layout head so the saved theme is applied +// before first paint, and so the site ships without inline scripts. That +// lets the container's Content-Security-Policy use script-src 'self'. +(function () { + var root = document.documentElement; + + try { + if (localStorage.getItem("theme") === "dark") { + root.setAttribute("data-theme", "dark"); + } + } catch (e) {} + + function current() { + return root.getAttribute("data-theme") === "dark" ? "dark" : "light"; + } + + function wireButtons() { + var buttons = document.querySelectorAll("[data-set-theme]"); + function paint() { + var c = current(); + buttons.forEach(function (b) { + var on = b.getAttribute("data-set-theme") === c; + b.classList.toggle("on", on); + b.setAttribute("aria-pressed", String(on)); + }); + } + buttons.forEach(function (b) { + b.addEventListener("click", function () { + var t = b.getAttribute("data-set-theme"); + if (t === "dark") root.setAttribute("data-theme", "dark"); + else root.removeAttribute("data-theme"); + try { localStorage.setItem("theme", t); } catch (e) {} + paint(); + }); + }); + paint(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", wireButtons); + } else { + wireButtons(); + } +})(); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index dfabe69..13fd09c 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -31,15 +31,7 @@ const isCurrent = (href: string) => path === href || path.startsWith(href + "/") - + @@ -80,33 +72,5 @@ const isCurrent = (href: string) => path === href || path.startsWith(href + "/") - - diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..142e183 --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,20 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +import { base } from "../lib/site"; + +const description = "That page does not exist."; +--- + + +
+
+
404
+

Page not found

+

+ The address may be wrong, or the note was moved or removed. + Try the front page or the + writing index. +

+
+
+