A simple HTTPS ingress for Kubernetes clusters, designed to work well with Anubis.

feat: get things working

Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 9de6bb43 d592cd61

+49
.gitea/workflows/docker.yaml
··· 1 + name: Docker 2 + 3 + on: 4 + push: 5 + branches: [ "main" ] 6 + # Publish semver tags as releases. 7 + tags: [ 'v*.*.*' ] 8 + pull_request: 9 + branches: [ "main" ] 10 + 11 + jobs: 12 + build: 13 + runs-on: ubuntu-latest 14 + permissions: 15 + contents: read 16 + packages: write 17 + steps: 18 + - name: Checkout repository 19 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 + 21 + - name: Set up Docker Buildx 22 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 23 + 24 + - name: Log into registry 25 + if: github.event_name != 'pull_request' 26 + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 27 + with: 28 + registry: git.xeserv.us 29 + username: mimi 30 + password: ${{ secrets.RELEASE_GITEA_TOKEN }} 31 + 32 + - name: Build and push 33 + if: github.event_name != 'pull_request' 34 + id: build 35 + uses: docker/bake-action@76f9fa3a758507623da19f6092dc4089a7e61592 # v6.6.0 36 + with: 37 + source: . 38 + push: true 39 + set: | 40 + hythlodaeus.tags=git.xeserv.us/techaro/hythlodaeus:latest 41 + 42 + - name: Build Docker image 43 + if: github.event_name == 'pull_request' 44 + uses: docker/bake-action@76f9fa3a758507623da19f6092dc4089a7e61592 # v6.6.0 45 + with: 46 + source: . 47 + push: false 48 + set: | 49 + hythlodaeus.tags=git.xeserv.us/techaro/hythlodaeus:latest
+1 -1
LICENSE
··· 1 1 MIT License 2 2 3 - Copyright (c) 2025 xe 3 + Copyright (c) 2025 Caleb Doxsey and Xe Iaso 4 4 5 5 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 6
+2
README.md
··· 1 1 # Hythlodaeus 2 2 3 3 A simple ingress controller for Kubernetes clusters that works with Anubis. 4 + 5 + This is a fork of [simple ingress controller](https://github.com/calebdoxsey/kubernetes-simple-ingress-controller).
+66
cmd/hythlodaeus/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "flag" 6 + "log" 7 + "os" 8 + "path/filepath" 9 + 10 + "git.xeserv.us/Techaro/hythlodaeus/server" 11 + "git.xeserv.us/Techaro/hythlodaeus/watcher" 12 + "github.com/facebookgo/flagenv" 13 + "golang.org/x/sync/errgroup" 14 + "k8s.io/client-go/kubernetes" 15 + "k8s.io/client-go/rest" 16 + "k8s.io/client-go/tools/clientcmd" 17 + ) 18 + 19 + var ( 20 + httpBind = flag.String("http-bind", ":80", "the host:port to bind plain HTTP") 21 + httpsBind = flag.String("https-bind", ":443", "the host:port to bind secure HTTPS") 22 + ) 23 + 24 + func main() { 25 + flagenv.Parse() 26 + flag.Parse() 27 + 28 + client, err := kubernetes.NewForConfig(getKubernetesConfig()) 29 + if err != nil { 30 + log.Fatalf("can't make kubernetes client: %v", err) 31 + } 32 + 33 + s := server.New(server.WithHTTPBind(*httpBind), server.WithHTTPSBind(*httpsBind)) 34 + w := watcher.New(client, func(payload *watcher.Payload) { 35 + s.Update(payload) 36 + }) 37 + 38 + eg, ctx := errgroup.WithContext(context.Background()) 39 + eg.Go(func() error { 40 + return s.Run(ctx) 41 + }) 42 + eg.Go(func() error { 43 + return w.Run(ctx) 44 + }) 45 + if err := eg.Wait(); err != nil { 46 + log.Fatal(err) 47 + } 48 + } 49 + 50 + func getKubernetesConfig() *rest.Config { 51 + config, err := rest.InClusterConfig() 52 + if err != nil { 53 + config, err = clientcmd.BuildConfigFromFlags("", filepath.Join(homeDir(), ".kube", "config")) 54 + } 55 + if err != nil { 56 + log.Fatalf("can't get kubernetes config: %v", err) 57 + } 58 + return config 59 + } 60 + 61 + func homeDir() string { 62 + if h := os.Getenv("HOME"); h != "" { 63 + return h 64 + } 65 + return os.Getenv("USERPROFILE") // windows 66 + }
+27
docker-bake.hcl
··· 1 + variable "ALPINE_VERSION" { default = "edge" } 2 + variable "GO_VERSION" { default = "1.24" } 3 + variable "GITHUB_SHA" { default = "devel" } 4 + 5 + group "default" { 6 + targets = [ 7 + "hythlodaeus", 8 + ] 9 + } 10 + 11 + target "hythlodaeus" { 12 + args = { 13 + ALPINE_VERSION = null 14 + GO_VERSION = null 15 + COMPONENT = "hythlodaeus" 16 + } 17 + context = "." 18 + dockerfile = "./docker/alpine.Dockerfile" 19 + platforms = [ 20 + "linux/amd64", 21 + "linux/arm64", 22 + ] 23 + pull = true 24 + tags = [ 25 + "git.xeserv.us/techaro/hythlodaeus:${GITHUB_SHA}" 26 + ] 27 + }
+28
docker/alpine.Dockerfile
··· 1 + ARG GO_VERSION=1.24 2 + ARG ALPINE_VERSION=edge 3 + FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine AS build 4 + 5 + ARG TARGETOS 6 + ARG TARGETARCH 7 + ARG COMPONENT 8 + 9 + WORKDIR /app 10 + 11 + COPY go.mod go.sum ./ 12 + RUN go mod download 13 + 14 + COPY . . 15 + RUN --mount=type=cache,target=/root/.cache GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /app/bin/${COMPONENT} ./cmd/${COMPONENT} 16 + 17 + FROM alpine:${ALPINE_VERSION} AS run 18 + WORKDIR /app 19 + 20 + ARG COMPONENT 21 + 22 + RUN apk -U add ca-certificates mailcap 23 + 24 + COPY --from=build /app/bin/${COMPONENT} /app/bin/${COMPONENT} 25 + 26 + CMD ["/app/bin/${COMPONENT}"] 27 + 28 + LABEL org.opencontainers.image.source="https://git.xeserv.us/techaro/hivemind"
+18 -10
go.mod
··· 3 3 go 1.24.3 4 4 5 5 require ( 6 + github.com/bep/debounce v1.2.0 7 + github.com/exaring/ja4plus v0.0.1 6 8 github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 9 + github.com/google/uuid v1.6.0 10 + golang.org/x/net v0.40.0 11 + golang.org/x/sync v0.14.0 12 + k8s.io/api v0.33.1 13 + k8s.io/apimachinery v0.33.1 7 14 k8s.io/client-go v0.33.1 8 15 ) 9 16 ··· 17 24 github.com/Microsoft/go-winio v0.6.2 // indirect 18 25 github.com/ProtonMail/go-crypto v1.1.6 // indirect 19 26 github.com/Songmu/gitconfig v0.2.0 // indirect 20 - github.com/TecharoHQ/yeet v0.2.2 // indirect 27 + github.com/TecharoHQ/yeet v0.2.3 // indirect 21 28 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect 22 29 github.com/cavaliergopher/cpio v1.0.1 // indirect 23 30 github.com/cli/go-gh v0.1.0 // indirect ··· 49 56 github.com/google/go-cmp v0.7.0 // indirect 50 57 github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect 51 58 github.com/google/rpmpack v0.6.1-0.20240329070804-c2247cbb881a // indirect 52 - github.com/google/uuid v1.6.0 // indirect 53 59 github.com/goreleaser/chglog v0.7.0 // indirect 54 60 github.com/goreleaser/fileglob v1.3.0 // indirect 55 61 github.com/goreleaser/nfpm/v2 v2.42.0 // indirect ··· 79 85 github.com/x448/float16 v0.8.4 // indirect 80 86 github.com/xanzy/ssh-agent v0.3.3 // indirect 81 87 gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect 82 - golang.org/x/crypto v0.37.0 // indirect 88 + golang.org/x/crypto v0.38.0 // indirect 83 89 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 84 - golang.org/x/net v0.39.0 // indirect 90 + golang.org/x/mod v0.24.0 // indirect 85 91 golang.org/x/oauth2 v0.27.0 // indirect 86 - golang.org/x/sys v0.32.0 // indirect 87 - golang.org/x/term v0.31.0 // indirect 88 - golang.org/x/text v0.24.0 // indirect 92 + golang.org/x/sys v0.33.0 // indirect 93 + golang.org/x/term v0.32.0 // indirect 94 + golang.org/x/text v0.25.0 // indirect 89 95 golang.org/x/time v0.9.0 // indirect 96 + golang.org/x/tools v0.33.0 // indirect 90 97 golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect 91 98 google.golang.org/protobuf v1.36.5 // indirect 92 99 gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 93 100 gopkg.in/inf.v0 v0.9.1 // indirect 94 101 gopkg.in/warnings.v0 v0.1.2 // indirect 95 102 gopkg.in/yaml.v3 v3.0.1 // indirect 96 - k8s.io/api v0.33.1 // indirect 97 - k8s.io/apimachinery v0.33.1 // indirect 98 103 k8s.io/klog/v2 v2.130.1 // indirect 99 104 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 100 105 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect ··· 104 109 sigs.k8s.io/yaml v1.4.0 // indirect 105 110 ) 106 111 107 - tool github.com/TecharoHQ/yeet/cmd/yeet 112 + tool ( 113 + github.com/TecharoHQ/yeet/cmd/yeet 114 + golang.org/x/tools/cmd/goimports 115 + )
+24 -14
go.sum
··· 24 24 github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= 25 25 github.com/Songmu/gitconfig v0.2.0 h1:pX2++u4KUq+K2k/ZCzGXLtkD3ceCqIdi0tDyb+IbSyo= 26 26 github.com/Songmu/gitconfig v0.2.0/go.mod h1:cB5bYJer+pl7W8g6RHFwL/0X6aJROVrYuHlvc7PT+hE= 27 - github.com/TecharoHQ/yeet v0.2.2 h1:L5pc2LhlR9PaMgGVmvK0pPtRrUVYkec54KDcEyIRf2Q= 28 - github.com/TecharoHQ/yeet v0.2.2/go.mod h1:avLiwxZpNY37A/o35XledvdmGnTkm3G7+Oskxca6Z7Y= 27 + github.com/TecharoHQ/yeet v0.2.3 h1:Pcsnq5HTnk4Xntlu/FNEidH7x55bIx+f5Mk1hpVIngs= 28 + github.com/TecharoHQ/yeet v0.2.3/go.mod h1:avLiwxZpNY37A/o35XledvdmGnTkm3G7+Oskxca6Z7Y= 29 29 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 30 30 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 31 31 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 32 32 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 33 + github.com/bep/debounce v1.2.0 h1:wXds8Kq8qRfwAOpAxHrJDbCXgC5aHSzgQb/0gKsHQqo= 34 + github.com/bep/debounce v1.2.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 33 35 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= 34 36 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= 35 37 github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= ··· 62 64 github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 63 65 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 64 66 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 67 + github.com/exaring/ja4plus v0.0.1 h1:JqFV1Jxi7Of48NpS5qAoueZq+xF1uaCz3wwnoxTU7Mk= 68 + github.com/exaring/ja4plus v0.0.1/go.mod h1:W9UnA4hC2x6dL+WvwphbNDUH0FWVTHfF0p+vk0my5SY= 65 69 github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0= 66 70 github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= 67 71 github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc= ··· 257 261 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 258 262 gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= 259 263 gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= 264 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 265 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 260 266 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 261 267 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 262 268 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 263 269 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 264 - golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 265 - golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 270 + golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 271 + golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 266 272 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 267 273 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 268 274 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 269 275 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 276 + golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 277 + golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 270 278 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 271 279 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 272 280 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 273 281 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 274 282 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 275 - golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 276 - golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 283 + golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 284 + golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 277 285 golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 278 286 golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 279 287 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 280 288 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 281 289 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 290 + golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 291 + golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 282 292 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 283 293 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 284 294 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 296 306 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 297 307 golang.org/x/sys v0.0.0-20220818161305-2296e01440c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 298 308 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 299 - golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 300 - golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 309 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 310 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 301 311 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 302 - golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 303 - golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 312 + golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 313 + golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 304 314 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 305 315 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 306 316 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 307 317 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 308 - golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 309 - golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 318 + golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 319 + golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 310 320 golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 311 321 golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 312 322 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 313 323 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 314 324 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 315 325 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 316 - golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 317 - golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 326 + golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 327 + golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 318 328 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 319 329 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 320 330 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-307
main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "crypto/tls" 6 - "errors" 7 - "flag" 8 - "fmt" 9 - "log" 10 - "log/slog" 11 - "net/url" 12 - "path/filepath" 13 - "regexp" 14 - "sync" 15 - "time" 16 - 17 - "github.com/bep/debounce" 18 - "github.com/facebookgo/flagenv" 19 - networkingv1 "k8s.io/api/networking/v1" 20 - "k8s.io/apimachinery/pkg/labels" 21 - "k8s.io/client-go/informers" 22 - "k8s.io/client-go/kubernetes" 23 - "k8s.io/client-go/tools/cache" 24 - "k8s.io/client-go/tools/clientcmd" 25 - "k8s.io/client-go/util/homedir" 26 - ) 27 - 28 - var ( 29 - kubeconfig = flag.String("kubeconfig", defaultKubeconfig(), "absolute path to the Kubernetes client configuration file") 30 - ) 31 - 32 - func defaultKubeconfig() string { 33 - if home := homedir.HomeDir(); home != "" { 34 - return filepath.Join(home, ".kube", "config") 35 - } 36 - 37 - return "" 38 - } 39 - 40 - func main() { 41 - flagenv.Parse() 42 - flag.Parse() 43 - 44 - config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig) 45 - if err != nil { 46 - log.Fatal(err) 47 - } 48 - 49 - client, err := kubernetes.NewForConfig(config) 50 - if err != nil { 51 - panic(err.Error()) 52 - } 53 - 54 - } 55 - 56 - type Payload struct { 57 - Ingresses []IngressPayload 58 - TLSCerts map[string]*tls.Certificate 59 - } 60 - 61 - type IngressPayload struct { 62 - Ingress *networkingv1.Ingress 63 - ServicePorts map[string]map[string]int 64 - } 65 - 66 - type Watcher struct { 67 - cli kubernetes.Interface 68 - onChange func(*Payload) 69 - } 70 - 71 - func p[T any](t T) *T { return &t } 72 - 73 - func (w *Watcher) update() { 74 - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 75 - defer cancel() 76 - 77 - if err := w.updateInner(ctx); err != nil { 78 - slog.Error("can't handle changes", "err", err) 79 - } 80 - } 81 - 82 - func (w *Watcher) updateInner(ctx context.Context) error { 83 - factory := informers.NewSharedInformerFactory(w.cli, time.Minute) 84 - secretLister := factory.Core().V1().Secrets().Lister() 85 - serviceLister := factory.Core().V1().Services().Lister() 86 - ingressLister := factory.Networking().V1().Ingresses().Lister() 87 - 88 - ingresses, err := ingressLister.List(labels.Everything()) 89 - if err != nil { 90 - return fmt.Errorf("failed to list ingresses: %w", err) 91 - } 92 - 93 - result := &Payload{ 94 - TLSCerts: map[string]*tls.Certificate{}, 95 - } 96 - 97 - var errs []error 98 - 99 - addBackend := func(ingp *IngressPayload, backend *networkingv1.IngressBackend) error { 100 - svc, err := serviceLister.Services(ingp.Ingress.Namespace).Get(backend.Service.Port.Name) 101 - if err != nil { 102 - return fmt.Errorf("can't get secret: namespace=%q name=%q: %w", ingp.Ingress.Namespace, backend.Service.Port.Name, err) 103 - } 104 - 105 - ports := map[string]int{} 106 - for _, port := range svc.Spec.Ports { 107 - ports[port.Name] = int(port.Port) 108 - } 109 - 110 - ingp.ServicePorts[svc.Name] = ports 111 - 112 - return nil 113 - } 114 - 115 - for _, ingress := range ingresses { 116 - if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "hythlodaeus" { 117 - continue 118 - } 119 - 120 - ingp := &IngressPayload{ 121 - ServicePorts: map[string]map[string]int{}, 122 - } 123 - var ingerrs []error 124 - 125 - for _, tlsSpec := range ingress.Spec.TLS { 126 - if tlsSpec.SecretName != "" { 127 - sec, err := secretLister.Secrets(ingress.Namespace).Get(tlsSpec.SecretName) 128 - if err != nil { 129 - ingerrs = append(ingerrs, err) 130 - slog.Error("can't read secret", "namespace", ingress.Namespace, "secret", tlsSpec.SecretName, "err", err) 131 - continue 132 - } 133 - 134 - cert, err := tls.X509KeyPair(sec.Data["tls.crt"], sec.Data["tls.key"]) 135 - if err != nil { 136 - ingerrs = append(ingerrs, err) 137 - slog.Error("can't load TLS keypair", "namespace", ingress.Namespace, "secret", tlsSpec.SecretName, "err", err) 138 - continue 139 - } 140 - 141 - result.TLSCerts[tlsSpec.SecretName] = &cert 142 - } 143 - } 144 - 145 - if ingress.Spec.DefaultBackend != nil { 146 - addBackend(ingp, ingress.Spec.DefaultBackend) 147 - } 148 - 149 - for _, rule := range ingress.Spec.Rules { 150 - if rule.HTTP != nil { 151 - continue 152 - } 153 - 154 - for _, path := range rule.HTTP.Paths { 155 - addBackend(ingp, &path.Backend) 156 - } 157 - } 158 - 159 - if len(ingerrs) != 0 { 160 - errs = append(errs, fmt.Errorf("can't make ingress payload: namespace=%q name=%q %w", ingress.Namespace, ingress.Name, errors.Join(ingerrs...))) 161 - continue 162 - } 163 - 164 - result.Ingresses = append(result.Ingresses, *ingp) 165 - } 166 - 167 - if len(errs) != 0 { 168 - return errors.Join(errs...) 169 - } 170 - 171 - w.onChange(result) 172 - 173 - return nil 174 - } 175 - 176 - func (w *Watcher) Run(ctx context.Context) error { 177 - factory := informers.NewSharedInformerFactory(w.cli, 15*time.Second) 178 - 179 - debounced := debounce.New(time.Second) 180 - handler := cache.ResourceEventHandlerFuncs{ 181 - AddFunc: func(obj interface{}) { 182 - debounced(w.update) 183 - }, 184 - UpdateFunc: func(oldObj, newObj interface{}) { 185 - debounced(w.update) 186 - }, 187 - DeleteFunc: func(obj interface{}) { 188 - debounced(w.update) 189 - }, 190 - } 191 - 192 - var wg sync.WaitGroup 193 - wg.Add(1) 194 - go func() { 195 - informer := factory.Core().V1().Secrets().Informer() 196 - informer.AddEventHandler(handler) 197 - informer.Run(ctx.Done()) 198 - wg.Done() 199 - }() 200 - 201 - wg.Add(1) 202 - go func() { 203 - informer := factory.Networking().V1().Ingresses().Informer() 204 - informer.AddEventHandler(handler) 205 - informer.Run(ctx.Done()) 206 - wg.Done() 207 - }() 208 - 209 - wg.Add(1) 210 - go func() { 211 - informer := factory.Core().V1().Services().Informer() 212 - informer.AddEventHandler(handler) 213 - informer.Run(ctx.Done()) 214 - wg.Done() 215 - }() 216 - 217 - wg.Wait() 218 - return nil 219 - } 220 - 221 - type routingTableBackend struct { 222 - pathRE *regexp.Regexp 223 - url *url.URL 224 - } 225 - 226 - func newRoutingTableBackend(scheme string, path string, serviceName string, servicePort int) (routingTableBackend, error) { 227 - rtb := routingTableBackend{ 228 - url: &url.URL{ 229 - Scheme: scheme, 230 - Host: fmt.Sprintf("%s:%d", serviceName, servicePort), 231 - }, 232 - } 233 - var err error 234 - if path != "" { 235 - rtb.pathRE, err = regexp.Compile(path) 236 - } 237 - return rtb, err 238 - } 239 - 240 - func (rtb routingTableBackend) matches(path string) bool { 241 - if rtb.pathRE == nil { 242 - return true 243 - } 244 - return rtb.pathRE.MatchString(path) 245 - } 246 - 247 - type RoutingTable struct { 248 - certsByHost map[string]map[string]*tls.Certificate 249 - backendsByHost map[string][]routingTableBackend 250 - } 251 - 252 - func NewRoutingTable(payload *Payload) *RoutingTable { 253 - result := &RoutingTable{ 254 - certsByHost: make(map[string]map[string]*tls.Certificate), 255 - backendsByHost: make(map[string][]routingTableBackend), 256 - } 257 - 258 - result.init(payload) 259 - 260 - return result 261 - } 262 - 263 - func (rt *RoutingTable) init(payload *Payload) { 264 - if payload == nil { 265 - return 266 - } 267 - 268 - for _, ingp := range payload.Ingresses { 269 - for _, rule := range ingp.Ingress.Spec.Rules { 270 - certs, ok := rt.certsByHost[rule.Host] 271 - if !ok { 272 - certs = make(map[string]*tls.Certificate) 273 - rt.certsByHost[rule.Host] = certs 274 - } 275 - 276 - for _, tlsSpec := range ingp.Ingress.Spec.TLS { 277 - for _, h := range tlsSpec.Hosts { 278 - cert, ok := payload.TLSCerts[tlsSpec.SecretName] 279 - if ok { 280 - certs[h] = cert 281 - } 282 - } 283 - } 284 - rt.addBackend(ingp, rule) 285 - } 286 - } 287 - } 288 - 289 - func (rt *RoutingTable) addBackend(ingp IngressPayload, rule networkingv1.IngressRule) { 290 - scheme := "http" 291 - 292 - if rule.HTTP == nil { 293 - if ingp.Ingress.Spec.DefaultBackend != nil { 294 - backend := ingp.Ingress.Spec.DefaultBackend 295 - rtb, err := newRoutingTableBackend(scheme, "", backend.Service.Name, int(backend.Service.Port.Number)) 296 - if err != nil { 297 - slog.Error("this should not happen, can't create routing table backend", "err", err) 298 - } 299 - rt.backendsByHost[rule.Host] = append(rt.backendsByHost[rule.Host], rtb) 300 - } 301 - } else { 302 - for _, path := range rule.HTTP.Paths { 303 - backend := path.Backend 304 - rtb, err := 305 - } 306 - } 307 - }
+2 -2
package-lock.json
··· 1 1 { 2 - "name": "@xe/hythlodaeus", 2 + "name": "@techaro/hythlodaeus", 3 3 "version": "0.1.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 - "name": "@xe/hythlodaeus", 8 + "name": "@techaro/hythlodaeus", 9 9 "version": "0.1.0", 10 10 "license": "MIT", 11 11 "devDependencies": {
+1 -1
package.json
··· 1 1 { 2 - "name": "@xe/hythlodaeus", 2 + "name": "@techaro/hythlodaeus", 3 3 "version": "0.1.0", 4 4 "description": "A simple ingress controller for Kubernetes clusters that works with Anubis", 5 5 "main": "index.js",
+28
server/config.go
··· 1 + package server 2 + 3 + type config struct { 4 + httpBind string 5 + httpsBind string 6 + } 7 + 8 + func defaultConfig() *config { 9 + return &config{ 10 + httpBind: ":80", 11 + httpsBind: ":443", 12 + } 13 + } 14 + 15 + // An Option modifies the config. 16 + type Option func(*config) 17 + 18 + func WithHTTPBind(httpBind string) Option { 19 + return func(cfg *config) { 20 + cfg.httpBind = httpBind 21 + } 22 + } 23 + 24 + func WithHTTPSBind(httpsBind string) Option { 25 + return func(cfg *config) { 26 + cfg.httpsBind = httpsBind 27 + } 28 + }
+34
server/event.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "sync" 6 + ) 7 + 8 + // An Event is used to communicate that something has happened. 9 + type Event struct { 10 + once sync.Once 11 + C chan struct{} 12 + } 13 + 14 + // NewEvent creates a new Event. 15 + func NewEvent() *Event { 16 + return &Event{ 17 + C: make(chan struct{}), 18 + } 19 + } 20 + 21 + // Set sets the event by closing the C channel. After the first time, calls to set are a no-op. 22 + func (e *Event) Set() { 23 + e.once.Do(func() { 24 + close(e.C) 25 + }) 26 + } 27 + 28 + // Wait waits for the event to get set. 29 + func (e *Event) Wait(ctx context.Context) { 30 + select { 31 + case <-ctx.Done(): 32 + case <-e.C: 33 + } 34 + }
+170
server/route.go
··· 1 + package server 2 + 3 + import ( 4 + "crypto/tls" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "net/url" 9 + "regexp" 10 + "strings" 11 + 12 + "git.xeserv.us/Techaro/hythlodaeus/watcher" 13 + networking "k8s.io/api/networking/v1" 14 + "k8s.io/apimachinery/pkg/util/intstr" 15 + ) 16 + 17 + const BackendProtocolAnnotation = "kubernetes-simple-ingress-controller/backend-protocol" 18 + 19 + // A RoutingTable contains the information needed to route a request. 20 + type RoutingTable struct { 21 + certificatesByHost map[string]map[string]*tls.Certificate 22 + backendsByHost map[string][]routingTableBackend 23 + } 24 + 25 + type routingTableBackend struct { 26 + pathRE *regexp.Regexp 27 + url *url.URL 28 + } 29 + 30 + func newRoutingTableBackend(scheme, path, serviceName, serviceNamespace string, servicePort int) (routingTableBackend, error) { 31 + rtb := routingTableBackend{ 32 + url: &url.URL{ 33 + Scheme: scheme, 34 + Host: fmt.Sprintf("%s.%s.svc.cluster.local", serviceName, serviceNamespace), 35 + }, 36 + } 37 + 38 + if servicePort != 0 { 39 + rtb.url.Host += fmt.Sprintf(":%d", servicePort) 40 + } 41 + 42 + var err error 43 + if path != "" { 44 + rtb.pathRE, err = regexp.Compile(path) 45 + } 46 + return rtb, err 47 + } 48 + 49 + func (rtb routingTableBackend) matches(path string) bool { 50 + if rtb.pathRE == nil { 51 + return true 52 + } 53 + return rtb.pathRE.MatchString(path) 54 + } 55 + 56 + // NewRoutingTable creates a new RoutingTable. 57 + func NewRoutingTable(payload *watcher.Payload) *RoutingTable { 58 + rt := &RoutingTable{ 59 + certificatesByHost: make(map[string]map[string]*tls.Certificate), 60 + backendsByHost: make(map[string][]routingTableBackend), 61 + } 62 + rt.init(payload) 63 + return rt 64 + } 65 + 66 + func (rt *RoutingTable) init(payload *watcher.Payload) { 67 + if payload == nil { 68 + return 69 + } 70 + for _, ingressPayload := range payload.Ingresses { 71 + for _, rule := range ingressPayload.Ingress.Spec.Rules { 72 + m, ok := rt.certificatesByHost[rule.Host] 73 + if !ok { 74 + m = make(map[string]*tls.Certificate) 75 + rt.certificatesByHost[rule.Host] = m 76 + } 77 + for _, t := range ingressPayload.Ingress.Spec.TLS { 78 + for _, h := range t.Hosts { 79 + cert, ok := payload.TLSCertificates[t.SecretName] 80 + if ok { 81 + m[h] = cert 82 + } 83 + } 84 + } 85 + rt.addBackend(ingressPayload, rule) 86 + } 87 + } 88 + } 89 + 90 + func (rt *RoutingTable) addBackend(ingressPayload watcher.IngressPayload, rule networking.IngressRule) { 91 + scheme, ok := ingressPayload.Ingress.Annotations[BackendProtocolAnnotation] 92 + if !ok { 93 + scheme = "http" 94 + } 95 + scheme = strings.ToLower(scheme) 96 + 97 + if rule.HTTP == nil { 98 + if ingressPayload.Ingress.Spec.DefaultBackend != nil { 99 + backend := ingressPayload.Ingress.Spec.DefaultBackend 100 + rtb, err := newRoutingTableBackend(scheme, "", backend.Service.Name, ingressPayload.Ingress.Namespace, 101 + rt.getServicePort(ingressPayload, backend.Service.Name, intstr.FromInt(int(backend.Service.Port.Number)))) 102 + if err != nil { 103 + // this shouldn't happen 104 + slog.Error("[unexpected] can't add routing table backend", "err", err) 105 + return 106 + } 107 + rt.backendsByHost[rule.Host] = append(rt.backendsByHost[rule.Host], rtb) 108 + } 109 + } else { 110 + for _, path := range rule.HTTP.Paths { 111 + backend := path.Backend 112 + rtb, err := newRoutingTableBackend(scheme, path.Path, backend.Service.Name, ingressPayload.Ingress.Namespace, 113 + rt.getServicePort(ingressPayload, backend.Service.Name, intstr.FromInt(int(backend.Service.Port.Number)))) 114 + if err != nil { 115 + slog.Error("can't add routing table backend", "err", err) 116 + continue 117 + } 118 + rt.backendsByHost[rule.Host] = append(rt.backendsByHost[rule.Host], rtb) 119 + } 120 + } 121 + } 122 + 123 + func (rt *RoutingTable) getServicePort(ingressPayload watcher.IngressPayload, serviceName string, servicePort intstr.IntOrString) int { 124 + if servicePort.Type == intstr.Int && servicePort.IntVal != 0 { 125 + return servicePort.IntValue() 126 + } 127 + if m, ok := ingressPayload.ServicePorts[serviceName]; ok { 128 + return m[servicePort.String()] 129 + } 130 + return 80 131 + } 132 + 133 + func (rt *RoutingTable) matches(sni string, certHost string) bool { 134 + for strings.HasPrefix(certHost, "*.") { 135 + if idx := strings.IndexByte(sni, '.'); idx >= 0 { 136 + sni = sni[idx+1:] 137 + } else { 138 + return false 139 + } 140 + certHost = certHost[2:] 141 + } 142 + return sni == certHost 143 + } 144 + 145 + // GetCertificate gets a certificate. 146 + func (rt *RoutingTable) GetCertificate(sni string) (*tls.Certificate, error) { 147 + hostCerts, ok := rt.certificatesByHost[sni] 148 + if ok { 149 + for h, cert := range hostCerts { 150 + if rt.matches(sni, h) { 151 + return cert, nil 152 + } 153 + } 154 + } 155 + return nil, fmt.Errorf("certificate not found for %s", sni) 156 + } 157 + 158 + // GetBackend gets the backend for the given host and path. 159 + func (rt *RoutingTable) GetBackend(host, path string) (*url.URL, error) { 160 + if idx := strings.IndexByte(host, ':'); idx > 0 { 161 + host = host[:idx] 162 + } 163 + backends := rt.backendsByHost[host] 164 + for _, backend := range backends { 165 + if backend.matches(path) { 166 + return backend.url, nil 167 + } 168 + } 169 + return nil, errors.New("backend not found") 170 + }
+121
server/server.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "crypto/tls" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "net/http/httputil" 10 + "sync/atomic" 11 + 12 + "git.xeserv.us/Techaro/hythlodaeus/watcher" 13 + "github.com/exaring/ja4plus" 14 + "github.com/google/uuid" 15 + "golang.org/x/net/http2" 16 + "golang.org/x/sync/errgroup" 17 + ) 18 + 19 + // A Server serves HTTP pages. 20 + type Server struct { 21 + cfg *config 22 + routingTable atomic.Value 23 + ja4m *ja4plus.JA4Middleware 24 + 25 + ready *Event 26 + } 27 + 28 + // New creates a new Server. 29 + func New(options ...Option) *Server { 30 + cfg := defaultConfig() 31 + for _, o := range options { 32 + o(cfg) 33 + } 34 + s := &Server{ 35 + cfg: cfg, 36 + ready: NewEvent(), 37 + ja4m: &ja4plus.JA4Middleware{}, 38 + } 39 + s.routingTable.Store(NewRoutingTable(nil)) 40 + return s 41 + } 42 + 43 + // Run runs the server. 44 + func (s *Server) Run(ctx context.Context) error { 45 + // don't start listening until the first payload 46 + s.ready.Wait(ctx) 47 + 48 + var eg errgroup.Group 49 + eg.Go(func() error { 50 + srv := http.Server{ 51 + Addr: s.cfg.httpsBind, 52 + Handler: s.ja4m.Wrap(s), 53 + ConnState: s.ja4m.ConnStateCallback, 54 + TLSConfig: &tls.Config{ 55 + GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { 56 + return s.routingTable.Load().(*RoutingTable).GetCertificate(hello.ServerName) 57 + }, 58 + GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) { 59 + s.ja4m.StoreFingerprintFromClientHello(chi) 60 + return nil, nil 61 + }, 62 + }, 63 + } 64 + slog.Info("starting server", "protocol", "http", "addr", srv.Addr) 65 + err := srv.ListenAndServeTLS("", "") 66 + if err != nil { 67 + return fmt.Errorf("error serving tls: %w", err) 68 + } 69 + return nil 70 + }) 71 + eg.Go(func() error { 72 + srv := http.Server{ 73 + Addr: s.cfg.httpBind, 74 + Handler: s, 75 + } 76 + slog.Info("starting server", "protocol", "https", "addr", srv.Addr) 77 + err := srv.ListenAndServe() 78 + if err != nil { 79 + return fmt.Errorf("error serving non-tls: %w", err) 80 + } 81 + return nil 82 + }) 83 + return eg.Wait() 84 + } 85 + 86 + // ServeHTTP serves an HTTP request. 87 + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 88 + reqID := uuid.Must(uuid.NewV7()).String() 89 + 90 + ja4 := ja4plus.JA4FromContext(r.Context()) 91 + if ja4 != "" { 92 + r.Header.Set("X-Tls-Fingerprint-Ja4", ja4) 93 + } 94 + 95 + r.Header.Set("X-Forwarded-Host", r.Host) 96 + r.Header.Set("X-Forwarded-Proto", "https") 97 + r.Header.Set("X-Forwarded-Scheme", "https") 98 + r.Header.Set("X-Request-Id", reqID) 99 + r.Header.Set("X-Scheme", "https") 100 + r.Header.Set("X-HTTP-Protocol", r.Proto) 101 + 102 + backendURL, err := s.routingTable.Load().(*RoutingTable).GetBackend(r.Host, r.URL.Path) 103 + if err != nil { 104 + http.Error(w, "upstream server not found", http.StatusNotFound) 105 + return 106 + } 107 + slog.Info("proxying request", "host", r.Host, "path", r.URL.Path, "backend", backendURL) 108 + p := httputil.NewSingleHostReverseProxy(backendURL) 109 + if backendURL.Scheme == "https" { 110 + p.Transport = &http2.Transport{ 111 + AllowHTTP: true, 112 + } 113 + } 114 + p.ServeHTTP(w, r) 115 + } 116 + 117 + // Update updates the server with new ingress rules. 118 + func (s *Server) Update(payload *watcher.Payload) { 119 + s.routingTable.Store(NewRoutingTable(payload)) 120 + s.ready.Set() 121 + }
+157
watcher/watcher.go
··· 1 + package watcher 2 + 3 + import ( 4 + "context" 5 + "crypto/tls" 6 + "log/slog" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bep/debounce" 11 + networking "k8s.io/api/networking/v1" 12 + "k8s.io/apimachinery/pkg/labels" 13 + "k8s.io/client-go/informers" 14 + "k8s.io/client-go/kubernetes" 15 + "k8s.io/client-go/tools/cache" 16 + ) 17 + 18 + // A Payload is a collection of Kubernetes data loaded by the watcher. 19 + type Payload struct { 20 + Ingresses []IngressPayload 21 + TLSCertificates map[string]*tls.Certificate 22 + } 23 + 24 + // An IngressPayload is an ingress + its service ports. 25 + type IngressPayload struct { 26 + Ingress *networking.Ingress 27 + ServicePorts map[string]map[string]int 28 + } 29 + 30 + // A Watcher watches for ingresses in the kubernetes cluster 31 + type Watcher struct { 32 + client kubernetes.Interface 33 + onChange func(*Payload) 34 + } 35 + 36 + // New creates a new Watcher. 37 + func New(client kubernetes.Interface, onChange func(*Payload)) *Watcher { 38 + return &Watcher{ 39 + client: client, 40 + onChange: onChange, 41 + } 42 + } 43 + 44 + // Run runs the watcher. 45 + func (w *Watcher) Run(ctx context.Context) error { 46 + factory := informers.NewSharedInformerFactory(w.client, time.Minute) 47 + secretLister := factory.Core().V1().Secrets().Lister() 48 + serviceLister := factory.Core().V1().Services().Lister() 49 + ingressLister := factory.Networking().V1().Ingresses().Lister() 50 + 51 + addBackend := func(ingressPayload *IngressPayload, backend networking.IngressBackend) { 52 + slog.Debug("adding backend", "namespace", ingressPayload.Ingress.Namespace, "ingress", ingressPayload.Ingress.Name) 53 + svc, err := serviceLister.Services(ingressPayload.Ingress.Namespace).Get(backend.Service.Name) 54 + if err != nil { 55 + slog.Error("unknown service", "namespace", ingressPayload.Ingress.Namespace, "name", backend.Service.Name, "err", err) 56 + } else { 57 + m := make(map[string]int) 58 + for _, port := range svc.Spec.Ports { 59 + m[port.Name] = int(port.Port) 60 + } 61 + ingressPayload.ServicePorts[svc.Name] = m 62 + } 63 + } 64 + 65 + onChange := func() { 66 + payload := &Payload{ 67 + TLSCertificates: make(map[string]*tls.Certificate), 68 + } 69 + 70 + ingresses, err := ingressLister.List(labels.Everything()) 71 + if err != nil { 72 + slog.Error("failed to list ingresses", "err", err) 73 + return 74 + } 75 + 76 + for _, ingress := range ingresses { 77 + ingressPayload := IngressPayload{ 78 + Ingress: ingress, 79 + ServicePorts: make(map[string]map[string]int), 80 + } 81 + payload.Ingresses = append(payload.Ingresses, ingressPayload) 82 + 83 + if ingress.Spec.DefaultBackend != nil { 84 + addBackend(&ingressPayload, *ingress.Spec.DefaultBackend) 85 + } 86 + for _, rule := range ingress.Spec.Rules { 87 + if rule.HTTP != nil { 88 + continue 89 + } 90 + for _, path := range rule.HTTP.Paths { 91 + addBackend(&ingressPayload, path.Backend) 92 + } 93 + } 94 + 95 + for _, rec := range ingress.Spec.TLS { 96 + if rec.SecretName != "" { 97 + secret, err := secretLister.Secrets(ingress.Namespace).Get(rec.SecretName) 98 + if err != nil { 99 + slog.Error("unknown secret", "namespace", ingress.Namespace, "name", rec.SecretName, "err", err) 100 + continue 101 + } 102 + 103 + cert, err := tls.X509KeyPair(secret.Data["tls.crt"], secret.Data["tls.key"]) 104 + if err != nil { 105 + slog.Error("invalid TLS certificate", "namespace", ingress.Namespace, "name", rec.SecretName, "err", err) 106 + continue 107 + } 108 + 109 + payload.TLSCertificates[rec.SecretName] = &cert 110 + } 111 + } 112 + } 113 + 114 + w.onChange(payload) 115 + } 116 + 117 + debounced := debounce.New(time.Second) 118 + handler := cache.ResourceEventHandlerFuncs{ 119 + AddFunc: func(obj interface{}) { 120 + debounced(onChange) 121 + }, 122 + UpdateFunc: func(oldObj, newObj interface{}) { 123 + debounced(onChange) 124 + }, 125 + DeleteFunc: func(obj interface{}) { 126 + debounced(onChange) 127 + }, 128 + } 129 + 130 + var wg sync.WaitGroup 131 + wg.Add(1) 132 + go func() { 133 + informer := factory.Core().V1().Secrets().Informer() 134 + informer.AddEventHandler(handler) 135 + informer.Run(ctx.Done()) 136 + wg.Done() 137 + }() 138 + 139 + wg.Add(1) 140 + go func() { 141 + informer := factory.Networking().V1().Ingresses().Informer() 142 + informer.AddEventHandler(handler) 143 + informer.Run(ctx.Done()) 144 + wg.Done() 145 + }() 146 + 147 + wg.Add(1) 148 + go func() { 149 + informer := factory.Core().V1().Services().Informer() 150 + informer.AddEventHandler(handler) 151 + informer.Run(ctx.Done()) 152 + wg.Done() 153 + }() 154 + 155 + wg.Wait() 156 + return nil 157 + }