From 956d2f55c3b5a40f4d99ebce6a4792ef5c35d1be Mon Sep 17 00:00:00 2001 From: Contre Date: Wed, 29 Apr 2026 15:16:21 +0200 Subject: [PATCH] feat(access-control): Add support for Kubernetes Label (#627) * feat(access-control): Add support for Kubernetes Label * feat(access-control): Defaults to Docker * feat(access-control): Remove kubeconfig fallback * feat(watcher): Watcher for kubernetes service * feat(watcher): Merge with main + remove nightly fix redirect * fix(go): Go mod + Go sum after sync with main * fix(config): Ser default value for LabelProvider to Docker * feat(go): go mod tidy * feat(k8s_service): Remove logic for deprecated Ingress k8s v1.22 * feat(k8s_service): (Watcher) -> Wait 5s before breaking to outer loop again * feat(k8s_service): Remove logic for deprecated Ingress k8s v1.22 * feat(k8s_service): Remove logic for deprecated Ingress k8s v1.22 * feat(k8s_service): Remove logic for deprecated Ingress k8s v1.22 * feat(k8s_service): Remove var _ = unstructured.Unstructured{} + comments + msg edits * feat(bootstrap): Remove dockerService from bootstrap svc * feat(auth_svc): Remove dockerService from authservice * feat(test): Add tests for kubernetes_services * feat(test): Remove docker serivce form proxy/user test * fix(refactor): Remove update logic from watcher and resync * fix(refactor): Split watchGVR to make it more readable * fix(refactor): Remove discovery + drop K 1.22 completely * fix(refactor): Move interface to acess_controls_service * feat: Autodetect labelprovider if TINYAUTH_LABELPROVIDER not set * fix(test): Match testing scheme to the controllers * fix: service bootstrap import after merge * fix: service bootstrap import after merge --- go.mod | 14 + go.sum | 80 +++++ internal/bootstrap/service_bootstrap.go | 36 ++- internal/config/config.go | 29 +- internal/controller/proxy_controller_test.go | 2 +- internal/controller/user_controller_test.go | 2 +- internal/service/access_controls_service.go | 22 +- internal/service/auth_service.go | 6 +- internal/service/kubernetes_service.go | 303 +++++++++++++++++++ internal/service/kubernetes_service_test.go | 186 ++++++++++++ 10 files changed, 643 insertions(+), 37 deletions(-) create mode 100644 internal/service/kubernetes_service.go create mode 100644 internal/service/kubernetes_service_test.go diff --git a/go.mod b/go.mod index 13b6865..d0c5a51 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/oauth2 v0.36.0 gotest.tools/v3 v3.5.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 modernc.org/sqlite v1.49.1 ) @@ -62,6 +64,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect @@ -72,7 +75,9 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -91,6 +96,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect @@ -105,6 +111,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -122,10 +129,17 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect rsc.io/qr v0.2.0 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 2e064d7..ee982c8 100644 --- a/go.sum +++ b/go.sum @@ -97,10 +97,14 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -118,6 +122,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -130,14 +140,23 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -162,8 +181,12 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -176,6 +199,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -209,6 +234,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -242,6 +269,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -261,8 +290,12 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE= github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -289,29 +322,54 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= @@ -324,11 +382,27 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= +k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= +k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= +k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= @@ -359,3 +433,9 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index 9c5806b..91e2b50 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -1,6 +1,8 @@ package bootstrap import ( + "os" + "github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/utils/tlog" @@ -10,6 +12,7 @@ type Services struct { accessControlService *service.AccessControlsService authService *service.AuthService dockerService *service.DockerService + kubernetesService *service.KubernetesService ldapService *service.LdapService oauthBrokerService *service.OAuthBrokerService oidcService *service.OIDCService @@ -38,17 +41,34 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er services.ldapService = ldapService - dockerService := service.NewDockerService() + var labelProvider service.LabelProvider + var dockerService *service.DockerService + var kubernetesService *service.KubernetesService - err = dockerService.Init() + useKubernetes := app.config.LabelProvider == "kubernetes" || + (app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "") - if err != nil { - return Services{}, err + if useKubernetes { + tlog.App.Debug().Msg("Using Kubernetes label provider") + kubernetesService = service.NewKubernetesService() + err = kubernetesService.Init() + if err != nil { + return Services{}, err + } + services.kubernetesService = kubernetesService + labelProvider = kubernetesService + } else { + tlog.App.Debug().Msg("Using Docker label provider") + dockerService = service.NewDockerService() + err = dockerService.Init() + if err != nil { + return Services{}, err + } + services.dockerService = dockerService + labelProvider = dockerService } - services.dockerService = dockerService - - accessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps) + accessControlsService := service.NewAccessControlsService(labelProvider, app.config.Apps) err = accessControlsService.Init() @@ -80,7 +100,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er SessionCookieName: app.context.sessionCookieName, IP: app.config.Auth.IP, LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL, - }, dockerService, services.ldapService, queries, services.oauthBrokerService) + }, services.ldapService, queries, services.oauthBrokerService) err = authService.Init() diff --git a/internal/config/config.go b/internal/config/config.go index 1bf64af..e364b45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,6 +59,7 @@ func NewDefaultConfiguration() *Config { Experimental: ExperimentalConfig{ ConfigFile: "", }, + LabelProvider: "auto", } } @@ -76,21 +77,21 @@ var RedirectCookieName = "tinyauth-redirect" var OAuthSessionCookieName = "tinyauth-oauth" // Main app config - type Config struct { - AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"` - Database DatabaseConfig `description:"Database configuration." yaml:"database"` - Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"` - Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"` - Server ServerConfig `description:"Server configuration." yaml:"server"` - Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` - Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"` - OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` - OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"` - UI UIConfig `description:"UI customization." yaml:"ui"` - Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"` - Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` - Log LogConfig `description:"Logging configuration." yaml:"log"` + AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"` + Database DatabaseConfig `description:"Database configuration." yaml:"database"` + Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"` + Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"` + Server ServerConfig `description:"Server configuration." yaml:"server"` + Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` + Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"` + OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` + OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"` + UI UIConfig `description:"UI customization." yaml:"ui"` + Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"` + Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` + LabelProvider string `description:"Label provider to use for ACLs (auto, docker, or kubernetes). auto detects the environment." yaml:"labelProvider"` + Log LogConfig `description:"Logging configuration." yaml:"log"` } type DatabaseConfig struct { diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 8ea8172..8efbd31 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -412,7 +412,7 @@ func TestProxyController(t *testing.T) { err = broker.Init() require.NoError(t, err) - authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker) + authService := service.NewAuthService(authServiceCfg, ldap, queries, broker) err = authService.Init() require.NoError(t, err) diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index d7a0773..65ef15e 100644 --- a/internal/controller/user_controller_test.go +++ b/internal/controller/user_controller_test.go @@ -370,7 +370,7 @@ func TestUserController(t *testing.T) { err = broker.Init() require.NoError(t, err) - authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker) + authService := service.NewAuthService(authServiceCfg, ldap, queries, broker) err = authService.Init() require.NoError(t, err) diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 56849de..d054b5f 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -8,15 +8,19 @@ import ( "github.com/tinyauthapp/tinyauth/internal/utils/tlog" ) -type AccessControlsService struct { - docker *DockerService - static map[string]config.App +type LabelProvider interface { + GetLabels(appDomain string) (config.App, error) } -func NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService { +type AccessControlsService struct { + labelProvider LabelProvider + static map[string]config.App +} + +func NewAccessControlsService(labelProvider LabelProvider, static map[string]config.App) *AccessControlsService { return &AccessControlsService{ - docker: docker, - static: static, + labelProvider: labelProvider, + static: static, } } @@ -48,7 +52,7 @@ func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, return app, nil } - // Fallback to Docker labels - tlog.App.Debug().Msg("Falling back to Docker labels for ACLs") - return acls.docker.GetLabels(domain) + // Fallback to label provider + tlog.App.Debug().Msg("Falling back to label provider for ACLs") + return acls.labelProvider.GetLabels(domain) } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 55d290a..0311229 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -83,7 +83,6 @@ type AuthServiceConfig struct { type AuthService struct { config AuthServiceConfig - docker *DockerService loginAttempts map[string]*LoginAttempt ldapGroupsCache map[string]*LdapGroupsCache oauthPendingSessions map[string]*OAuthPendingSession @@ -98,17 +97,16 @@ type AuthService struct { lockdownCancelFunc context.CancelFunc } -func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService { +func NewAuthService(config AuthServiceConfig, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService { return &AuthService{ config: config, - docker: docker, loginAttempts: make(map[string]*LoginAttempt), ldapGroupsCache: make(map[string]*LdapGroupsCache), oauthPendingSessions: make(map[string]*OAuthPendingSession), ldap: ldap, queries: queries, oauthBroker: oauthBroker, - } +} } func (auth *AuthService) Init() error { diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go new file mode 100644 index 0000000..6e11eac --- /dev/null +++ b/internal/service/kubernetes_service.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/tinyauthapp/tinyauth/internal/config" + "github.com/tinyauthapp/tinyauth/internal/utils/decoders" + "github.com/tinyauthapp/tinyauth/internal/utils/tlog" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" +) + +type ingressKey struct { + namespace string + name string +} + +type ingressAppKey struct { + ingressKey + appName string +} + +type ingressApp struct { + domain string + appName string + app config.App +} + +type KubernetesService struct { + client dynamic.Interface + ctx context.Context + cancel context.CancelFunc + started bool + mu sync.RWMutex + ingressApps map[ingressKey][]ingressApp + domainIndex map[string]ingressAppKey + appNameIndex map[string]ingressAppKey +} + +func NewKubernetesService() *KubernetesService { + return &KubernetesService{ + ingressApps: make(map[ingressKey][]ingressApp), + domainIndex: make(map[string]ingressAppKey), + appNameIndex: make(map[string]ingressAppKey), + } +} + +func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) { + k.mu.Lock() + defer k.mu.Unlock() + + key := ingressKey{namespace, name} + // Remove existing entries for this ingress + if existing, ok := k.ingressApps[key]; ok { + for _, app := range existing { + delete(k.domainIndex, app.domain) + delete(k.appNameIndex, app.appName) + } + } + // Add new entries + k.ingressApps[key] = apps + for _, app := range apps { + appKey := ingressAppKey{key, app.appName} + k.domainIndex[app.domain] = appKey + k.appNameIndex[app.appName] = appKey + } +} + +func (k *KubernetesService) removeIngress(namespace, name string) { + k.mu.Lock() + defer k.mu.Unlock() + + key := ingressKey{namespace, name} + if apps, ok := k.ingressApps[key]; ok { + for _, app := range apps { + delete(k.domainIndex, app.domain) + delete(k.appNameIndex, app.appName) + } + delete(k.ingressApps, key) + } +} + +func (k *KubernetesService) getByDomain(domain string) (config.App, bool) { + k.mu.RLock() + defer k.mu.RUnlock() + + if appKey, ok := k.domainIndex[domain]; ok { + if apps, ok := k.ingressApps[appKey.ingressKey]; ok { + for _, app := range apps { + if app.domain == domain && app.appName == appKey.appName { + return app.app, true + } + } + } + } + return config.App{}, false +} + +func (k *KubernetesService) getByAppName(appName string) (config.App, bool) { + k.mu.RLock() + defer k.mu.RUnlock() + + if appKey, ok := k.appNameIndex[appName]; ok { + if apps, ok := k.ingressApps[appKey.ingressKey]; ok { + for _, app := range apps { + if app.appName == appName { + return app.app, true + } + } + } + } + return config.App{}, false +} + +func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { + namespace := item.GetNamespace() + name := item.GetName() + annotations := item.GetAnnotations() + if annotations == nil { + k.removeIngress(namespace, name) + return + } + labels, err := decoders.DecodeLabels[config.Apps](annotations, "apps") + if err != nil { + tlog.App.Debug().Err(err).Msg("Failed to decode labels from annotations") + k.removeIngress(namespace, name) + return + } + var apps []ingressApp + for appName, appLabels := range labels.Apps { + if appLabels.Config.Domain == "" { + continue + } + apps = append(apps, ingressApp{ + domain: appLabels.Config.Domain, + appName: appName, + app: appLabels, + }) + } + if len(apps) == 0 { + k.removeIngress(namespace, name) + } else { + k.addIngressApps(namespace, name, apps) + } +} + +func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error { + ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second) + defer cancel() + + list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + tlog.App.Debug().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list ingresses during resync") + return err + } + for i := range list.Items { + k.updateFromItem(&list.Items[i]) + } + tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resynced ingress cache") + return nil +} + +// runWatcher drains events from an active watcher until it closes or the context is done. +// Returns true if the caller should restart the watcher, false if it should exit. +func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool { + for { + select { + case <-k.ctx.Done(): + w.Stop() + return false + case event, ok := <-w.ResultChan(): + if !ok { + tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting in 5 seconds") + w.Stop() + time.Sleep(5 * time.Second) + return true + } + item, ok := event.Object.(*unstructured.Unstructured) + if !ok { + tlog.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Failed to cast watched object") + continue + } + switch event.Type { + case watch.Added, watch.Modified: + k.updateFromItem(item) + case watch.Deleted: + k.removeIngress(item.GetNamespace(), item.GetName()) + } + case <-resyncTicker.C: + if err := k.resyncGVR(gvr); err != nil { + tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed") + } + } + } +} + +func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) { + resyncTicker := time.NewTicker(5 * time.Minute) + defer resyncTicker.Stop() + + if err := k.resyncGVR(gvr); err != nil { + tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, retrying in 30 seconds") + time.Sleep(30 * time.Second) + } + + for { + select { + case <-k.ctx.Done(): + tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Stopping watcher") + return + case <-resyncTicker.C: + if err := k.resyncGVR(gvr); err != nil { + tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed") + } + default: + ctx, cancel := context.WithCancel(k.ctx) + watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{}) + if err != nil { + tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher") + cancel() + time.Sleep(10 * time.Second) + continue + } + tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started") + if !k.runWatcher(gvr, watcher, resyncTicker) { + cancel() + return + } + cancel() + } + } +} + +func (k *KubernetesService) Init() error { + var cfg *rest.Config + var err error + + cfg, err = rest.InClusterConfig() + if err != nil { + return fmt.Errorf("failed to get in-cluster Kubernetes config: %w", err) + } + + client, err := dynamic.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + k.client = client + k.ctx, k.cancel = context.WithCancel(context.Background()) + + gvr := schema.GroupVersionResource{ + Group: "networking.k8s.io", + Version: "v1", + Resource: "ingresses", + } + + accessCtx, accessCancel := context.WithTimeout(k.ctx, 5*time.Second) + defer accessCancel() + _, err = k.client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) + if err != nil { + tlog.App.Warn().Err(err).Msg("Insufficient permissions for networking.k8s.io/v1 Ingress, Kubernetes label provider will not work") + k.started = false + return nil + } + + tlog.App.Debug().Msg("networking.k8s.io/v1 Ingress API accessible") + go k.watchGVR(gvr) + + k.started = true + tlog.App.Info().Msg("Kubernetes label provider initialized") + return nil +} + +func (k *KubernetesService) GetLabels(appDomain string) (config.App, error) { + if !k.started { + tlog.App.Debug().Msg("Kubernetes not connected, returning empty labels") + return config.App{}, nil + } + + // First check cache + if app, found := k.getByDomain(appDomain); found { + tlog.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain") + return app, nil + } + appName := strings.SplitN(appDomain, ".", 2)[0] + if app, found := k.getByAppName(appName); found { + tlog.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name") + return app, nil + } + + tlog.App.Debug().Str("domain", appDomain).Msg("Cache miss, no matching ingress found") + return config.App{}, nil +} + diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go new file mode 100644 index 0000000..1cd75b6 --- /dev/null +++ b/internal/service/kubernetes_service_test.go @@ -0,0 +1,186 @@ +package service + +import ( + "testing" + + "github.com/tinyauthapp/tinyauth/internal/config" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKubernetesService(t *testing.T) { + type testCase struct { + description string + run func(t *testing.T, svc *KubernetesService) + } + + tests := []testCase{ + { + description: "Cache by domain returns app and misses unknown domain", + run: func(t *testing.T, svc *KubernetesService) { + app := config.App{Config: config.AppConfig{Domain: "foo.example.com"}} + svc.addIngressApps("default", "my-ingress", []ingressApp{ + {domain: "foo.example.com", appName: "foo", app: app}, + }) + + got, ok := svc.getByDomain("foo.example.com") + require.True(t, ok) + assert.Equal(t, "foo.example.com", got.Config.Domain) + + _, ok = svc.getByDomain("notfound.example.com") + assert.False(t, ok) + }, + }, + { + description: "Cache by app name returns app and misses unknown name", + run: func(t *testing.T, svc *KubernetesService) { + app := config.App{Config: config.AppConfig{Domain: "bar.example.com"}} + svc.addIngressApps("default", "my-ingress", []ingressApp{ + {domain: "bar.example.com", appName: "bar", app: app}, + }) + + got, ok := svc.getByAppName("bar") + require.True(t, ok) + assert.Equal(t, "bar.example.com", got.Config.Domain) + + _, ok = svc.getByAppName("notfound") + assert.False(t, ok) + }, + }, + { + description: "RemoveIngress clears domain and app name entries", + run: func(t *testing.T, svc *KubernetesService) { + app := config.App{Config: config.AppConfig{Domain: "baz.example.com"}} + svc.addIngressApps("default", "my-ingress", []ingressApp{ + {domain: "baz.example.com", appName: "baz", app: app}, + }) + + svc.removeIngress("default", "my-ingress") + + _, ok := svc.getByDomain("baz.example.com") + assert.False(t, ok) + _, ok = svc.getByAppName("baz") + assert.False(t, ok) + }, + }, + { + description: "AddIngressApps replaces stale entries for the same ingress", + run: func(t *testing.T, svc *KubernetesService) { + old := config.App{Config: config.AppConfig{Domain: "old.example.com"}} + svc.addIngressApps("default", "my-ingress", []ingressApp{ + {domain: "old.example.com", appName: "old", app: old}, + }) + + updated := config.App{Config: config.AppConfig{Domain: "new.example.com"}} + svc.addIngressApps("default", "my-ingress", []ingressApp{ + {domain: "new.example.com", appName: "new", app: updated}, + }) + + _, ok := svc.getByDomain("old.example.com") + assert.False(t, ok) + + got, ok := svc.getByDomain("new.example.com") + require.True(t, ok) + assert.Equal(t, "new.example.com", got.Config.Domain) + }, + }, + { + description: "GetLabels returns app from cache when started", + run: func(t *testing.T, svc *KubernetesService) { + svc.started = true + + app := config.App{Config: config.AppConfig{Domain: "hit.example.com"}} + svc.addIngressApps("default", "ing", []ingressApp{ + {domain: "hit.example.com", appName: "hit", app: app}, + }) + + got, err := svc.GetLabels("hit.example.com") + require.NoError(t, err) + assert.Equal(t, "hit.example.com", got.Config.Domain) + }, + }, + { + description: "GetLabels returns empty app on cache miss when started", + run: func(t *testing.T, svc *KubernetesService) { + svc.started = true + + got, err := svc.GetLabels("notfound.example.com") + require.NoError(t, err) + assert.Equal(t, config.App{}, got) + }, + }, + { + description: "GetLabels resolves app by app name", + run: func(t *testing.T, svc *KubernetesService) { + svc.started = true + + app := config.App{Config: config.AppConfig{Domain: "myapp.internal.example.com"}} + svc.addIngressApps("default", "ing", []ingressApp{ + {domain: "myapp.internal.example.com", appName: "myapp", app: app}, + }) + + got, err := svc.GetLabels("myapp.internal.example.com") + require.NoError(t, err) + assert.Equal(t, "myapp.internal.example.com", got.Config.Domain) + }, + }, + { + description: "GetLabels returns empty app when service not yet started", + run: func(t *testing.T, svc *KubernetesService) { + got, err := svc.GetLabels("anything.example.com") + require.NoError(t, err) + assert.Equal(t, config.App{}, got) + }, + }, + { + description: "UpdateFromItem parses annotations and populates cache", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-ingress") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.myapp.config.domain": "myapp.example.com", + "tinyauth.apps.myapp.users.allow": "alice", + }) + + svc.updateFromItem(&item) + + got, ok := svc.getByDomain("myapp.example.com") + require.True(t, ok) + assert.Equal(t, "myapp.example.com", got.Config.Domain) + assert.Equal(t, "alice", got.Users.Allow) + }, + }, + { + description: "UpdateFromItem with no annotations removes existing cache entries", + run: func(t *testing.T, svc *KubernetesService) { + app := config.App{Config: config.AppConfig{Domain: "todelete.example.com"}} + svc.addIngressApps("default", "test-ingress", []ingressApp{ + {domain: "todelete.example.com", appName: "todelete", app: app}, + }) + + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-ingress") + + svc.updateFromItem(&item) + + _, ok := svc.getByDomain("todelete.example.com") + assert.False(t, ok) + }, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + svc := &KubernetesService{ + ingressApps: make(map[ingressKey][]ingressApp), + domainIndex: make(map[string]ingressAppKey), + appNameIndex: make(map[string]ingressAppKey), + } + test.run(t, svc) + }) + } +}