Exposition et protection de GetHomepage dans Kubernetes

En multipliant les services exposés sur la toile, il est primordial de les protéger à minima. Déployant mes services dans k3s (article disponible ici ), je vous propose de mettre l’outil Anubis dans la boucle.

Récemment, j’ai écrit cet article concernant Anubis et sa mise en place dans une machine virtuelle :

Anubis, l’outil qui bloque la récolte agressiveLe web est un vaste terrain de jeu. Que ce soit des humains ou des robots, il y a une quantité de données et de flux qui se croisent et se bousculent. Ces dernières années, ce sont plutôt les robots de récolte de données qui prennent de plus en plus

Étant en pleine migration de mes services d’un VPS à l’autre (quelques infos dans cette page ), je me suis mis en quête de conteneuriser le maximum de service possible. Ainsi, dès qu’un outil sera déployé, il aura son instance Anubis à côté. Pour cet exemple, je vais déployer GetHomepage et Anubis pour qu’ils fonctionnent ensemble.

Rappel du contexte et prérequis#

Avant de se lancer dans le déploiement des services, il est nécessaire de se préparer un minimum. Vous devez avoir les droits et accès au cluster Kubernetes, et quelques notions quant à l’écosystème Kubernetes.

Dans mon cas, Kubernetes est exécuté via K3S sur un VPS, avec Cilium comme CNI. Étant donné que je n’ai qu’une seule machine servant de control-plane et indirectement de worker, je n’utilise ni ingress ni gateway-api, mais j’expose les services via NodePort. Le stockage n’est pas nécessaire ici, donc je n’aborderai pas la CSI. Traefik se trouve devant K3S pour rediriger le trafic externe vers les services exposés via NodePort. Enfin, l’accès à la machine nécessite de passer par Cloudflare.

💡 J’aimerais utiliser un service souverain, mais (malheureusement) Cloudflare est le seul prestataire offrant un large éventail de service gratuitement, sans (trop de) limites.

Web -> Cloudflare -> OVHcloud -> [VPS = Traefik -> NodePort -> K3S (Cilium) -> Services -> Pod]

Configuration de Cloudflare#

Dans le panneau d’administration Cloudflare, j’ai créé des enregistrements DNS de type A pour chaque domaine et sous-domaine concerné. La case “Proxy” est cochée, forçant le flux à transiter via Cloudflare avant d’arriver à sa destination.

Côté SSL/TLS, la configuration a son importance. Après plusieurs essais, le plus pertinent est d’être dans le mode encryption "SSL/TLS full (strict)". La documentation est claire et vous fournira de nombreux détails, sur le site officiel ici :

Encryption modesEncryption modes allow you to control how Cloudflare connects to your origin web server and how certificates presented by your origin are validated.Cloudflare DocsAvec le mode de chiffrement “full (strict)”, le flux entre Cloudflare et le VPS est chiffré, et est certifié avec la PKI de Cloudflare. Il est nécessaire d’utiliser les certificats Let’s Encrypt générés par Cloudflare pour que tout le système de chiffrement fonctionne de bout en bout. La génération des certificats s’effectuera par le biais de Traefik, automagiquement. Les certificats concernent uniquement la bordure, ce qui est visible et est exposé sur le web. Il ne s’agit pas de générer des certificats dans Kubernetes (pas de mTLS donc).

Configuration de Traefik#

C’est finalement Traefik qui m’a donné le plus de tissu à broder. Entre les headers qui sont différents d’une source à l’autre à cause / grâce à Cloudflare, la source n’est jamais la même. Quelques configurations s’imposent pour que tout soit en ordre. Dans cet environnement, seuls les IP provenant des sous-réseaux de Cloudflare seront reconnus et acceptés. Pour tous les autres sous-réseaux et IP, une erreur SSL et le code HTTP 523 s’affichera.

Trafik est installé en dur sur mon VPS (documentation sur J.HOMMET.NET ici ), dans le dossier /etc/traefik. Les configurations dynamiques seront dans le dossier /etc/traefik/dynamic, et les certificats seront stockés dans le fichier /etc/traefik.acme.json. Créez, changez ou mappez ces dossiers et fichiers selon votre environnement.

Pour Traefik, la logique sera la suivante :

Entrée unique en TCP/443 -> vérification des headers -> si validé, redirection vers le routeur traefik -> redirection vers le service anubis

Passons aux fichiers de configuration. Voici le fichier de configuration statique traefik.yml :

---
global:
  sendAnonymousUsage: true
  checkNewVersion: false

api:
  dashboard: false
  debug: false

providers:
  file:
    directory: "/etc/traefik/dynamic"
    watch: true

entryPoints:
  websecure:
    address: ":443"
    forwardedHeaders:
      trustedIPs:
        # Plages IP Cloudflare (à jour août 2025)
        - "173.245.48.0/20"
        - "103.21.244.0/22"
        - "103.22.200.0/22"
        - "103.31.4.0/22"
        - "141.101.64.0/18"
        - "108.162.192.0/18"
        - "190.93.240.0/20"
        - "188.114.96.0/20"
        - "197.234.240.0/22"
        - "198.41.128.0/17"
        - "162.158.0.0/15"
        - "104.16.0.0/13"
        - "104.24.0.0/14"
        - "172.64.0.0/13"
        - "131.0.72.0/22"
        - "2400:cb00::/32"
        - "2606:4700::/32"
        - "2803:f800::/32"
        - "2405:b500::/32"
        - "2405:8100::/32"
        - "2a06:98c0::/29"
        - "2c0f:f248::/32"

certificatesResolvers:
  cloudflare:
    acme:
      email: email@mail.net
      # caServer: https://acme-staging-v02.api.letsencrypt.org/directory
      caServer: https://acme-v02.api.letsencrypt.org/directory
      storage: /etc/traefik/acme.json
      certificatesDuration: 2160 # 90 jours
      keyType: EC256
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"

Et le fichier de configuration dynamique pour exposer le service Anubis pour GetHomepage (fichier rt-k3s-gethomepage.yml):

http:
  serversTransports:
    cftransport:
      insecureSkipVerify: true

  middlewares:
    headersLight:
      headers:
        frameDeny: true
        browserXssFilter: true
        hostsProxyHeaders:
          - "X-Forwarded-Host"
          - "X-Forwarded-Proto"
          - "X-Forwarded-For"

  services:
    svc-k3s-gethomepage:
      loadBalancer:
        serversTransport: cftransport
        servers:
          - url: "http://localhost:31000"

  routers:
    rt-homepage:
      entryPoints:
        - "websecure"
      rule: "Host(`hommet.net`)"
      service: "svc-k3s-gethomepage"
      middlewares:
        - headersLight
      tls:
        certResolver: cloudflare

La configuration Traefik donnée sert principalement à router le trafic HTTPS sécurisé vers un service local avec gestion de certificats via Cloudflare, en appliquant des règles de sécurité sur les headers HTTP.

  • Elle définit un routeur HTTP sécurisé (HTTPS) sur l’entrée “websecure” qui redirige les requêtes ayant pour hôte “hommet.net” vers le service exposé par NodePort dans K3S sur le port local 31000 (http://localhost:31000 ).
  • Le service svc-k3s-gethomepage est un load balancer qui pointe vers ce serveur local.
  • Un middleware headersLight applique des en-têtes HTTP pour renforcer la sécurité du navigateur, notamment en bloquant l’affichage dans des frames (frameDeny) et activant un filtre XSS côté navigateur. Trois en-têtes HTTP sont envoyés pour transmettre des informations sur l’origine de la requête au backend. Cela permet de récupérer l’adresse IP d’origine, de vérifier qu’elle provient de Cloudflare, et de s’assurer qu’elle correspond à un sous-réseau de la liste des « trustedIPs » dans la configuration statique.
  • Le TLS est activé via un certResolver configuré pour utiliser Cloudflare pour la gestion automatique des certificats SSL publics.
  • Le middleware serverTransports: insecureSkipVerify: true est important et nécessaire, pour désactiver la vérification TLS du certificat du serveur backend par Traefik quand il communique avec le service local. Ceci est utile ici parce que le backend local peut avoir un certificat autosigné ou non valide pour HTTPS (ne correspondant pas au SNI du certificat de Cloudflare), et Traefik ne bloque pas la connexion TLS vers ce backend. En utilisant Cloudflare comme résolveur de certificat public pour l’entrée HTTPS, Traefik gère des certificats publics valides pour les clients externes, mais ignore la validation interne pour le backend. Cela évite des erreurs TLS sans compromettre la sécurité client externe.

L’idéal serait un chiffrement réellement de bout en bout (du web jusqu’au pod).

Configuration des services Anubis et GetHomepage#

Maintenant, il est temps de déployer GetHomepage dans Kubernetes, avec Anubis à ses côtés. Voici le manifest complet pour les deux services, avec tout le nécessaire (namespace, serviceAccount, configMaps…). De plus, quelques sécurités sont configurées pour avoir le meilleur déploiement possible, sans trop de blocage (serviceAccount, UID et GID à 1000, pas de root…) :

apiVersion: v1
kind: Namespace
metadata:
  name: gethomepage
apiVersion: v1
---
kind: ServiceAccount
metadata:
  name: homepage
  namespace: gethomepage
  labels:
    app.kubernetes.io/name: homepage
secrets:
  - name: homepage
---
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token
metadata:
  name: homepage
  namespace: gethomepage
  labels:
    app.kubernetes.io/name: homepage
  annotations:
    kubernetes.io/service-account.name: homepage
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: anubis-policy
  namespace: gethomepage
data:
  anubis-policy.json: |
    {
      "bots": [
        {
          "name": "internal-api-traffic",
          "remote_addresses": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"],
          "action": "ALLOW"
        },
        {
          "name": "kubernetes-api",
          "path_regex": "^/api/.*$",
          "action": "ALLOW"
        },
        {
          "name": "kubernetes-apis",
          "path_regex": "^/apis/.*$",
          "action": "ALLOW"
        },
        {
          "name": "homepage-backend-api",
          "user_agent_regex": "(?i:homepage|gethomepage)",
          "action": "ALLOW"
        },
        {
          "name": "cloudflare-insights",
          "path_regex": "^/beacon\\.min\\.js$",
          "action": "ALLOW"
        },
        {
          "name": "static-files",
          "path_regex": "^/(favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\.(css|js|png|jpg|jpeg|gif|webp|ico|svg))$",
          "action": "ALLOW"
        },
        {
          "name": "anubis-api",
          "path_regex": "^/\\.within\\.website/x/cmd/anubis/api/.*$",
          "action": "ALLOW"
        },
        {
          "name": "regular-browsers",
          "user_agent_regex": "Mozilla|Chrome|Safari|Firefox|Edge",
          "action": "CHALLENGE"
        }
      ]
    }
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: homepage
  namespace: gethomepage
  labels:
    app.kubernetes.io/name: homepage
data:
  kubernetes.yaml: |
    mode: cluster
  settings.yaml: |
    title: HOMMET.NET index
    description: Un monde à part.
    language: fr
  custom.css: ""
  custom.js: ""
  bookmarks.yaml: |
    - HOMMET.NET:
        - Blog:
            - abbr: JH
              href: https://j.hommet.net
  services.yaml: ""
  widgets.yaml: |
    - kubernetes:
        cluster:
          show: false
          cpu: true
          memory: true
          showLabel: true
          label: "VPS K3S"
        nodes:
          show: true
          cpu: true
          memory: true
          showLabel: false
    - resources:
        backend: resources
        expanded: true
        cpu: true
        memory: true
        network: default
    - search:
        provider: duckduckgo
        target: _blank
  proxmox.yaml: ""
  docker.yaml: ""
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: homepage
  labels:
    app.kubernetes.io/name: homepage
rules:
  - apiGroups:
      - ""
    resources:
      - namespaces
      - pods
      - nodes
    verbs:
      - get
      - list
  - apiGroups:
      - extensions
      - networking.k8s.io
    resources:
      - ingresses
    verbs:
      - get
      - list
  - apiGroups:
      - traefik.io
    resources:
      - ingressroutes
    verbs:
      - get
      - list
  - apiGroups:
      - gateway.networking.k8s.io
    resources:
      - httproutes
      - gateways
    verbs:
      - get
      - list
  - apiGroups:
      - metrics.k8s.io
    resources:
      - nodes
      - pods
    verbs:
      - get
      - list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: homepage
  labels:
    app.kubernetes.io/name: homepage
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: homepage
subjects:
  - kind: ServiceAccount
    name: homepage
    namespace: gethomepage
---
apiVersion: v1
kind: Service
metadata:
  name: homepage
  namespace: gethomepage
  labels:
    app.kubernetes.io/name: homepage
  annotations:
spec:
  type: NodePort
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      name: anubis
      nodePort: 31000
  selector:
    app.kubernetes.io/name: homepage
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: homepage
  namespace: gethomepage
  labels:
    app.kubernetes.io/name: homepage
spec:
  revisionHistoryLimit: 3
  replicas: 1
  strategy:
    type: RollingUpdate
  selector:
    matchLabels:
      app.kubernetes.io/name: homepage
  template:
    metadata:
      labels:
        app.kubernetes.io/name: homepage
    spec:
      serviceAccountName: homepage
      automountServiceAccountToken: true
      dnsPolicy: ClusterFirst
      enableServiceLinks: true
      securityContext:
        runAsUser: 1000
        runAsGroup: 1000
        runAsNonRoot: true
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: homepage
          image: "ghcr.io/gethomepage/homepage:v1.4"
          imagePullPolicy: IfNotPresent
          env:
            - name: HOMEPAGE_ALLOWED_HOSTS
              value: hommet.net # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
          ports:
            - name: http
              containerPort: 3000
              protocol: TCP
          resources:
            limits:
              cpu: 500m
              memory: 128Mi
            requests:
              cpu: 250m
              memory: 64Mi
          securityContext:
            allowPrivilegeEscalation: false
            runAsUser: 1000
            runAsGroup: 1000
            runAsNonRoot: true
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - mountPath: /app/config/custom.js
              name: homepage-config
              subPath: custom.js
            - mountPath: /app/config/custom.css
              name: homepage-config
              subPath: custom.css
            - mountPath: /app/config/bookmarks.yaml
              name: homepage-config
              subPath: bookmarks.yaml
            - mountPath: /app/config/docker.yaml
              name: homepage-config
              subPath: docker.yaml
            - mountPath: /app/config/kubernetes.yaml
              name: homepage-config
              subPath: kubernetes.yaml
            - mountPath: /app/config/proxmox.yaml
              name: homepage-config
              subPath: proxmox.yaml
            - mountPath: /app/config/services.yaml
              name: homepage-config
              subPath: services.yaml
            - mountPath: /app/config/settings.yaml
              name: homepage-config
              subPath: settings.yaml
            - mountPath: /app/config/widgets.yaml
              name: homepage-config
              subPath: widgets.yaml
            - mountPath: /app/config/logs
              name: logs
        - name: anubis
          image: ghcr.io/techarohq/anubis:latest
          imagePullPolicy: Always
          env:
            - name: BIND
              value: ":8080"
            - name: DIFFICULTY
              value: "4"
            - name: SERVE_ROBOTS_TXT
              value: "false"
            - name: OG_PASSTHROUGH
              value: "true" # Sert à passer les requêtes OG si déjà validé
            - name: OG_EXPIRY_TIME
              value: "48h" # Durée cache OG allongée pour moins de tests
            - name: OG_MAX_QUEUE_LENGTH
              value: "50" # Limiter taille file d’attente OG (prévenir montée en charge)
            - name: TIMEOUT_HTTP
              value: "15s"
            - name: REDIRECT_DOMAINS
              value: "hommet.net"
            - name: TARGET
              value: "http://localhost:3000"
            - name: COOKIE_DOMAIN
              value: "hommet.net"
            - name: POLICY_FNAME
              value: "/app/config/anubis-policy.json"
            - name: REDIRECT_URL
              value: "https://hommet.net/"
            - name: TRUSTED_PROXIES
              value: "173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32"
          resources:
            limits:
              cpu: 500m
              memory: 128Mi
            requests:
              cpu: 250m
              memory: 64Mi
          securityContext:
            runAsUser: 1000
            runAsGroup: 1000
            runAsNonRoot: true
            allowPrivilegeEscalation: false
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - mountPath: /app/config/anubis-policy.json
              name: anubis-policy
              subPath: anubis-policy.json
          readinessProbe:
            exec:
              command: ["anubis", "--healthcheck"]
            initialDelaySeconds: 1
            periodSeconds: 5
            timeoutSeconds: 30
            failureThreshold: 5
          livenessProbe:
            exec:
              command: ["anubis", "--healthcheck"]
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 30
            failureThreshold: 5
      volumes:
        - name: anubis-policy
          configMap:
            name: anubis-policy
        - name: homepage-config
          configMap:
            name: homepage
        - name: logs
          emptyDir: {}

La configuration et le déploiement d’Anubis est assez simple, que ce soit pour Kubernetes qu’en dur sur une machine. La première étape consiste à créer le namespace :

kubectl create ns gethomepage

Dans le déploiement de Gethomepage, il faut laisser les montages de tous les fichiers de configuration même si vous ne vous en servez pas. Ici, je n’utilise pas docker.yaml ni proxmox.yaml, mais Gethomepage ne démarrera pas s’il ne trouve pas ces fichiers (même vide).

Enregistrez ce manifest dans un fichier (par exemple gethomepage.yaml) et déployez-le kubectl apply -f gethomepage.yaml.

Lors du premier déploiement, je n’arrivais plus à avoir les informations des widgets de Gethomepage. En effet, la page Gethomepage se rafraîchit toutes les quelques secondes pour mettre à jour les widgets. Avec Anubis devant, ça obligeait à refaire le challenge de navigateur pour rafraichir la page et l’afficher ; ce n’était pas viable et ça finissait par saturer la machine. Ainsi, dans la configuration d’Anubis, quelques règles sont en “ALLOW” pour esquiver le challenge et éviter de bloquer les ressources internes/locales à Gethomepage et à Cloudflare.

Vérifiez que tous les deux pods sont lancés, que le statut est bien Ready et que le service fonctionne, en accédant à l’URL publique de GetHomepage (pour ma part, https://hommet.net ).

kubectl get svc,pod,deploy,cm,sa -n gethomepage
NAME               TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)                         AGE
service/homepage   NodePort   10.43.103.126   <none>        3000:32661/TCP,8080:31000/TCP   16h

NAME                            READY   STATUS    RESTARTS   AGE
pod/homepage-6b654b7f9c-fjrsv   2/2     Running   0          14h

NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/homepage   1/1     1            1           16h

NAME                         DATA   AGE
configmap/anubis-policy      1      14h
configmap/homepage           9      15h
configmap/kube-root-ca.crt   1      16h

NAME                      SECRETS   AGE
serviceaccount/homepage   1         16h

Sources#

Kubernetes Installation - HomepageInstall on KuberneteslogoKubernetes | AnubisWhen setting up Anubis in Kubernetes, you want to make sure that you thread requests through Anubis kinda like this:Help with insecureSkipVerifyEnable Traefik access log in JSON format (doc) and share the output from a request.Traefik Labs Community Forumbluepuma77Plages IPCette page constitue la source définitive des plages IP actuelles de Cloudflare.Logo Cloudflare, couleur

Julien HOMMET
11 minutes
2257 mots
tuto