Domains, certificates, and DNS

Accessing services via raw IP addresses isn’t that swell; I’m no Rain Man.

It’s time to set up subdomains for my hietala.xyz domain for internal use. We’ll use the Gateway API to set up routes, cert-manager to give us https without self-signed browser warnings, and ExternalDNS to set up DNS overrides.

HTTP routing

I believe the flow of resolving http://argocd.hietala.xyz to a service looks something like this:

digraph {
  rankdir=LR
  "Browser" -> "OPNsense DNS" [label="resolve host"]
  "OPNsense DNS" -> "Browser" [label="Gateway IP"]
  "Browser" -> "Cilium Gateway" [label="HTTP request" class="label-below"]
  "Cilium Gateway" -> "HTTPRoute" [label="match hostname" class="label-below"]
  "HTTPRoute" -> "Service" [label="backendRef" class="label-below"]
  "Service" -> "Pod" [label="EndpointSlice" class="label-below"]
}

First the browser asks my router running OPNsense about argocd.hietala.xyz and gets the Gateway IP address. The request then flows through a route (http or https), to a Service, and eventually a Pod where ArgoCD is running.

We’ll handle these one at a time but let’s start by creating the Gateway and giving it an IP (we’ll use 10.1.4.101):

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: main
  namespace: kube-system
  annotations:
    io.cilium/lb-ipam-ips: "10.1.4.101"
spec:
  gatewayClassName: cilium

We installed the prerequisites for Gateway when we configured Cilium during our initial cluster bootstrap.

Then we need to tell the Gateway to manage all http routes for all namespaces (we’ll get back to https):

# Continued from the above Gateway manifest
spec:
  gatewayClassName: cilium
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: All

Then we can add an HTTPRoute for our ArgoCD application that targets argocd-server at port 80:

# Below the ArgoCD Application setup
# Resources in the same file are separated by `---`
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: argocd
  namespace: argocd
spec:
  parentRefs:
    - name: main
      namespace: kube-system
      sectionName: http
  hostnames:
    - argocd.hietala.xyz
  rules:
    - backendRefs:
        - name: argocd-server
          port: 80

If we then add a DNS override from http://argocd.hietala.xyz to 10.1.4.101 (/etc/hosts or OPNsense or similar) then we should be able to reach http://argocd.hietala.xyz.

Enabling SSL

I want https://argocd.hietala.xyz to “just work” and for that we need to tell Gateway to manage https routes:

# ...
spec:
  gatewayClassName: cilium
  listeners:
    - name: https
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - name: hietala-xyz-tls
      allowedRoutes:
        namespaces:
          from: All
    - name: http
      # Http definition from before

(Note the tls addition that terminates using a not-yet-defined certificate.)

And add the https route itself:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: argocd
  namespace: argocd
spec:
  parentRefs:
    - name: main
      namespace: kube-system
      sectionName: https
  hostnames:
    - argocd.hietala.xyz
  rules:
    - backendRefs:
        - name: argocd-server
          port: 80

This doesn’t work just yet as we need to create the hietala-xyz-tls cert.

Cert-manager

cert-manager seems like the standard way to manage certificates for Kubernetes. I don’t want to expose my services to the internet which means I need a DNS01 challenge. cert-manager doesn’t natively support Namecheap (bummer) but there’s an open source webhook Namecheap provider out there. It hasn’t been updated in a couple of years but I couldn’t find an alternative…

YOLO I guess?

The cert-manager manifest:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://charts.jetstack.io
    chart: cert-manager
    targetRevision: v1.21.0
    helm:
      values: |
        installCRDs: true
  destination:
    server: https://kubernetes.default.svc
    namespace: cert-manager
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

Note the installCRDs: true that makes things easier for us, and CreateNamespace=true which will create the cert-manager namespace for us too. Saves some typing. We also set sync-wave to -1 as it needs to sync before the cluster issuer and certificate that we’ll define later.

Then the namecheap webhook:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager-webhook-namecheap
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/kelvie/cert-manager-webhook-namecheap.git
    targetRevision: HEAD
    path: deploy/cert-manager-webhook-namecheap
    helm:
      # Identifier that our issuer will use
      values: |
        groupName: acme.namecheap.com
  destination:
    server: https://kubernetes.default.svc
    namespace: cert-manager
  syncPolicy:
    syncOptions:
      - CreateNamespace=false
    automated:
      prune: true
      selfHeal: true

Then a ClusterIssuer that uses the namecheap webhook:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
  annotations:
    # Must come after the cert-manager applications.
    # It defaults to 0 anyway but this is more explicit.
    argocd.argoproj.io/sync-wave: "0"
    # If resources doesn't exist ArgoCD may complain.
    argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
  acme:
    # Use the staging endpoint during testing!
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: homelab@hietala.xyz
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
      - dns01:
          # Use the namecheap webhook
          webhook:
            # Same identifier the webhook defined above.
            groupName: acme.namecheap.com
            solverName: namecheap
            config:
              # These are very sensitive!
              # Store them in a Sealed Secret
              apiKeySecretRef:
                name: namecheap-credentials
                key: apiKey
              apiUserSecretRef:
                name: namecheap-credentials
                key: apiUser

Make sure to use the staging issuer during testing to avoid rate limits. When you’re done playing around you can switch to the production server at https://acme-v02.api.letsencrypt.org/directory.

I created a new ClusterIssuer called letsencrypt-prod instead of replacing the url so I can easily change between them if I need to.

Create the sealed secret:

kubectl create secret generic namecheap-credentials \
  --namespace cert-manager \
  --from-literal=apiKey="key" \
  --from-literal=apiUser="user" \
  --dry-run=client -o yaml \
  | kubeseal --cert infrastructure/sealed-secrets-cert.pem \
             --format yaml \
  > gitops/apps/cert-manager/namecheap-secret.yaml

Finally we need to create the hietala-xyz-tls certificate that uses the issuer:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: hietala-xyz
  # Must match the namespace of the Gateway
  namespace: kube-system
  annotations:
    # References the issuer so must be synced after.
    argocd.argoproj.io/sync-wave: "2"
    argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
  # Gateway references this certificate using this name.
  secretName: hietala-xyz-tls
  dnsNames:
    - "*.hietala.xyz"
    - "hietala.xyz"
  issuerRef:
    # Switch to `letsencrypt-prod` later.
    name: letsencrypt-staging
    kind: ClusterIssuer

When all this has been synced we should be able to see that the certificate is created:

$ kubectl get certificate -n kube-system

NAME          READY   SECRET            AGE
hietala-xyz   True    hietala-xyz-tls   4d20h

And that we can visit https://argocd.hietala.xyz (browser will warn while we use letsencrypt-staging, on prod it should be without errors).

(If not, then you have a bunch of debugging to do. Have fun!)

Want to redirect http to https? Add this route:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: argocd-redirect
  namespace: argocd
spec:
  parentRefs:
    - name: main
      namespace: kube-system
      sectionName: http
  hostnames:
    - argocd.hietala.xyz
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

In general though I skip the http route as I tell Firefox to always use https. ArgoCD is still accessible via its external IP I defined in a previous post but you can also port-forward or proxy to it:

# http://localhost:8001/api/v1/namespaces/argocd/services/argocd-server:80/proxy/
kubectl proxy
# localhost:8123
kubectl port-forward svc/argocd-server -n argocd 8123:80

Automating DNS overrides

By now most of the things are set up in proper GitOps fashion but there’s still one thing I have to do manually: I have to add a DNS override to unbound (it’s on my OPNsense router). Doing it once is fine but it gets old fast.

Some of my overrides in unbound. Can you see a pattern?

A wildcard domain could work but I have other services running outside of Kubernetes, so I’d like a cleaner solution.

That solution is ExternalDNS, which automatically adds overrides for any existing Gateway HTTPRoute. There’s a webhook provider for OPNsense that we’ll use.

The manifest:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: external-dns
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  sources:
    - repoURL: https://kubernetes-sigs.github.io/external-dns/
      chart: external-dns
      targetRevision: 1.21.1
      helm:
        valueFiles:
          - $values/gitops/apps/external-dns/values.yaml
    - repoURL: https://git.hietala.xyz/tree/home-ops.git
      targetRevision: HEAD
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: external-dns
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

It loads values.yaml from the repo:

provider:
  name: webhook
  webhook:
    image:
      repository: ghcr.io/crutonjohn/external-dns-opnsense-webhook
      tag: v1.0.0
    env:
      - name: OPNSENSE_HOST
        value: "https://router.hietala.xyz"
      # Remember to create the `external-dns` sealed secret.
      - name: OPNSENSE_API_KEY
        valueFrom:
          secretKeyRef:
            name: external-dns
            key: api-key
      - name: OPNSENSE_API_SECRET
        valueFrom:
          secretKeyRef:
            name: external-dns
            key: api-secret
sources:
  - gateway-httproute
policy: sync
domainFilters:
  - hietala.xyz
txtOwnerId: talos-dorne

This connects to OPNsense, sources routes from the Gateway, targets the hietala.xyz domain, and uses a new external-dns secret:

kubectl create secret generic external-dns \
  --namespace external-dns \
  --from-literal=api-key="key" \
  --from-literal=api-secret="secret" \
  --dry-run=client -o yaml \
  | kubeseal --cert infrastructure/sealed-secrets-cert.pem \
             --format yaml \
  > gitops/apps/external-dns/external-dns-secret.yaml

With this I don’t have to add manual overrides anymore. Nothing like spending hours to automate a few minutes of work!

If you want to extend support to other network things, say a Minecraft server talking TCP over 25565 with a fixed IP, we can add service support to ExternalDNS:

sources:
  - gateway-httproute
  - service

And declare the Minecraft Service like so:

apiVersion: v1
kind: Service
metadata:
  name: minecraft
  annotations:
    external-dns.alpha.kubernetes.io/hostname: mc.hietala.xyz
spec:
  type: ClusterIP
  externalIPs:
    - 10.1.4.53
  selector:
    app: minecraft
  ports:
    - port: 25565
      targetPort: 25565
      protocol: TCP
      name: minecraft

Many steps but the end is nice

There’s a few moving parts but once done supporting new apps is satisfyingly easy. For example, to expose the homeassistant service under https://ha.hietala.xyz this is enough:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: homeassistant
spec:
  parentRefs:
    - name: main
      namespace: kube-system
      sectionName: https
  hostnames:
    - ha.hietala.xyz
  rules:
    - backendRefs:
        - name: homeassistant
          port: 8123

Commit and push, and https://ha.hietala.xyz is ready in a jiffy, certificates and DNS overrides included.

I encountered a bug in ArgoCD v3.3.5 where it’ll get stuck syncing the routes with extra lines in the diff (or mark them as out of sync), for example here:

    - backendRefs:
        - name: my-app
          port: 80
          group: ""
          kind: Service
          weight: 1
      matches:
        - path:
            type: PathPrefix
            value: /

You can add the lines to each route to fix it but you can also tell ArgoCD to ignore them with these lines in the Application template:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
spec:
  template:
    spec:
      ignoreDifferences:
        - group: gateway.networking.k8s.io
          kind: HTTPRoute
          jqPathExpressions:
            - .spec.parentRefs[].group
            - .spec.parentRefs[].kind
            - .spec.rules[].backendRefs[].group
            - .spec.rules[].backendRefs[].kind
            - .spec.rules[].backendRefs[].weight
            - .spec.rules[].matches
      syncPolicy:
        syncOptions:
          - ServerSideApply=true
          - RespectIgnoreDifferences=true
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论