diff --git a/.github/workflows/check-coverage.yml b/.github/workflows/check-coverage.yml index bfeec1fef968..8e607a8f1a26 100644 --- a/.github/workflows/check-coverage.yml +++ b/.github/workflows/check-coverage.yml @@ -19,7 +19,7 @@ on: - "CHANGELOG/**" env: # Common versions - GO_VERSION: "1.23" + GO_VERSION: "1.25" PROJECT_PATH: "./lifecycle" jobs: diff --git a/.github/workflows/check-license.yml b/.github/workflows/check-license.yml index 060f0006d0af..9da0e966ed25 100755 --- a/.github/workflows/check-license.yml +++ b/.github/workflows/check-license.yml @@ -19,7 +19,7 @@ on: - "CHANGELOG/**" env: # Common versions - GO_VERSION: "1.23" + GO_VERSION: "1.25" PROJECT_PATH: "./lifecycle" jobs: diff --git a/.github/workflows/ci-patch-image.yml b/.github/workflows/ci-patch-image.yml index 0155bedca9ff..9baac174507a 100755 --- a/.github/workflows/ci-patch-image.yml +++ b/.github/workflows/ci-patch-image.yml @@ -2,7 +2,7 @@ name: CI Patch Images Package env: # Common versions - GO_VERSION: "1.23" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" PROJECT_PATH: "./lifecycle" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 162fa64f285c..44006b4382e9 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI env: # Common versions - GO_VERSION: "1.23" + GO_VERSION: "1.25" on: workflow_call: diff --git a/.github/workflows/cloud-release.yml b/.github/workflows/cloud-release.yml index 1368fab84c1e..16d77c3577e7 100644 --- a/.github/workflows/cloud-release.yml +++ b/.github/workflows/cloud-release.yml @@ -28,7 +28,7 @@ on: env: # Common versions - GO_VERSION: "1.20" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" permissions: diff --git a/.github/workflows/cloud.yml b/.github/workflows/cloud.yml index b87498955c69..148868eac85d 100644 --- a/.github/workflows/cloud.yml +++ b/.github/workflows/cloud.yml @@ -56,7 +56,7 @@ permissions: env: # Common versions - GO_VERSION: "1.20" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} ALIYUN_REPO_PREFIX: ${{ secrets.ALIYUN_REPO_PREFIX && secrets.ALIYUN_REPO_PREFIX || secrets.ALIYUN_USERNAME && format('{0}/{1}', secrets.ALIYUN_REGISTRY, secrets.ALIYUN_USERNAME) || '' }} diff --git a/.github/workflows/controllers.yml b/.github/workflows/controllers.yml index 42ffd09d1397..22f6fb2b1e18 100644 --- a/.github/workflows/controllers.yml +++ b/.github/workflows/controllers.yml @@ -52,7 +52,7 @@ on: env: # Common versions - GO_VERSION: "1.24" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" LICENSE_KEY: ${{ secrets.LICENSE_KEY }} ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index b89d07ab86bd..d40a598ecf3b 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -20,7 +20,7 @@ on: env: # Common versions - GO_VERSION: "1.20" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} ALIYUN_REPO_PREFIX: ${{ secrets.ALIYUN_REPO_PREFIX && secrets.ALIYUN_REPO_PREFIX || secrets.ALIYUN_USERNAME && format('{0}/{1}', secrets.ALIYUN_REGISTRY, secrets.ALIYUN_USERNAME) || '' }} diff --git a/.github/workflows/import-patch-image.yml b/.github/workflows/import-patch-image.yml index a3520ef24d0c..5739061a04b2 100755 --- a/.github/workflows/import-patch-image.yml +++ b/.github/workflows/import-patch-image.yml @@ -2,7 +2,7 @@ name: Import Patch Images Package env: # Common versions - GO_VERSION: "1.23" + GO_VERSION: "1.25" PROJECT_PATH: "./lifecycle" on: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03ae2cc67710..6f492d30c711 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release env: # Common versions - GO_VERSION: "1.23" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" on: diff --git a/.github/workflows/services.yml b/.github/workflows/services.yml index 15147b295ad0..5acc0f2328ac 100644 --- a/.github/workflows/services.yml +++ b/.github/workflows/services.yml @@ -51,7 +51,7 @@ on: - "!**/*.yaml" env: # Common versions - GO_VERSION: "1.22" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} ALIYUN_REPO_PREFIX: ${{ secrets.ALIYUN_REPO_PREFIX && secrets.ALIYUN_REPO_PREFIX || secrets.ALIYUN_USERNAME && format('{0}/{1}', secrets.ALIYUN_REGISTRY, secrets.ALIYUN_USERNAME) || '' }} @@ -112,6 +112,7 @@ jobs: devbox, vlogs, hubble, + sshgate, ] steps: - name: Checkout @@ -257,6 +258,7 @@ jobs: devbox, vlogs, hubble, + sshgate, ] steps: - name: Checkout diff --git a/.github/workflows/webhooks.yml b/.github/workflows/webhooks.yml index 31b6b2a022d2..8818565298b4 100644 --- a/.github/workflows/webhooks.yml +++ b/.github/workflows/webhooks.yml @@ -52,7 +52,7 @@ on: - "!**/*.yaml" env: # Common versions - GO_VERSION: "1.22" + GO_VERSION: "1.25" DEFAULT_OWNER: "labring" ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} ALIYUN_REPO_PREFIX: ${{ secrets.ALIYUN_REPO_PREFIX && secrets.ALIYUN_REPO_PREFIX || secrets.ALIYUN_USERNAME && format('{0}/{1}', secrets.ALIYUN_REGISTRY, secrets.ALIYUN_USERNAME) || '' }} diff --git a/.golangci.yml b/.golangci.yml index 1255a98cddae..0e77a87fb3ea 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ version: "2" run: - go: "1.23" + go: "1.24" relative-path-mode: gomod modules-download-mode: readonly diff --git a/service/go.work b/service/go.work index 4cf2adc4db7c..892931baefc2 100644 --- a/service/go.work +++ b/service/go.work @@ -1,4 +1,4 @@ -go 1.24.0 +go 1.25.0 use ( . @@ -12,6 +12,7 @@ use ( ./pay ./vlogs ./zombiedetector + ./sshgate ) replace ( diff --git a/service/go.work.sum b/service/go.work.sum index 5f6ebceadd6f..7cd41262372c 100644 --- a/service/go.work.sum +++ b/service/go.work.sum @@ -983,7 +983,6 @@ github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/checkpoint-restore/go-criu/v5 v5.3.0 h1:wpFFOoomK3389ue2lAb0Boag6XPht5QYpipxmSNL4d8= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= @@ -999,7 +998,6 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/cilium/cilium v1.17.6 h1:5BgAhTKJH2rzsipQL1/GzEVRmKEujMvcFD+zcMZqLHU= github.com/cilium/cilium v1.17.6/go.mod h1:kmOkYfjmMUDQYBK3TsiZHoeLG097l5j3GflRftr1e3g= github.com/cilium/coverbee v0.3.3-0.20240723084546-664438750fce h1:gqzXY3NuHllVVDw9vD49mlXx+9bYFPlg23rdrkQNFDM= github.com/cilium/coverbee v0.3.3-0.20240723084546-664438750fce/go.mod h1:6RGqSqaXtkBGjm7na2bKFi52BeeGUuiT3178zeje4Ik= @@ -1097,7 +1095,6 @@ github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53E github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= @@ -1193,13 +1190,9 @@ github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzP github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/glendc/gopher-json v0.0.0-20170414221815-dc4743023d0c h1:iRTj5SRYwbvsygdwVp+y9kZT145Y1s6xOPpeOEIeGc4= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -1215,7 +1208,6 @@ github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNV github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -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-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= @@ -1225,6 +1217,7 @@ github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC0 github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= @@ -1236,11 +1229,11 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-redis/redis v6.14.2+incompatible h1:UE9pLhzmWf+xHNmZsoccjXosPicuiNaInPgym8nzfg0= -github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= @@ -1355,7 +1348,6 @@ github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cU github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= -github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d h1:7XGaL1e6bYS1yIonGp9761ExpPPV1ui0SAC59Yube9k= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 h1:zC34cGQu69FG7qzJ3WiKW244WfhDC3xxYMeNOX2gtUQ= @@ -1609,8 +1601,6 @@ github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -1632,11 +1622,9 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c h1:bY6ktFuJkt+ZXkX0RChQch2FtHpWQLVS8Qo1YasiIVk= github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= @@ -1652,7 +1640,6 @@ github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRah github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= -github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= @@ -1701,7 +1688,6 @@ github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= @@ -1824,7 +1810,6 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace h1:9PNP1jnUjRhfmGMlkXHjYPishpcw4jpSt/V/xYY3FMA= github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= @@ -1843,7 +1828,6 @@ github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8w github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= @@ -1982,7 +1966,6 @@ go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGc go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00= go.opentelemetry.io/contrib/detectors/gcp v1.32.0 h1:P78qWqkLSShicHmAzfECaTgvslqHxblNE9j62Ws1NK8= @@ -2012,7 +1995,6 @@ go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 h1:TaB+1rQhddO1sF71MpZOZAuSPW1klK2M8XxfrBMfK7Y= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0/go.mod h1:78XhIg8Ht9vR4tbLNUhXsiOnE2HOuSeKAiAcoVQEpOY= @@ -2039,7 +2021,6 @@ go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6b go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/sdk v1.10.0 h1:jZ6K7sVn04kk/3DNUdJ4mqRlGDiXAVuIG+MMENpTNdY= go.opentelemetry.io/otel/sdk v1.10.0/go.mod h1:vO06iKzD5baltJz1zarxMCNHFpUlUiOy4s65ECtn6kE= @@ -2051,9 +2032,7 @@ go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8 go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= @@ -2064,7 +2043,6 @@ go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+ go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v0.7.0 h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8= go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= @@ -2093,11 +2071,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= @@ -2118,8 +2093,8 @@ golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ss golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4 h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= @@ -2148,6 +2123,7 @@ golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -2178,8 +2154,8 @@ golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= @@ -2203,7 +2179,6 @@ golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -2216,6 +2191,9 @@ golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2248,13 +2226,14 @@ golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808 h1:+Kc94D8UVEVxJnLXp/+FMfqQARZtWHfVrcRtcG8aT3g= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b h1:DU+gwOBXU+6bO0sEyO7o/NeMlxZxCZEvI7v+J4a1zRQ= golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= @@ -2265,7 +2244,6 @@ golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -2278,7 +2256,6 @@ golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= @@ -2304,6 +2281,7 @@ golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= @@ -2449,7 +2427,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= @@ -2470,7 +2447,6 @@ google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjr google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= @@ -2488,7 +2464,6 @@ google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojt google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= @@ -2498,7 +2473,6 @@ gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= @@ -2572,7 +2546,6 @@ k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkv k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/gengo/v2 v2.0.0-20250207200755-1244d31929d7 h1:2OX19X59HxDprNCVrWi6jb7LW1PoqTlYqEq5H2oetog= k8s.io/gengo/v2 v2.0.0-20250207200755-1244d31929d7/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= -k8s.io/klog v0.3.1 h1:RVgyDHY/kFKtLqh67NvEWIgkMneNoIrdkN0CxDSQc68= k8s.io/klog v0.3.1/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= @@ -2594,7 +2567,6 @@ k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/ k8s.io/utils v0.0.0-20230209194617-a36077c30491/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= @@ -2623,7 +2595,6 @@ sigs.k8s.io/controller-tools v0.16.5 h1:5k9FNRqziBPwqr17AMEPPV/En39ZBplLAdOwwQHr sigs.k8s.io/controller-tools v0.16.5/go.mod h1:8vztuRVzs8IuuJqKqbXCSlXcw+lkAv/M2sTpg55qjMY= sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= @@ -2632,5 +2603,3 @@ sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW sigs.k8s.io/mcs-api v0.1.1-0.20250116162235-62ede9a032dc h1:oQrn1nrTacXiaXEYg+0TozPznSDIHFl2U/KZ5UFiYT8= sigs.k8s.io/mcs-api v0.1.1-0.20250116162235-62ede9a032dc/go.mod h1:Uicqc5FnWP4dco2y7+AEg2mzNN20mVX1TDB3aDfmvhc= sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/service/sshgate/.env.example b/service/sshgate/.env.example new file mode 100644 index 000000000000..cd2eebb15f14 --- /dev/null +++ b/service/sshgate/.env.example @@ -0,0 +1,78 @@ +# SSH Gateway Configuration Example +# Copy this file to .env and customize as needed + +# ============================================ +# Server Configuration +# ============================================ +# SSH listen address (default: :2222) +SSH_LISTEN_ADDR=:2222 + +# Backend devbox SSH port (default: 22) +SSH_BACKEND_PORT=22 + +# ============================================ +# Proxy Mode Configuration +# ============================================ +# Enable agent forwarding mode (session channel) (default: true) +ENABLE_AGENT_FORWARD=true + +# Enable proxy jump mode (direct-tcpip) (default: false) +ENABLE_PROXY_JUMP=false + +# ============================================ +# Logging Configuration +# ============================================ +# Enable debug mode (default: false) +DEBUG=false + +# Log level: debug, info, warn, error (default: info) +LOG_LEVEL=info + +# Log format: text, json (default: text) +LOG_FORMAT=text + +# ============================================ +# Timeout Configuration (Optional) +# ============================================ +# SSH handshake timeout (default: 15s) +# SSH_HANDSHAKE_TIMEOUT=15s + +# Backend connection timeout for PublicKey mode (default: 10s) +# BACKEND_CONNECT_TIMEOUT_PUBLICKEY=10s + +# Backend connection timeout for Agent Forward mode (default: 5s) +# BACKEND_CONNECT_TIMEOUT_AGENT=5s + +# ProxyJump connection timeout (default: 5s) +# PROXY_JUMP_TIMEOUT=5s + +# Session request processing timeout (default: 3s) +# SESSION_REQUEST_TIMEOUT=3s + +# ============================================ +# Security Configuration +# ============================================ +# SSH host key seed for deterministic key generation (default: sealos-devbox) +SSH_HOST_KEY_SEED=sealos-devbox + +# ============================================ +# Informer Configuration (Optional) +# ============================================ +# Informer resync period (default: 30s) +# INFORMER_RESYNC_PERIOD=30s + +# ============================================ +# Limits Configuration (Optional) +# ============================================ +# Maximum cached requests (default: 6) +# MAX_CACHED_REQUESTS=6 + +# ============================================ +# Performance Profiling (Optional) +# ============================================ +# Enable pprof server (default: true) +PPROF_ENABLED=true + +# Pprof port (0 for random port, default: 0) +# Note: Pprof always listens on 127.0.0.1 for security +PPROF_PORT=6060 diff --git a/service/sshgate/.gitignore b/service/sshgate/.gitignore new file mode 100644 index 000000000000..0aaf10815e15 --- /dev/null +++ b/service/sshgate/.gitignore @@ -0,0 +1,61 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.out +coverage.html + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# Build artifacts +/sshgate +/sshgate.* +*.key +*.pub +bin/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Environment files +.env +.env.local +.env.*.local +!.env.example + +# Test files +*_test_* +test_*.sh + +# Kubernetes config (if any) +kubeconfig +*.kubeconfig + +# Temporary files +tmp/ +temp/ +*.tmp + +# Project specific +ssh_host_* +PROJECT_SUMMARY.md +.claude +*.tgz + diff --git a/service/sshgate/Dockerfile b/service/sshgate/Dockerfile new file mode 100644 index 000000000000..bf7a002211cf --- /dev/null +++ b/service/sshgate/Dockerfile @@ -0,0 +1,7 @@ +FROM gcr.io/distroless/static:nonroot +ARG TARGETARCH +COPY bin/service-sshgate-$TARGETARCH /sshgate +EXPOSE 2222 +USER 65532:65532 + +ENTRYPOINT [ "/sshgate" ] \ No newline at end of file diff --git a/service/sshgate/Makefile b/service/sshgate/Makefile new file mode 100644 index 000000000000..60ce3ac6da36 --- /dev/null +++ b/service/sshgate/Makefile @@ -0,0 +1,56 @@ +IMG ?= ghcr.io/labring/sealos-sshgate-service:latest + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# only support linux, non cgo +PLATFORMS ?= linux_arm64 linux_amd64 +GOOS=linux +CGO_ENABLED=0 +GOARCH=$(shell go env GOARCH) +TARGETARCH ?= $(GOARCH) + +GO_BUILD_FLAGS=-trimpath -ldflags "-s -w" + +.PHONY: all +all: build + +##@ General + +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Build + +.PHONY: clean +clean: + rm -f $(SERVICE_NAME) + +.PHONY: build +build: clean ## Build service-hub binary. + CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) go build $(GO_BUILD_FLAGS) -o bin/manager main.go + +.PHONY: docker-build +docker-build: build + mv bin/manager bin/service-sshgate-${TARGETARCH} + docker build -t $(IMG) . + +.PHONY: docker-push +docker-push: + docker push $(IMG) diff --git a/service/sshgate/README.md b/service/sshgate/README.md new file mode 100644 index 000000000000..dea8fca4c85d --- /dev/null +++ b/service/sshgate/README.md @@ -0,0 +1,62 @@ +# SSH Gateway for Sealos Devbox + +A Kubernetes-native SSH gateway that routes SSH connections to Devbox pods based on client public keys. + +## Features + +- **Public Key Routing**: Automatically matches and routes connections based on SSH public keys +- **Real-time Sync**: Uses client-go Informers to watch Kubernetes Secrets and Pods +- **Multi-replica Consistency**: All replicas use identical host keys via deterministic key generation +- **Flexible Username**: Accepts any SSH username +- **Multiple Proxy Modes**: Supports Agent forwarding and ProxyJump (direct-tcpip) + +## Architecture + +``` +User (ssh @gateway -i ~/.ssh/key) + ↓ +SSH Gateway (public key matching) + ↓ +Registry (Informer cache) + ↓ +Backend Devbox Pod (via Pod IP) +``` + +## How It Works + +1. User connects via `ssh @gateway` (username can be anything) +2. Gateway looks up the corresponding Devbox using the user's public key +3. Connects to the backend pod using the Devbox's private key +4. Proxies all SSH traffic bidirectionally + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SSH_LISTEN_ADDR` | `:2222` | Listen address | +| `SSH_HOST_KEY_SEED` | `sealos-devbox` | Seed for deterministic key generation | +| `SSH_BACKEND_PORT` | `22` | Backend SSH port | +| `ENABLE_AGENT_FORWARD` | `true` | Enable Agent forwarding mode | +| `ENABLE_PROXY_JUMP` | `false` | Enable ProxyJump mode | +| `LOG_LEVEL` | `info` | Log level (debug/info/warn/error) | +| `LOG_FORMAT` | `text` | Log format (text/json) | + +### Kubernetes Resources + +The gateway watches the following resources: + +**Secret**: + +- Label: `app.kubernetes.io/part-of: devbox` +- Data fields: + - `SEALOS_DEVBOX_PUBLIC_KEY`: User's public key (base64) + - `SEALOS_DEVBOX_PRIVATE_KEY`: Devbox's private key (base64) +- OwnerReference: Points to Devbox CR + +**Pod**: + +- Label: `app.kubernetes.io/part-of: devbox` +- OwnerReference: Points to Devbox CR +- Must have PodIP assigned diff --git a/service/sshgate/config/config.go b/service/sshgate/config/config.go new file mode 100644 index 000000000000..45938ceb2cf9 --- /dev/null +++ b/service/sshgate/config/config.go @@ -0,0 +1,111 @@ +// Package config provides configuration management for the SSH gateway +package config + +import ( + "errors" + "fmt" + "time" + + "github.com/caarlos0/env/v9" + "github.com/joho/godotenv" + "github.com/labring/sealos/service/sshgate/gateway" +) + +// Config holds all configuration for the SSH gateway +type Config struct { + // Server configuration + SSHListenAddr string `env:"SSH_LISTEN_ADDR" envDefault:":2222"` + + // Logging configuration + Debug bool `env:"DEBUG" envDefault:"false"` + LogLevel string `env:"LOG_LEVEL" envDefault:"info"` + LogFormat string `env:"LOG_FORMAT" envDefault:"text"` + + // Informer configuration + InformerResyncPeriod time.Duration `env:"INFORMER_RESYNC_PERIOD" envDefault:"30s"` + + // Security configuration + SSHHostKeySeed string `env:"SSH_HOST_KEY_SEED" envDefault:"sealos-devbox"` + + // Pprof configuration + PprofEnabled bool `env:"PPROF_ENABLED" envDefault:"true"` + PprofPort int `env:"PPROF_PORT" envDefault:"0"` + + // Gateway configuration + Gateway gateway.Options `envPrefix:""` +} + +// Load loads configuration from environment variables +// It will attempt to load .env file if it exists +func Load() (*Config, error) { + // Try to load .env file, but don't fail if it doesn't exist + _ = godotenv.Load() + + cfg := &Config{} + if err := env.Parse(cfg); err != nil { + return nil, fmt.Errorf("failed to parse environment variables: %w", err) + } + + // Validate configuration + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return cfg, nil +} + +// validate validates the configuration +func (c *Config) validate() error { + // Validate log level + validLogLevels := map[string]bool{ + "debug": true, + "info": true, + "warn": true, + "error": true, + } + if !validLogLevels[c.LogLevel] { + return fmt.Errorf("invalid log level: %s (must be debug, info, warn, or error)", c.LogLevel) + } + + // Validate log format + validLogFormats := map[string]bool{ + "text": true, + "json": true, + } + if !validLogFormats[c.LogFormat] { + return fmt.Errorf("invalid log format: %s (must be text or json)", c.LogFormat) + } + + // Validate port numbers + if c.Gateway.SSHBackendPort < 1 || c.Gateway.SSHBackendPort > 65535 { + return fmt.Errorf("invalid SSH backend port: %d", c.Gateway.SSHBackendPort) + } + + if c.PprofPort < 0 || c.PprofPort > 65535 { + return fmt.Errorf("invalid pprof port: %d", c.PprofPort) + } + + // Validate that at least one proxy mode is enabled + if !c.Gateway.EnableAgentForward && !c.Gateway.EnableProxyJump { + return errors.New( + "at least one proxy mode must be enabled (ENABLE_AGENT_FORWARD or ENABLE_PROXY_JUMP)", + ) + } + + return nil +} + +// NewDefaultConfig creates a config for testing with sensible defaults +func NewDefaultConfig() *Config { + return &Config{ + SSHListenAddr: ":2222", + Debug: false, + LogLevel: "info", + LogFormat: "text", + InformerResyncPeriod: 30 * time.Second, + SSHHostKeySeed: "sealos-devbox", + PprofEnabled: true, + PprofPort: 0, + Gateway: gateway.DefaultOptions(), + } +} diff --git a/service/sshgate/config/config_test.go b/service/sshgate/config/config_test.go new file mode 100644 index 000000000000..bb408628a32e --- /dev/null +++ b/service/sshgate/config/config_test.go @@ -0,0 +1,410 @@ +package config_test + +import ( + "testing" + "time" + + "github.com/labring/sealos/service/sshgate/config" + "github.com/labring/sealos/service/sshgate/gateway" +) + +func TestLoad(t *testing.T) { + t.Run("LoadWithDefaults", func(t *testing.T) { + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + // Verify default values + if cfg.SSHListenAddr != ":2222" { + t.Errorf("SSHListenAddr = %s, want :2222", cfg.SSHListenAddr) + } + + if cfg.Debug != false { + t.Errorf("Debug = %v, want false", cfg.Debug) + } + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %s, want info", cfg.LogLevel) + } + + if cfg.LogFormat != "text" { + t.Errorf("LogFormat = %s, want text", cfg.LogFormat) + } + + if cfg.Gateway.SSHBackendPort != 22 { + t.Errorf("Gateway.SSHBackendPort = %d, want 22", cfg.Gateway.SSHBackendPort) + } + + if cfg.Gateway.EnableAgentForward != true { + t.Errorf("Gateway.EnableAgentForward = %v, want true", cfg.Gateway.EnableAgentForward) + } + + if cfg.Gateway.EnableProxyJump != false { + t.Errorf("Gateway.EnableProxyJump = %v, want true", cfg.Gateway.EnableProxyJump) + } + }) + + t.Run("LoadWithCustomValues", func(t *testing.T) { + // Set custom env vars + t.Setenv("SSH_LISTEN_ADDR", ":3333") + t.Setenv("DEBUG", "true") + t.Setenv("LOG_LEVEL", "debug") + t.Setenv("LOG_FORMAT", "json") + t.Setenv("SSH_BACKEND_PORT", "2222") + t.Setenv("ENABLE_AGENT_FORWARD", "false") + t.Setenv("ENABLE_PROXY_JUMP", "true") + t.Setenv("SSH_HOST_KEY_SEED", "custom-seed") + t.Setenv("PPROF_PORT", "6060") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.SSHListenAddr != ":3333" { + t.Errorf("SSHListenAddr = %s, want :3333", cfg.SSHListenAddr) + } + + if cfg.Debug != true { + t.Errorf("Debug = %v, want true", cfg.Debug) + } + + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %s, want debug", cfg.LogLevel) + } + + if cfg.LogFormat != "json" { + t.Errorf("LogFormat = %s, want json", cfg.LogFormat) + } + + if cfg.Gateway.SSHBackendPort != 2222 { + t.Errorf("Gateway.SSHBackendPort = %d, want 2222", cfg.Gateway.SSHBackendPort) + } + + if cfg.Gateway.EnableAgentForward != false { + t.Errorf("Gateway.EnableAgentForward = %v, want false", cfg.Gateway.EnableAgentForward) + } + + if cfg.Gateway.EnableProxyJump != true { + t.Errorf("Gateway.EnableProxyJump = %v, want true", cfg.Gateway.EnableProxyJump) + } + + if cfg.SSHHostKeySeed != "custom-seed" { + t.Errorf("SSHHostKeySeed = %s, want custom-seed", cfg.SSHHostKeySeed) + } + + if cfg.PprofPort != 6060 { + t.Errorf("PprofPort = %d, want 6060", cfg.PprofPort) + } + }) + + t.Run("LoadWithInvalidLogLevel", func(t *testing.T) { + t.Setenv("LOG_LEVEL", "invalid") + + _, err := config.Load() + if err == nil { + t.Fatal("Expected error for invalid log level, got nil") + } + }) + + t.Run("LoadWithInvalidLogFormat", func(t *testing.T) { + t.Setenv("LOG_FORMAT", "invalid") + + _, err := config.Load() + if err == nil { + t.Fatal("Expected error for invalid log format, got nil") + } + }) + + t.Run("LoadWithInvalidSSHBackendPort", func(t *testing.T) { + t.Setenv("SSH_BACKEND_PORT", "70000") + + _, err := config.Load() + if err == nil { + t.Fatal("Expected error for invalid SSH backend port, got nil") + } + }) + + t.Run("LoadWithInvalidPprofPort", func(t *testing.T) { + t.Setenv("PPROF_PORT", "70000") + + _, err := config.Load() + if err == nil { + t.Fatal("Expected error for invalid pprof port, got nil") + } + }) + + t.Run("LoadWithBothProxyModesDisabled", func(t *testing.T) { + t.Setenv("ENABLE_AGENT_FORWARD", "false") + t.Setenv("ENABLE_PROXY_JUMP", "false") + + _, err := config.Load() + if err == nil { + t.Fatal("Expected error when both proxy modes are disabled, got nil") + } + }) + + t.Run("LoadWithTimeoutValues", func(t *testing.T) { + t.Setenv("SSH_HANDSHAKE_TIMEOUT", "30s") + t.Setenv("BACKEND_CONNECT_TIMEOUT_PUBLICKEY", "20s") + t.Setenv("BACKEND_CONNECT_TIMEOUT_AGENT", "10s") + t.Setenv("PROXY_JUMP_TIMEOUT", "8s") + t.Setenv("SESSION_REQUEST_TIMEOUT", "5s") + t.Setenv("INFORMER_RESYNC_PERIOD", "60s") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.Gateway.SSHHandshakeTimeout != 30*time.Second { + t.Errorf("SSHHandshakeTimeout = %v, want 30s", cfg.Gateway.SSHHandshakeTimeout) + } + + if cfg.Gateway.BackendConnectTimeoutPublicKey != 20*time.Second { + t.Errorf( + "BackendConnectTimeoutPublicKey = %v, want 20s", + cfg.Gateway.BackendConnectTimeoutPublicKey, + ) + } + + if cfg.Gateway.BackendConnectTimeoutAgent != 10*time.Second { + t.Errorf( + "BackendConnectTimeoutAgent = %v, want 10s", + cfg.Gateway.BackendConnectTimeoutAgent, + ) + } + + if cfg.Gateway.ProxyJumpTimeout != 8*time.Second { + t.Errorf("ProxyJumpTimeout = %v, want 8s", cfg.Gateway.ProxyJumpTimeout) + } + + if cfg.Gateway.SessionRequestTimeout != 5*time.Second { + t.Errorf("SessionRequestTimeout = %v, want 5s", cfg.Gateway.SessionRequestTimeout) + } + + if cfg.InformerResyncPeriod != 60*time.Second { + t.Errorf("InformerResyncPeriod = %v, want 60s", cfg.InformerResyncPeriod) + } + }) + + t.Run("LoadWithSecurityOptions", func(t *testing.T) { + t.Setenv("MAX_CACHED_REQUESTS", "10") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.Gateway.MaxCachedRequests != 10 { + t.Errorf("MaxCachedRequests = %d, want 10", cfg.Gateway.MaxCachedRequests) + } + }) +} + +func TestNewDefaultConfig(t *testing.T) { + cfg := config.NewDefaultConfig() + + if cfg == nil { + t.Fatal("NewDefaultConfig() returned nil") + } + + // Test basic fields + if cfg.SSHListenAddr != ":2222" { + t.Errorf("SSHListenAddr = %s, want :2222", cfg.SSHListenAddr) + } + + if cfg.Debug != false { + t.Errorf("Debug = %v, want false", cfg.Debug) + } + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %s, want info", cfg.LogLevel) + } + + if cfg.LogFormat != "text" { + t.Errorf("LogFormat = %s, want text", cfg.LogFormat) + } + + if cfg.InformerResyncPeriod != 30*time.Second { + t.Errorf("InformerResyncPeriod = %v, want 30s", cfg.InformerResyncPeriod) + } + + if cfg.SSHHostKeySeed != "sealos-devbox" { + t.Errorf("SSHHostKeySeed = %s, want sealos-devbox", cfg.SSHHostKeySeed) + } + + if cfg.PprofEnabled != true { + t.Errorf("PprofEnabled = %v, want true", cfg.PprofEnabled) + } + + if cfg.PprofPort != 0 { + t.Errorf("PprofPort = %d, want 0", cfg.PprofPort) + } + + // Test gateway options - should match DefaultOptions() + defaultGateway := gateway.DefaultOptions() + + if cfg.Gateway.SSHHandshakeTimeout != defaultGateway.SSHHandshakeTimeout { + t.Errorf( + "Gateway.SSHHandshakeTimeout = %v, want %v", + cfg.Gateway.SSHHandshakeTimeout, + defaultGateway.SSHHandshakeTimeout, + ) + } + + if cfg.Gateway.SSHBackendPort != defaultGateway.SSHBackendPort { + t.Errorf( + "Gateway.SSHBackendPort = %d, want %d", + cfg.Gateway.SSHBackendPort, + defaultGateway.SSHBackendPort, + ) + } + + if cfg.Gateway.EnableAgentForward != defaultGateway.EnableAgentForward { + t.Errorf( + "Gateway.EnableAgentForward = %v, want %v", + cfg.Gateway.EnableAgentForward, + defaultGateway.EnableAgentForward, + ) + } + + if cfg.Gateway.EnableProxyJump != defaultGateway.EnableProxyJump { + t.Errorf( + "Gateway.EnableProxyJump = %v, want %v", + cfg.Gateway.EnableProxyJump, + defaultGateway.EnableProxyJump, + ) + } +} + +func TestValidLogLevels(t *testing.T) { + validLevels := []string{"debug", "info", "warn", "error"} + + for _, level := range validLevels { + t.Run(level, func(t *testing.T) { + t.Setenv("LOG_LEVEL", level) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() failed for valid log level %s: %v", level, err) + } + + if cfg.LogLevel != level { + t.Errorf("LogLevel = %s, want %s", cfg.LogLevel, level) + } + }) + } +} + +func TestValidLogFormats(t *testing.T) { + validFormats := []string{"text", "json"} + + for _, format := range validFormats { + t.Run(format, func(t *testing.T) { + t.Setenv("LOG_FORMAT", format) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() failed for valid log format %s: %v", format, err) + } + + if cfg.LogFormat != format { + t.Errorf("LogFormat = %s, want %s", cfg.LogFormat, format) + } + }) + } +} + +func TestProxyModeValidation(t *testing.T) { + tests := []struct { + name string + enableAgentForwd string + enableProxyJump string + shouldFail bool + description string + }{ + { + name: "BothEnabled", + enableAgentForwd: "true", + enableProxyJump: "true", + shouldFail: false, + description: "Both modes enabled should succeed", + }, + { + name: "OnlyAgentForward", + enableAgentForwd: "true", + enableProxyJump: "false", + shouldFail: false, + description: "Only agent forward enabled should succeed", + }, + { + name: "OnlyProxyJump", + enableAgentForwd: "false", + enableProxyJump: "true", + shouldFail: false, + description: "Only proxy jump enabled should succeed", + }, + { + name: "BothDisabled", + enableAgentForwd: "false", + enableProxyJump: "false", + shouldFail: true, + description: "Both modes disabled should fail", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ENABLE_AGENT_FORWARD", tt.enableAgentForwd) + t.Setenv("ENABLE_PROXY_JUMP", tt.enableProxyJump) + + _, err := config.Load() + + if tt.shouldFail && err == nil { + t.Errorf("%s: expected error but got none", tt.description) + } + + if !tt.shouldFail && err != nil { + t.Errorf("%s: unexpected error: %v", tt.description, err) + } + }) + } +} + +func TestPortValidation(t *testing.T) { + tests := []struct { + name string + envVar string + value string + shouldFail bool + }{ + {"ValidSSHBackendPort", "SSH_BACKEND_PORT", "22", false}, + {"ValidSSHBackendPortHigh", "SSH_BACKEND_PORT", "65535", false}, + {"InvalidSSHBackendPortZero", "SSH_BACKEND_PORT", "0", true}, + {"InvalidSSHBackendPortNegative", "SSH_BACKEND_PORT", "-1", true}, + {"InvalidSSHBackendPortTooHigh", "SSH_BACKEND_PORT", "65536", true}, + {"ValidPprofPortZero", "PPROF_PORT", "0", false}, + {"ValidPprofPort", "PPROF_PORT", "6060", false}, + {"ValidPprofPortHigh", "PPROF_PORT", "65535", false}, + {"InvalidPprofPortNegative", "PPROF_PORT", "-1", true}, + {"InvalidPprofPortTooHigh", "PPROF_PORT", "65536", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(tt.envVar, tt.value) + + _, err := config.Load() + + if tt.shouldFail && err == nil { + t.Errorf("Expected error for %s=%s, got none", tt.envVar, tt.value) + } + + if !tt.shouldFail && err != nil { + t.Errorf("Unexpected error for %s=%s: %v", tt.envVar, tt.value, err) + } + }) + } +} diff --git a/service/sshgate/deploy/Kubefile b/service/sshgate/deploy/Kubefile new file mode 100644 index 000000000000..391cd4fa941b --- /dev/null +++ b/service/sshgate/deploy/Kubefile @@ -0,0 +1,8 @@ +FROM --platform=$BUILDPLATFORM scratch +ARG TARGETARCH +COPY registry_${TARGETARCH} registry + +COPY install.sh install.sh +COPY charts charts + +CMD ["bash install.sh"] diff --git a/service/sshgate/deploy/chart/.helmignore b/service/sshgate/deploy/chart/.helmignore new file mode 100644 index 000000000000..0e8a0eb36f4c --- /dev/null +++ b/service/sshgate/deploy/chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/service/sshgate/deploy/chart/Chart.yaml b/service/sshgate/deploy/chart/Chart.yaml new file mode 100644 index 000000000000..e2ce8be19cc0 --- /dev/null +++ b/service/sshgate/deploy/chart/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: sshgate +description: A Kubernetes-native SSH gateway that routes SSH connections to Devbox pods based on client public key +type: application +version: 0.1.0 +appVersion: "latest" +keywords: + - ssh + - gateway + - devbox + - sealos +maintainers: + - name: sshgate diff --git a/service/sshgate/deploy/chart/templates/_helpers.tpl b/service/sshgate/deploy/chart/templates/_helpers.tpl new file mode 100644 index 000000000000..8444902ec8a5 --- /dev/null +++ b/service/sshgate/deploy/chart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "sshgate.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "sshgate.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "sshgate.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "sshgate.labels" -}} +helm.sh/chart: {{ include "sshgate.chart" . }} +{{ include "sshgate.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "sshgate.selectorLabels" -}} +app.kubernetes.io/name: {{ include "sshgate.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "sshgate.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "sshgate.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/service/sshgate/deploy/chart/templates/clusterrole.yaml b/service/sshgate/deploy/chart/templates/clusterrole.yaml new file mode 100644 index 000000000000..6a7cd18f4a78 --- /dev/null +++ b/service/sshgate/deploy/chart/templates/clusterrole.yaml @@ -0,0 +1,12 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "sshgate.fullname" . }} + labels: + {{- include "sshgate.labels" . | nindent 4 }} +rules: +- apiGroups: [""] + resources: ["secrets", "pods"] + verbs: ["get", "list", "watch"] +{{- end }} diff --git a/service/sshgate/deploy/chart/templates/clusterrolebinding.yaml b/service/sshgate/deploy/chart/templates/clusterrolebinding.yaml new file mode 100644 index 000000000000..3d46345f6eed --- /dev/null +++ b/service/sshgate/deploy/chart/templates/clusterrolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "sshgate.fullname" . }} + labels: + {{- include "sshgate.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "sshgate.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ include "sshgate.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/service/sshgate/deploy/chart/templates/configmap.yaml b/service/sshgate/deploy/chart/templates/configmap.yaml new file mode 100644 index 000000000000..1d9026d3efb2 --- /dev/null +++ b/service/sshgate/deploy/chart/templates/configmap.yaml @@ -0,0 +1,21 @@ +{{- $existingConfigMap := lookup "v1" "ConfigMap" .Release.Namespace (include "sshgate.fullname" .) -}} +{{- $sshHostKeySeed := "" -}} +{{- if .Values.sshHostKeySeed -}} + {{- $sshHostKeySeed = .Values.sshHostKeySeed -}} +{{- else if $existingConfigMap -}} + {{- $sshHostKeySeed = index $existingConfigMap.data "SSH_HOST_KEY_SEED" -}} +{{- else -}} + {{- $sshHostKeySeed = randAlphaNum 32 -}} +{{- end -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "sshgate.fullname" . }} + labels: + {{- include "sshgate.labels" . | nindent 4 }} +data: + SSH_HOST_KEY_SEED: {{ $sshHostKeySeed | quote }} + SSH_LISTEN_ADDR: {{ printf ":%d" (int .Values.sshPort) | quote }} +{{- range $key, $value := .Values.env }} + {{ $key }}: {{ $value | quote }} +{{- end }} diff --git a/service/sshgate/deploy/chart/templates/daemonset.yaml b/service/sshgate/deploy/chart/templates/daemonset.yaml new file mode 100644 index 000000000000..e47444483570 --- /dev/null +++ b/service/sshgate/deploy/chart/templates/daemonset.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: {{ include "sshgate.fullname" . }} + labels: + {{- include "sshgate.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "sshgate.selectorLabels" . | nindent 6 }} + updateStrategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "sshgate.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "sshgate.serviceAccountName" . }} + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: ssh + containerPort: {{ .Values.sshPort }} + hostPort: {{ .Values.sshPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "sshgate.fullname" . }} + livenessProbe: + tcpSocket: + port: {{ .Values.sshPort }} + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: {{ .Values.sshPort }} + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/service/sshgate/deploy/chart/templates/serviceaccount.yaml b/service/sshgate/deploy/chart/templates/serviceaccount.yaml new file mode 100644 index 000000000000..9bacdc2eb081 --- /dev/null +++ b/service/sshgate/deploy/chart/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "sshgate.serviceAccountName" . }} + labels: + {{- include "sshgate.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/service/sshgate/deploy/chart/values.yaml b/service/sshgate/deploy/chart/values.yaml new file mode 100644 index 000000000000..0ef198553607 --- /dev/null +++ b/service/sshgate/deploy/chart/values.yaml @@ -0,0 +1,88 @@ +# Default values for sshgate + +image: + repository: ghcr.io/labring/sealos-sshgate-service + pullPolicy: IfNotPresent + tag: "" # Overrides the image tag whose default is the chart appVersion + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# SSH port exposed on the host (hostPort) +# The gateway listens on this port on each node +sshPort: 2222 + +# SSH Host Key Seed for deterministic key generation +# All DaemonSet pods with the same seed will generate identical host keys +# Warning: Keep this secure. Anyone with the seed can regenerate the private key. +# If empty, a random 32-character seed will be auto-generated on first install +sshHostKeySeed: "" + +# Additional environment variables +# These will be added to the ConfigMap and injected into the pods +env: {} + # Example: + # LOG_LEVEL: "debug" + # CUSTOM_VAR: "value" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# RBAC settings +rbac: + # Specifies whether RBAC resources should be created + create: true + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# Node selector for DaemonSet scheduling +# Example: Only run on nodes with specific label +# nodeSelector: +# role: gateway +nodeSelector: {} + +# Tolerations for DaemonSet scheduling +# Example: Run on master nodes +# tolerations: +# - key: node-role.kubernetes.io/master +# effect: NoSchedule +tolerations: [] + +# Affinity rules for DaemonSet scheduling +affinity: {} + +# Update strategy for DaemonSet +updateStrategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 diff --git a/service/sshgate/deploy/install.sh b/service/sshgate/deploy/install.sh new file mode 100644 index 000000000000..cc4a7d8b7f6c --- /dev/null +++ b/service/sshgate/deploy/install.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +HELM_OPTS=${HELM_OPTS:-""} + +helm upgrade -i sshgate \ + -n devbox-system --create-namespace \ + ./chart \ + --set 'tolerations[0].operator=Exists' \ + ${HELM_OPTS} \ + --wait diff --git a/service/sshgate/gateway/agent.go b/service/sshgate/gateway/agent.go new file mode 100644 index 000000000000..e819dd39b07c --- /dev/null +++ b/service/sshgate/gateway/agent.go @@ -0,0 +1,220 @@ +package gateway + +import ( + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +func (g *Gateway) handleAgentForwardMode( + newChannel ssh.NewChannel, + ctx *sessionContext, +) { + sessionLogger := ctx.logger.WithField("mode", "agent_forwarding") + + channel, requests, err := newChannel.Accept() + if err != nil { + sessionLogger.WithError(err).Error("Failed to accept session channel") + return + } + defer channel.Close() + + // Process channel requests to handle auth-agent-req@openssh.com + // This implements the OpenSSH standard where auth-agent-req is a CHANNEL request + // Returns agent channel and cached requests + sessionResult := g.handleSessionRequests(requests, ctx) + + // Check if agent forwarding was successful + if sessionResult == nil || sessionResult.AgentChannel == nil { + sessionLogger.Warn("Failed to establish agent forwarding") + fmt.Fprintf(channel, + "Failed to establish agent forwarding\r\n"+ + "Make sure your SSH agent is running and has the correct keys\r\n", + ) + + return + } + + // Connect to backend with agent authentication if available + backendConn, err := g.connectToBackend(ctx, sessionResult.AgentChannel) + // close agent channel + _ = sessionResult.AgentChannel.Close() + + if err != nil { + sessionLogger.WithError(err).Error("Failed to connect to backend") + fmt.Fprintf(channel, + "Failed to connect to devbox: %v\r\n"+ + "Make sure your SSH agent has the correct key and that the key is in ~/.ssh/authorized_keys on the devbox\r\n", + err, + ) + + return + } + + defer backendConn.Close() + + sessionLogger.Info("Backend connected via agent forwarding") + + backendChannel, backendRequests, err := backendConn.OpenChannel("session", nil) + if err != nil { + sessionLogger.WithError(err).Error("Failed to open backend channel") + return + } + defer backendChannel.Close() + + // Forward cached requests to backend + g.forwardCachedRequests(sessionResult.CachedRequests, backendChannel, sessionLogger) + + // Use synchronized proxy to ensure exit-status is forwarded before closing + g.proxyChannelWithRequests( + channel, + backendChannel, + requests, + backendRequests, + sessionLogger, + ) +} + +// forwardCachedRequests forwards cached SSH requests to the backend +func (g *Gateway) forwardCachedRequests( + cachedRequests []*ssh.Request, + backendChannel ssh.Channel, + logger *log.Entry, +) { + for _, req := range cachedRequests { + ok, err := backendChannel.SendRequest(req.Type, req.WantReply, req.Payload) + + if req.WantReply { + _ = req.Reply(ok, nil) + } + + if err != nil { + logger.WithField("request_type", req.Type). + WithError(err). + Warn("Failed to forward cached request") + + return + } + } +} + +// SessionRequestsResult contains the results of processing session requests +type SessionRequestsResult struct { + AgentChannel ssh.Channel // Agent channel to client (nil if not requested/failed) + CachedRequests []*ssh.Request // Cached non-agent requests (max 6) +} + +// handleSessionRequests processes channel requests for a session +// This handles auth-agent-req@openssh.com as a CHANNEL request (OpenSSH standard) +// Returns a result with agent request status and cached non-agent requests +func (g *Gateway) handleSessionRequests( + requests <-chan *ssh.Request, + ctx *sessionContext, +) *SessionRequestsResult { + result := &SessionRequestsResult{ + AgentChannel: nil, + CachedRequests: make([]*ssh.Request, 0, g.options.MaxCachedRequests), + } + + // Process requests until we've handled all initial setup requests + timeout := time.NewTimer(g.options.SessionRequestTimeout) + defer timeout.Stop() + + for { + select { + case req, ok := <-requests: + if !ok { + return result + } + + ctx.logger.WithFields(log.Fields{ + "request_type": req.Type, + "want_reply": req.WantReply, + }).Debug("Session channel request") + + // Handle auth-agent-req@openssh.com as a channel request (OpenSSH standard) + if req.Type == "auth-agent-req@openssh.com" { + ctx.logger.Info("Agent forwarding requested by client") + + if req.WantReply { + _ = req.Reply(true, nil) + } + + // CRITICAL: In bastion host mode, we need to actively create + // an agent channel to the client immediately! + result.AgentChannel = g.createAgentChannelToClient(ctx) + + result.CachedRequests = append(result.CachedRequests, req) + + // Don't forward this request to backend - we handle it + return result + } + + // For all other request types, cache them for forwarding + if len(result.CachedRequests) < g.options.MaxCachedRequests { + result.CachedRequests = append(result.CachedRequests, req) + + timeout.Reset(time.Second) + continue + } + + return nil + + case <-timeout.C: + // Timeout - stop processing initial requests + return result + } + } +} + +// createAgentChannelToClient actively creates an agent channel to the client +// This is the critical function for bastion host SSH agent forwarding +// Returns the created agent channel or nil if failed +func (g *Gateway) createAgentChannelToClient(ctx *sessionContext) ssh.Channel { + // Use the client connection to open an agent channel + // This tells the client "I want to access your SSH agent" + agentChannel, agentReqs, err := ctx.conn.OpenChannel("auth-agent@openssh.com", nil) + if err != nil { + ctx.logger.WithError(err).Error("Failed to open agent channel to client") + return nil + } + + ctx.logger.Info("Agent channel to client established") + + // Discard requests on the agent channel + go ssh.DiscardRequests(agentReqs) + + return agentChannel +} + +func (g *Gateway) connectToBackend( + ctx *sessionContext, + agentChannel ssh.Channel, +) (*ssh.Client, error) { + backendAddr := fmt.Sprintf("%s:%d", ctx.info.PodIP, g.options.SSHBackendPort) + + agentClient := agent.NewClient(agentChannel) + + backendConfig := &ssh.ClientConfig{ + User: ctx.realUser, + Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}, + //nolint:gosec + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: g.options.BackendConnectTimeoutAgent, + } + + ctx.logger.WithFields(log.Fields{ + "backend_addr": backendAddr, + "backend_user": ctx.realUser, + }).Info("Connecting to backend with agent authentication") + + conn, err := ssh.Dial("tcp", backendAddr, backendConfig) + if err != nil { + return nil, err + } + + return conn, nil +} diff --git a/service/sshgate/gateway/auth.go b/service/sshgate/gateway/auth.go new file mode 100644 index 000000000000..885cf2d6308e --- /dev/null +++ b/service/sshgate/gateway/auth.go @@ -0,0 +1,219 @@ +package gateway + +import ( + "errors" + + "github.com/labring/sealos/service/sshgate/registry" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// AuthMode represents the authentication mode +type AuthMode int + +const ( + AuthModeUnknown AuthMode = iota + AuthModePublicKey // Public key authentication mode + AuthModeCustomKey // Custom key authentication mode (user-defined public keys) + AuthModeNoAuth // No client authentication mode +) + +func (m AuthMode) String() string { + switch m { + case AuthModePublicKey: + return "public-key" + case AuthModeCustomKey: + return "custom-key" + case AuthModeNoAuth: + return "no-auth" + default: + return "unknown" + } +} + +// publicKeyCallback handles public key authentication +func (g *Gateway) PublicKeyCallback( + conn ssh.ConnMetadata, + key ssh.PublicKey, +) (*ssh.Permissions, error) { + username := conn.User() + + // Create auth logger with base fields + authLogger := g.logger.WithFields(log.Fields{ + "auth_type": "public_key", + "remote_addr": conn.RemoteAddr().String(), + "user": username, + }) + + authLogger.Info("authentication attempt") + + // Look up devbox by public key + info, ok := g.registry.GetByPublicKey(key) + if !ok { + // Parse username: username@short_user_namespace-devboxname + username, fullNamespace, devboxName, err := g.parser.Parse(conn.User()) + if err != nil { + return nil, err + } + + // Update logger with devbox info for custom key mode + customKeyLogger := authLogger.WithFields(log.Fields{ + "auth_mode": AuthModeCustomKey.String(), + "namespace": fullNamespace, + "devbox": devboxName, + }) + + info, ok := g.registry.GetDevboxInfo(fullNamespace, devboxName) + if !ok { + return nil, errors.New("devbox not found") + } + + customKeyLogger.Info("authentication accept") + + return &ssh.Permissions{ + Extensions: map[string]string{ + "username": username, + "auth_mode": AuthModeCustomKey.String(), + }, + ExtraData: map[any]any{ + "devbox_info": info, + "logger": customKeyLogger, + }, + }, nil + } + + // Update logger with matched devbox info + pkLogger := authLogger.WithFields(log.Fields{ + "namespace": info.Namespace, + "devbox": info.DevboxName, + }) + + authLogger.Info("authentication accept") + + return &ssh.Permissions{ + Extensions: map[string]string{ + "username": username, + "auth_mode": AuthModePublicKey.String(), + }, + ExtraData: map[any]any{ + "devbox_info": info, + "logger": pkLogger, + }, + }, nil +} + +// NoClientAuthCallback handles no client authentication +// It parses the username to determine which devbox to connect to +func (g *Gateway) NoClientAuthCallback(conn ssh.ConnMetadata) (*ssh.Permissions, error) { + username := conn.User() + + // Create auth logger with base fields + authLogger := g.logger.WithFields(log.Fields{ + "auth_type": "no_auth", + "remote_addr": conn.RemoteAddr().String(), + "user": username, + }) + + authLogger.Info("authentication attempt") + + // Parse username: username@short_user_namespace-devboxname + parsedUsername, fullNamespace, devboxName, err := g.parser.Parse(username) + if err != nil { + return nil, err + } + + // Update logger with devbox info + noAuthLogger := authLogger.WithFields(log.Fields{ + "namespace": fullNamespace, + "devbox": devboxName, + }) + + noAuthLogger.Info("authentication accept") + + // Get devbox info + info, ok := g.registry.GetDevboxInfo(fullNamespace, devboxName) + if !ok { + return nil, errors.New("devbox not found") + } + + return &ssh.Permissions{ + Extensions: map[string]string{ + "username": parsedUsername, + "auth_mode": AuthModeNoAuth.String(), + }, + ExtraData: map[any]any{ + "devbox_info": info, + "logger": noAuthLogger, + }, + }, nil +} + +// determineAuthMode determines which authentication mode is being used +func (g *Gateway) determineAuthMode(conn *ssh.ServerConn) AuthMode { + if conn.Permissions == nil { + return AuthModeNoAuth + } + + authMode := conn.Permissions.Extensions["auth_mode"] + switch authMode { + case AuthModePublicKey.String(): + return AuthModePublicKey + case AuthModeNoAuth.String(): + return AuthModeNoAuth + default: + return AuthModeCustomKey + } +} + +func (g *Gateway) getDevboxInfoFromPermissions( + perms *ssh.Permissions, +) (*registry.DevboxInfo, error) { + if perms == nil { + return nil, errors.New("permissions is nil") + } + + infoValue, ok := perms.ExtraData["devbox_info"] + if !ok { + return nil, errors.New("no devbox_info in permissions") + } + + info, ok := infoValue.(*registry.DevboxInfo) + if !ok || info == nil { + return nil, errors.New("invalid devbox_info type") + } + + return info, nil +} + +// GetDevboxInfoFromPermissions is exported for testing +func GetDevboxInfoFromPermissions(perms *ssh.Permissions) (*registry.DevboxInfo, error) { + gw := &Gateway{} + return gw.getDevboxInfoFromPermissions(perms) +} + +// GetUsernameFromPermissions is exported for testing +func GetUsernameFromPermissions(perms *ssh.Permissions) (string, error) { + if perms == nil { + return "", errors.New("permissions is nil") + } + + username, ok := perms.Extensions["username"] + if !ok { + return "", errors.New("no username in permissions") + } + + return username, nil +} + +// NewPublicKeyCallback creates a public key callback for testing +func NewPublicKeyCallback( + reg *registry.Registry, +) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { + gw := &Gateway{ + registry: reg, + parser: &UsernameParser{}, + logger: log.WithField("component", "gateway"), + } + + return gw.PublicKeyCallback +} diff --git a/service/sshgate/gateway/custom.go b/service/sshgate/gateway/custom.go new file mode 100644 index 000000000000..60f5150890df --- /dev/null +++ b/service/sshgate/gateway/custom.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "github.com/labring/sealos/service/sshgate/registry" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +type sessionContext struct { + conn *ssh.ServerConn + info *registry.DevboxInfo + realUser string + logger *log.Entry +} + +func (g *Gateway) handleCustomKeyOrNoAuthMode( + conn *ssh.ServerConn, + chans <-chan ssh.NewChannel, + reqs <-chan *ssh.Request, + info *registry.DevboxInfo, + username string, + logger *log.Entry, +) { + ctx := &sessionContext{ + conn: conn, + info: info, + realUser: username, + logger: logger.WithFields(log.Fields{ + "namespace": info.Namespace, + "devbox": info.DevboxName, + "user": username, + }), + } + + go ssh.DiscardRequests(reqs) + + for newChannel := range chans { + g.handleChannelCustomKeyOrNoAuth(newChannel, ctx) + } +} + +func (g *Gateway) handleChannelCustomKeyOrNoAuth( + newChannel ssh.NewChannel, + ctx *sessionContext, +) { + channelType := newChannel.ChannelType() + ctx.logger.WithField("channel_type", channelType).Info("New channel") + + switch channelType { + case "session": + g.handleAgentForwardMode(newChannel, ctx) + + case "direct-tcpip": + g.handleProxyJumpMode(newChannel, ctx) + + default: + ctx.logger.WithField("channel_type", channelType).Warn("Rejecting unknown channel type") + + _ = newChannel.Reject(ssh.UnknownChannelType, "unsupported channel type") + } +} diff --git a/service/sshgate/gateway/gateway.go b/service/sshgate/gateway/gateway.go new file mode 100644 index 000000000000..7fd45a14eaf7 --- /dev/null +++ b/service/sshgate/gateway/gateway.go @@ -0,0 +1,245 @@ +// Package gateway provides SSH gateway functionality with support for +// multiple authentication modes including agent forwarding and proxy jump. +package gateway + +import ( + "fmt" + "net" + "time" + + "github.com/labring/sealos/service/sshgate/registry" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// Options holds gateway configuration options +type Options struct { + SSHHandshakeTimeout time.Duration `env:"SSH_HANDSHAKE_TIMEOUT" envDefault:"15s"` + SSHBackendPort int `env:"SSH_BACKEND_PORT" envDefault:"22"` + BackendConnectTimeoutPublicKey time.Duration `env:"BACKEND_CONNECT_TIMEOUT_PUBLICKEY" envDefault:"10s"` + BackendConnectTimeoutAgent time.Duration `env:"BACKEND_CONNECT_TIMEOUT_AGENT" envDefault:"5s"` + ProxyJumpTimeout time.Duration `env:"PROXY_JUMP_TIMEOUT" envDefault:"5s"` + SessionRequestTimeout time.Duration `env:"SESSION_REQUEST_TIMEOUT" envDefault:"3s"` + MaxCachedRequests int `env:"MAX_CACHED_REQUESTS" envDefault:"6"` + EnableAgentForward bool `env:"ENABLE_AGENT_FORWARD" envDefault:"true"` + EnableProxyJump bool `env:"ENABLE_PROXY_JUMP" envDefault:"false"` +} + +// DefaultOptions returns the default gateway options +func DefaultOptions() Options { + return Options{ + SSHHandshakeTimeout: 15 * time.Second, + SSHBackendPort: 22, + BackendConnectTimeoutPublicKey: 10 * time.Second, + BackendConnectTimeoutAgent: 5 * time.Second, + ProxyJumpTimeout: 5 * time.Second, + SessionRequestTimeout: 3 * time.Second, + MaxCachedRequests: 6, + EnableAgentForward: true, + EnableProxyJump: false, + } +} + +// Option is a functional option for configuring Gateway +type Option func(*Options) + +// WithOptions applies pre-configured options +func WithOptions(opts Options) Option { + return func(o *Options) { + *o = opts + } +} + +// WithSSHHandshakeTimeout sets the SSH handshake timeout +func WithSSHHandshakeTimeout(timeout time.Duration) Option { + return func(o *Options) { + o.SSHHandshakeTimeout = timeout + } +} + +// WithSSHBackendPort sets the SSH backend port +func WithSSHBackendPort(port int) Option { + return func(o *Options) { + o.SSHBackendPort = port + } +} + +// WithBackendConnectTimeouts sets the backend connect timeouts +func WithBackendConnectTimeouts(publicKeyTimeout, agentTimeout time.Duration) Option { + return func(o *Options) { + o.BackendConnectTimeoutPublicKey = publicKeyTimeout + o.BackendConnectTimeoutAgent = agentTimeout + } +} + +// WithProxyJumpTimeout sets the proxy jump timeout +func WithProxyJumpTimeout(timeout time.Duration) Option { + return func(o *Options) { + o.ProxyJumpTimeout = timeout + } +} + +// WithSessionRequestTimeout sets the session request timeout +func WithSessionRequestTimeout(timeout time.Duration) Option { + return func(o *Options) { + o.SessionRequestTimeout = timeout + } +} + +// WithMaxCachedRequests sets the maximum number of cached requests +func WithMaxCachedRequests(maxRequests int) Option { + return func(o *Options) { + o.MaxCachedRequests = maxRequests + } +} + +// WithEnableAgentForward sets whether agent forwarding is enabled +func WithEnableAgentForward(enable bool) Option { + return func(o *Options) { + o.EnableAgentForward = enable + } +} + +// WithEnableProxyJump sets whether proxy jump is enabled +func WithEnableProxyJump(enable bool) Option { + return func(o *Options) { + o.EnableProxyJump = enable + } +} + +// Gateway handles SSH connections and routes them to backend devbox pods +type Gateway struct { + sshConfig *ssh.ServerConfig + registry *registry.Registry + options *Options + parser *UsernameParser + logger *log.Entry +} + +// New creates a new Gateway instance with functional options +func New(hostKey ssh.Signer, reg *registry.Registry, opts ...Option) *Gateway { + // Start with default options + options := DefaultOptions() + + // Apply functional options + for _, opt := range opts { + opt(&options) + } + + gw := &Gateway{ + registry: reg, + options: &options, + parser: &UsernameParser{}, + logger: log.WithField("component", "gateway"), + } + + sshConfig := &ssh.ServerConfig{ + // Ref: https://www.openssh.org/txt/release-7.2 + // need disable no client auth mode + // because AddKeysToAgent need use public key auth + // NoClientAuth: true, + // NoClientAuthCallback: gw.NoClientAuthCallback, + PublicKeyCallback: gw.PublicKeyCallback, + } + sshConfig.AddHostKey(hostKey) + + gw.sshConfig = sshConfig + + return gw +} + +func (g *Gateway) HandleConnection(nConn net.Conn) { + _ = nConn.SetDeadline(time.Now().Add(g.options.SSHHandshakeTimeout)) + + conn, chans, reqs, err := ssh.NewServerConn(nConn, g.sshConfig) + if err != nil { + g.logger.WithFields(log.Fields{ + "remote_addr": nConn.RemoteAddr().String(), + }).WithError(err).Warn("SSH handshake failed") + return + } + defer conn.Close() + + _ = nConn.SetDeadline(time.Time{}) + + info, err := g.getDevboxInfoFromPermissions(conn.Permissions) + if err != nil { + g.logger.WithFields(log.Fields{ + "remote_addr": conn.RemoteAddr().String(), + "user": conn.User(), + }).WithError(err).Error("Failed to get devbox info from permissions") + + return + } + + username := conn.Permissions.Extensions["username"] + + // Determine authentication mode + authMode := g.determineAuthMode(conn) + + // Extract logger from permissions (created during authentication) + connLogger := g.getLoggerFromPermissions(conn.Permissions) + + // Fallback: create logger if not found in ExtraData (shouldn't happen normally) + if connLogger == nil { + connLogger = g.logger.WithFields(log.Fields{ + "remote_addr": conn.RemoteAddr().String(), + "ssh_user": conn.User(), + "namespace": info.Namespace, + "devbox": info.DevboxName, + "auth_mode": authMode.String(), + }) + } + + // Check if devbox is running + if info.PodIP == "" { + connLogger.Warn("Devbox not running") + // Reject all incoming channels and close connection + + go ssh.DiscardRequests(reqs) + + for newChannel := range chans { + _ = newChannel.Reject( + ssh.ConnectionFailed, + fmt.Sprintf("devbox %s/%s is not running", info.Namespace, info.DevboxName), + ) + } + + return + } + + connLogger.Info("Connection established") + + switch authMode { + case AuthModePublicKey: + g.handlePublicKeyMode(conn, chans, reqs, info, username, connLogger) + case AuthModeCustomKey, AuthModeNoAuth: + g.handleCustomKeyOrNoAuthMode(conn, chans, reqs, info, username, connLogger) + default: + connLogger.Warn("Unknown auth mode, closing connection") + } +} + +func (g *Gateway) Config() *ssh.ServerConfig { + return g.sshConfig +} + +// getLoggerFromPermissions extracts the logger from SSH permissions +// Returns the logger if found, otherwise returns nil +func (g *Gateway) getLoggerFromPermissions(perms *ssh.Permissions) *log.Entry { + if perms == nil || perms.ExtraData == nil { + return nil + } + + loggerValue, ok := perms.ExtraData["logger"] + if !ok { + return nil + } + + logger, ok := loggerValue.(*log.Entry) + if !ok { + return nil + } + + return logger +} diff --git a/service/sshgate/gateway/gateway_test.go b/service/sshgate/gateway/gateway_test.go new file mode 100644 index 000000000000..87d8e224df2b --- /dev/null +++ b/service/sshgate/gateway/gateway_test.go @@ -0,0 +1,559 @@ +package gateway_test + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "net" + "os" + "testing" + "time" + + "github.com/labring/sealos/service/sshgate/gateway" + "github.com/labring/sealos/service/sshgate/logger" + "github.com/labring/sealos/service/sshgate/registry" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMain(m *testing.M) { + // Initialize logger for tests + logger.InitLog() + os.Exit(m.Run()) +} + +func generateTestKeys(t *testing.T) (ssh.Signer, ssh.PublicKey, []byte, []byte) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("Failed to create SSH public key: %v", err) + } + + sshPriv, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("Failed to create SSH signer: %v", err) + } + + pubBytes := ssh.MarshalAuthorizedKey(sshPub) + + privPEM, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("Failed to marshal private key: %v", err) + } + + privBytes := pem.EncodeToMemory(privPEM) + + return sshPriv, sshPub, pubBytes, privBytes +} + +func TestNew(t *testing.T) { + hostKeySigner, _, _, _ := generateTestKeys(t) + reg := registry.New() + + gw := gateway.New(hostKeySigner, reg) + + if gw == nil { + t.Fatal("New() returned nil") + } + + // Verify that Config is accessible + sshConfig := gw.Config() + if sshConfig == nil { + t.Fatal("Config() returned nil") + } + + // Verify that PublicKeyCallback is set + if sshConfig.PublicKeyCallback == nil { + t.Fatal("PublicKeyCallback is nil") + } +} + +// mockConnMetadata implements ssh.ConnMetadata for testing +type mockConnMetadata struct { + user string + sessionID []byte + clientVersion []byte + serverVersion []byte + remoteAddr net.Addr + localAddr net.Addr +} + +func (m *mockConnMetadata) User() string { return m.user } +func (m *mockConnMetadata) SessionID() []byte { return m.sessionID } +func (m *mockConnMetadata) ClientVersion() []byte { return m.clientVersion } +func (m *mockConnMetadata) ServerVersion() []byte { return m.serverVersion } +func (m *mockConnMetadata) RemoteAddr() net.Addr { return m.remoteAddr } +func (m *mockConnMetadata) LocalAddr() net.Addr { return m.localAddr } + +func newMockConnMetadata(username string) *mockConnMetadata { + return &mockConnMetadata{ + user: username, + sessionID: []byte("test-session"), + clientVersion: []byte("SSH-2.0-Test"), + serverVersion: []byte("SSH-2.0-Test"), + remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345}, + localAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 22}, + } +} + +func TestPublicKeyCallback_AcceptKnownKey(t *testing.T) { + // Create registry and add a devbox + reg := registry.New() + + // Generate test keys for the devbox + devboxSigner, devboxPub, devboxPubBytes, devboxPrivBytes := generateTestKeys(t) + + // Create a secret with the devbox keys + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: devboxPubBytes, + registry.DevboxPrivateKeyField: devboxPrivBytes, + }, + } + + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret to registry: %v", err) + } + + // Create the PublicKeyCallback + callback := gateway.NewPublicKeyCallback(reg) + + // Test authentication with the known key + conn := newMockConnMetadata("testuser") + + perms, err := callback(conn, devboxPub) + if err != nil { + t.Fatalf("Expected no error for known key, got: %v", err) + } + + if perms == nil { + t.Fatal("Expected permissions to be returned") + } + + // Verify username is stored + username, err := gateway.GetUsernameFromPermissions(perms) + if err != nil { + t.Fatalf("Failed to get username from permissions: %v", err) + } + + if username != "testuser" { + t.Errorf("Expected username 'testuser', got: %s", username) + } + + // Verify DevboxInfo is stored + info, err := gateway.GetDevboxInfoFromPermissions(perms) + if err != nil { + t.Fatalf("Failed to get devbox info from permissions: %v", err) + } + + if info.Namespace != "test-ns" { + t.Errorf("Expected namespace 'test-ns', got: %s", info.Namespace) + } + + if info.DevboxName != "test-devbox" { + t.Errorf("Expected devbox name 'test-devbox', got: %s", info.DevboxName) + } + + // Verify the private key is available + if info.PrivateKey == nil { + t.Error("Expected PrivateKey to be set") + } + + // Verify the private key matches + if string(info.PrivateKey.PublicKey().Marshal()) != string(devboxSigner.PublicKey().Marshal()) { + t.Error("Private key mismatch") + } +} + +func TestPublicKeyCallback_RejectUnknownKey(t *testing.T) { + // Create empty registry + reg := registry.New() + + // Generate an unknown key + _, unknownPub, _, _ := generateTestKeys(t) + + // Create the PublicKeyCallback + callback := gateway.NewPublicKeyCallback(reg) + + // Test authentication with unknown key + conn := newMockConnMetadata("testuser") + + perms, err := callback(conn, unknownPub) + if err == nil { + t.Fatal("Expected error for unknown key, got nil") + } + + if perms != nil { + t.Error("Expected nil permissions for unknown key") + } +} + +func TestPublicKeyCallback_MultipleDevboxes(t *testing.T) { + reg := registry.New() + + // Add multiple devboxes + devboxes := []struct { + namespace string + name string + }{ + {"ns1", "devbox1"}, + {"ns1", "devbox2"}, + {"ns2", "devbox1"}, + } + + keys := make(map[string]ssh.PublicKey) + + for _, db := range devboxes { + _, pub, pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-" + db.name, + Namespace: db.namespace, + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: db.name, + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret for %s/%s: %v", db.namespace, db.name, err) + } + + keys[db.namespace+"/"+db.name] = pub + } + + // Test that each key is accepted and maps to the correct devbox + callback := gateway.NewPublicKeyCallback(reg) + + for _, db := range devboxes { + key := keys[db.namespace+"/"+db.name] + conn := newMockConnMetadata("user") + + perms, err := callback(conn, key) + if err != nil { + t.Errorf("Failed to authenticate with key for %s/%s: %v", db.namespace, db.name, err) + continue + } + + info, err := gateway.GetDevboxInfoFromPermissions(perms) + if err != nil { + t.Errorf("Failed to get devbox info for %s/%s: %v", db.namespace, db.name, err) + continue + } + + if info.Namespace != db.namespace || info.DevboxName != db.name { + t.Errorf("Expected %s/%s, got %s/%s", + db.namespace, db.name, info.Namespace, info.DevboxName) + } + } +} + +// TestHandleConnection_Basic is a basic sanity check that HandleConnection can be called +// Full integration testing would require setting up a complete SSH server and backend. +// The authentication logic is thoroughly tested in the PublicKeyCallback tests above. +func TestHandleConnection_Basic(t *testing.T) { + // This test verifies that HandleConnection can be called without panicking + // when given an invalid connection. Real end-to-end testing would require + // a full SSH server setup with backend pods. + reg := registry.New() + hostKey, _, _, _ := generateTestKeys(t) + gw := gateway.New(hostKey, reg) + + // Create a connection that will immediately fail + clientConn, serverConn := net.Pipe() + clientConn.Close() // Close immediately to cause handshake failure + + // This should return quickly with a handshake error (not panic) + done := make(chan bool, 1) + go func() { + gw.HandleConnection(serverConn) + + done <- true + }() + + select { + case <-done: + // Expected: HandleConnection returned due to handshake failure + case <-time.After(1 * time.Second): + t.Fatal("HandleConnection did not return in time") + } +} + +func TestPublicKeyCallback_DifferentUsernames(t *testing.T) { + reg := registry.New() + + _, pub, pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + callback := gateway.NewPublicKeyCallback(reg) + + // Test that the same key works with different usernames + usernames := []string{"root", "user1", "admin", "developer"} + + for _, username := range usernames { + conn := newMockConnMetadata(username) + + perms, err := callback(conn, pub) + if err != nil { + t.Errorf("Failed to authenticate as %s: %v", username, err) + continue + } + + actualUsername, err := gateway.GetUsernameFromPermissions(perms) + if err != nil { + t.Errorf("Failed to get username from permissions: %v", err) + continue + } + + if actualUsername != username { + t.Errorf("Expected username %s, got: %s", username, actualUsername) + } + } +} + +func TestGetDevboxInfoFromPermissions(t *testing.T) { + reg := registry.New() + _, pub, pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + callback := gateway.NewPublicKeyCallback(reg) + conn := newMockConnMetadata("testuser") + + t.Run("ValidPermissions", func(t *testing.T) { + perms, err := callback(conn, pub) + if err != nil { + t.Fatalf("Authentication failed: %v", err) + } + + info, err := gateway.GetDevboxInfoFromPermissions(perms) + if err != nil { + t.Fatalf("Failed to get devbox info: %v", err) + } + + if info.Namespace != "test-ns" { + t.Errorf("Expected namespace 'test-ns', got: %s", info.Namespace) + } + + if info.DevboxName != "test-devbox" { + t.Errorf("Expected devbox name 'test-devbox', got: %s", info.DevboxName) + } + }) + + t.Run("NilPermissions", func(t *testing.T) { + _, err := gateway.GetDevboxInfoFromPermissions(nil) + if err == nil { + t.Error("Expected error for nil permissions") + } + }) + + t.Run("MissingDevboxInfo", func(t *testing.T) { + perms := &ssh.Permissions{ + ExtraData: map[any]any{}, + } + + _, err := gateway.GetDevboxInfoFromPermissions(perms) + if err == nil { + t.Error("Expected error for missing devbox_info") + } + }) + + t.Run("InvalidDevboxInfoType", func(t *testing.T) { + perms := &ssh.Permissions{ + ExtraData: map[any]any{ + "devbox_info": "invalid", + }, + } + + _, err := gateway.GetDevboxInfoFromPermissions(perms) + if err == nil { + t.Error("Expected error for invalid devbox_info type") + } + }) +} + +func TestGetUsernameFromPermissions(t *testing.T) { + t.Run("ValidPermissions", func(t *testing.T) { + perms := &ssh.Permissions{ + Extensions: map[string]string{ + "username": "testuser", + }, + } + + username, err := gateway.GetUsernameFromPermissions(perms) + if err != nil { + t.Fatalf("Failed to get username: %v", err) + } + + if username != "testuser" { + t.Errorf("Expected username 'testuser', got: %s", username) + } + }) + + t.Run("NilPermissions", func(t *testing.T) { + _, err := gateway.GetUsernameFromPermissions(nil) + if err == nil { + t.Error("Expected error for nil permissions") + } + }) + + t.Run("MissingUsername", func(t *testing.T) { + perms := &ssh.Permissions{ + Extensions: map[string]string{}, + } + + _, err := gateway.GetUsernameFromPermissions(perms) + if err == nil { + t.Error("Expected error for missing username") + } + }) +} + +func TestPublicKeyCallback_WithPodIP(t *testing.T) { + reg := registry.New() + + _, pub, pubBytes, privBytes := generateTestKeys(t) + + // Add secret first + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + // Add pod with IP + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + } + + if err := reg.UpdatePod(pod); err != nil { + t.Fatalf("Failed to update pod: %v", err) + } + + // Test authentication + callback := gateway.NewPublicKeyCallback(reg) + conn := newMockConnMetadata("testuser") + + perms, err := callback(conn, pub) + if err != nil { + t.Fatalf("Authentication failed: %v", err) + } + + info, err := gateway.GetDevboxInfoFromPermissions(perms) + if err != nil { + t.Fatalf("Failed to get devbox info from permissions: %v", err) + } + + if info.PodIP != "10.0.0.1" { + t.Errorf("Expected PodIP '10.0.0.1', got: %s", info.PodIP) + } +} diff --git a/service/sshgate/gateway/proxyjump.go b/service/sshgate/gateway/proxyjump.go new file mode 100644 index 000000000000..8de58e2eb350 --- /dev/null +++ b/service/sshgate/gateway/proxyjump.go @@ -0,0 +1,78 @@ +package gateway + +import ( + "fmt" + "net" + "strconv" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// directTCPIPMsg represents the payload for direct-tcpip channel requests +type directTCPIPMsg struct { + HostToConnect string + PortToConnect uint32 + OriginatorIPAddr string + OriginatorPort uint32 +} + +// handleProxyJumpMode handles SSH proxy jump (direct-tcpip) connections +// It ignores the client's requested destination and forcibly connects to the devbox +func (g *Gateway) handleProxyJumpMode( + newChannel ssh.NewChannel, + ctx *sessionContext, +) { + proxyLogger := ctx.logger.WithField("mode", "proxy_jump") + + // Parse the direct-tcpip payload + var msg directTCPIPMsg + if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil { + proxyLogger.WithError(err).Error("Failed to parse direct-tcpip payload") + + _ = newChannel.Reject(ssh.ConnectionFailed, "failed to parse request") + return + } + + proxyLogger.WithFields(log.Fields{ + "requested_host": msg.HostToConnect, + "requested_port": msg.PortToConnect, + "originator_ip": msg.OriginatorIPAddr, + "originator_port": msg.OriginatorPort, + }).Info("Client requested proxy jump") + + // Force connection to devbox, ignoring client's requested address + devboxAddr := net.JoinHostPort(ctx.info.PodIP, strconv.Itoa(g.options.SSHBackendPort)) + proxyLogger.WithField("devbox_addr", devboxAddr).Info("Forcing connection to devbox") + + // Dial to devbox + //nolint:noctx + conn, err := net.DialTimeout("tcp", devboxAddr, g.options.ProxyJumpTimeout) + if err != nil { + proxyLogger.WithField("devbox_addr", devboxAddr). + WithError(err). + Error("Failed to connect to devbox") + _ = newChannel.Reject(ssh.ConnectionFailed, fmt.Sprintf("failed to connect: %v", err)) + + return + } + defer conn.Close() + + // Accept the SSH channel + channel, requests, err := newChannel.Accept() + if err != nil { + proxyLogger.WithError(err).Error("Failed to accept channel") + return + } + defer channel.Close() + + // Discard any requests on this channel + go ssh.DiscardRequests(requests) + + proxyLogger.Info("Tunnel established") + + // Proxy data between client channel and devbox connection + g.proxyChannelToConn(channel, conn) + + proxyLogger.Info("Tunnel closed") +} diff --git a/service/sshgate/gateway/publickey.go b/service/sshgate/gateway/publickey.go new file mode 100644 index 000000000000..95d9d82ca11e --- /dev/null +++ b/service/sshgate/gateway/publickey.go @@ -0,0 +1,105 @@ +package gateway + +import ( + "fmt" + + "github.com/labring/sealos/service/sshgate/registry" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +func (g *Gateway) handlePublicKeyMode( + _ *ssh.ServerConn, + chans <-chan ssh.NewChannel, + reqs <-chan *ssh.Request, + info *registry.DevboxInfo, + username string, + logger *log.Entry, +) { + backendAddr := fmt.Sprintf("%s:%d", info.PodIP, g.options.SSHBackendPort) + + backendConfig := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(info.PrivateKey), + }, + //nolint:gosec + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: g.options.BackendConnectTimeoutPublicKey, + } + + backendConn, err := ssh.Dial("tcp", backendAddr, backendConfig) + if err != nil { + logger.WithField("backend_addr", backendAddr). + WithError(err). + Error("Failed to connect to backend") + return + } + defer backendConn.Close() + + logger.Info("Backend connected") + + go g.handleGlobalRequestsPublicKey(reqs, backendConn, logger) + + for newChannel := range chans { + go g.handleChannelPublicKey(newChannel, backendConn, logger) + } +} + +func (g *Gateway) handleGlobalRequestsPublicKey( + reqs <-chan *ssh.Request, + backendConn *ssh.Client, + logger *log.Entry, +) { + for req := range reqs { + ok, response, err := backendConn.SendRequest(req.Type, req.WantReply, req.Payload) + if req.WantReply { + _ = req.Reply(ok, response) + } + if err != nil { + logger.WithField("request_type", req.Type). + WithError(err). + Error("Error forwarding request") + + return + } + } +} + +func (g *Gateway) handleChannelPublicKey( + newChannel ssh.NewChannel, + backendConn *ssh.Client, + logger *log.Entry, +) { + channelLogger := logger.WithField("channel_type", newChannel.ChannelType()) + + backendChannel, backendReqs, err := backendConn.OpenChannel( + newChannel.ChannelType(), + newChannel.ExtraData(), + ) + if err != nil { + channelLogger.WithError(err).Warn("Failed to open backend channel") + _ = newChannel.Reject(ssh.ConnectionFailed, err.Error()) + return + } + defer backendChannel.Close() + + channel, requests, err := newChannel.Accept() + if err != nil { + channelLogger.WithError(err).Warn("Failed to accept channel") + backendChannel.Close() + return + } + defer channel.Close() + + channelLogger.Debug("Channel established") + + // Use synchronized proxy to ensure exit-status is forwarded before closing + g.proxyChannelWithRequests( + channel, + backendChannel, + requests, + backendReqs, + channelLogger, + ) +} diff --git a/service/sshgate/gateway/username.go b/service/sshgate/gateway/username.go new file mode 100644 index 000000000000..55f895df7f5f --- /dev/null +++ b/service/sshgate/gateway/username.go @@ -0,0 +1,66 @@ +package gateway + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// UsernameParser parses username in format: username@short_user_namespace-devboxname +type UsernameParser struct{} + +// Parse parses the username format +// Format: username@short_user_namespace-devboxname +// Examples: +// - ubuntu@someteam-workspace +func (p *UsernameParser) Parse(input string) (username, namespace, devboxname string, err error) { + // URL decode (handle %2E, %2D, etc.) + decoded, err := url.QueryUnescape(input) + if err == nil { + input = decoded + } + + // Cut at the first dot (separates username from target) + username, target, found := strings.Cut(input, "@") + if !found { + return "", "", "", fmt.Errorf( + "invalid format: expected username@namespace-devboxname, got: %s", + input, + ) + } + + if username == "" { + return "", "", "", errors.New("username cannot be empty") + } + + // Cut at the dash (separates namespace from devboxname) + namespace, devboxname, found = strings.Cut(target, "-") + if !found { + return "", "", "", fmt.Errorf( + "invalid format: expected namespace-devboxname, got: %s", + target, + ) + } + + if namespace == "" { + return "", "", "", errors.New("namespace cannot be empty") + } + + if devboxname == "" { + return "", "", "", errors.New("devboxname cannot be empty") + } + + return username, "ns-" + namespace, devboxname, nil +} + +// Format formats username, namespace, and devboxname into the standard format +func (p *UsernameParser) Format(username, namespace, devboxname string) string { + return fmt.Sprintf("%s.%s-%s", username, namespace, devboxname) +} + +// Validate validates the username format +func (p *UsernameParser) Validate(input string) error { + _, _, _, err := p.Parse(input) + return err +} diff --git a/service/sshgate/gateway/utils.go b/service/sshgate/gateway/utils.go new file mode 100644 index 000000000000..8e2bdd1bb24a --- /dev/null +++ b/service/sshgate/gateway/utils.go @@ -0,0 +1,79 @@ +package gateway + +import ( + "io" + "net" + "sync" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +func (g *Gateway) proxyRequests( + in <-chan *ssh.Request, + out ssh.Channel, + logger *log.Entry, +) { + for req := range in { + ok, err := out.SendRequest(req.Type, req.WantReply, req.Payload) + if req.WantReply { + _ = req.Reply(ok, nil) + } + if err != nil { + logger.WithField("request_type", req.Type). + WithError(err). + Error("Error forwarding request") + + return + } + } +} + +// proxyChannelWithRequests proxies data between two SSH channels while also +// forwarding requests. It ensures that exit-status is forwarded before closing. +func (g *Gateway) proxyChannelWithRequests( + channel, backendChannel ssh.Channel, + clientReqs, backendReqs <-chan *ssh.Request, + logger *log.Entry, +) { + // Client to backend: requests and data + go func() { + g.proxyRequests(clientReqs, backendChannel, logger) + }() + + go func() { + _, _ = io.Copy(backendChannel, channel) + _ = backendChannel.CloseWrite() + }() + + // Backend to client: wait for both data and requests before CloseWrite + var backendToClientWg sync.WaitGroup + + backendToClientWg.Go(func() { + _, _ = io.Copy(channel, backendChannel) + _ = channel.CloseWrite() + }) + + backendToClientWg.Go(func() { + g.proxyRequests(backendReqs, channel, logger) + }) + + // Wait for backend->client to complete (data + exit-status) + backendToClientWg.Wait() +} + +// proxyChannelToConn proxies data between an SSH channel and a net.Conn +func (g *Gateway) proxyChannelToConn(channel ssh.Channel, conn net.Conn) { + var wg sync.WaitGroup + wg.Go(func() { + _, _ = io.Copy(channel, conn) + _ = channel.CloseWrite() + }) + + _, _ = io.Copy(conn, channel) + _ = conn.Close() + + wg.Wait() + + _ = channel.Close() +} diff --git a/service/sshgate/gateway/utils_test.go b/service/sshgate/gateway/utils_test.go new file mode 100644 index 000000000000..5aeb88e67b52 --- /dev/null +++ b/service/sshgate/gateway/utils_test.go @@ -0,0 +1,410 @@ +package gateway_test + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "testing" + "time" + + "github.com/labring/sealos/service/sshgate/gateway" + "github.com/labring/sealos/service/sshgate/registry" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestExitStatusForwarding tests that exit-status is reliably forwarded +// from the backend to the client before the channel is closed. +// This is a regression test for the race condition where the gateway +// would close the client channel before forwarding the exit-status. +func TestExitStatusForwarding(t *testing.T) { + // Setup: Create a gateway with a mock backend + reg := registry.New() + hostKey, _, pubBytes, privBytes := generateTestKeys(t) + + // Create and register a devbox secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + {Kind: registry.DevboxOwnerKind, Name: "test-devbox"}, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + // Start a mock backend SSH server that returns a specific exit code + var lc net.ListenConfig + + backendListener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start backend listener: %v", err) + } + defer backendListener.Close() + + backendAddr := backendListener.Addr().String() + _, backendPort, _ := net.SplitHostPort(backendAddr) + + // Update registry with pod IP pointing to our mock backend + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + {Kind: registry.DevboxOwnerKind, Name: "test-devbox"}, + }, + }, + Status: corev1.PodStatus{ + PodIP: "127.0.0.1", + }, + } + if err := reg.UpdatePod(pod); err != nil { + t.Fatalf("Failed to update pod: %v", err) + } + + // Create gateway with custom backend port + gw := gateway.New(hostKey, reg, + gateway.WithSSHBackendPort(mustAtoi(t, backendPort)), + gateway.WithSSHHandshakeTimeout(5*time.Second), + gateway.WithBackendConnectTimeouts(5*time.Second, 5*time.Second), + ) + + // Start the gateway listener + gwListener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start gateway listener: %v", err) + } + defer gwListener.Close() + + // Run the mock backend server + backendKey, _, _, _ := generateTestKeys(t) + go runMockBackendServer(t, backendListener, backendKey, privBytes, 42) + + // Run gateway accept loop + go func() { + for { + conn, err := gwListener.Accept() + if err != nil { + return + } + + go gw.HandleConnection(conn) + } + }() + + // Run the test multiple times sequentially to catch race conditions + const numRuns = 50 + + for i := 1; i <= numRuns; i++ { + exitCode, err := runSSHCommand(t, gwListener.Addr().String(), privBytes, "exit 42") + if err != nil { + t.Fatalf("Run %d: SSH error: %v", i, err) + } + + if exitCode != 42 { + t.Errorf("Run %d: Expected exit code 42, got %d", i, exitCode) + } + } +} + +// TestExitStatusForwardingSequential runs exit status tests sequentially +// to isolate timing issues +func TestExitStatusForwardingSequential(t *testing.T) { + reg := registry.New() + hostKey, _, pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + {Kind: registry.DevboxOwnerKind, Name: "test-devbox"}, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + if err := reg.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + var lc net.ListenConfig + + backendListener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start backend listener: %v", err) + } + defer backendListener.Close() + + backendAddr := backendListener.Addr().String() + _, backendPort, _ := net.SplitHostPort(backendAddr) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + {Kind: registry.DevboxOwnerKind, Name: "test-devbox"}, + }, + }, + Status: corev1.PodStatus{ + PodIP: "127.0.0.1", + }, + } + if err := reg.UpdatePod(pod); err != nil { + t.Fatalf("Failed to update pod: %v", err) + } + + gw := gateway.New(hostKey, reg, + gateway.WithSSHBackendPort(mustAtoi(t, backendPort)), + gateway.WithSSHHandshakeTimeout(5*time.Second), + gateway.WithBackendConnectTimeouts(5*time.Second, 5*time.Second), + ) + + gwListener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start gateway listener: %v", err) + } + defer gwListener.Close() + + backendKey, _, _, _ := generateTestKeys(t) + go runMockBackendServer(t, backendListener, backendKey, privBytes, 42) + + go func() { + for { + conn, err := gwListener.Accept() + if err != nil { + return + } + + go gw.HandleConnection(conn) + } + }() + + // Test different exit codes sequentially + testCases := []int{0, 1, 42, 127, 255} + + for _, expectedCode := range testCases { + exitCode, err := runSSHCommand( + t, + gwListener.Addr().String(), + privBytes, + fmt.Sprintf("exit %d", expectedCode), + ) + if err != nil { + t.Fatalf("SSH command (exit %d) failed: %v", expectedCode, err) + } + + if exitCode != expectedCode { + t.Errorf("Expected exit code %d, got %d", expectedCode, exitCode) + } + } +} + +// runMockBackendServer runs a simple SSH server that accepts connections +// and returns the specified exit code for any command +func runMockBackendServer( + t *testing.T, + listener net.Listener, + hostKey ssh.Signer, + authorizedKey []byte, + defaultExitCode int, +) { + t.Helper() + + // Parse the authorized public key + authorizedPubKey, _, _, _, err := ssh.ParseAuthorizedKey(authorizedKey) + if err != nil { + // Try parsing as private key and extract public key + signer, parseErr := ssh.ParsePrivateKey(authorizedKey) + if parseErr != nil { + t.Logf("Failed to parse authorized key: %v", parseErr) + return + } + + authorizedPubKey = signer.PublicKey() + } + + config := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) == string(authorizedPubKey.Marshal()) { + return &ssh.Permissions{}, nil + } + + return nil, errors.New("unknown public key") + }, + } + config.AddHostKey(hostKey) + + for { + conn, err := listener.Accept() + if err != nil { + return + } + + go handleMockBackendConnection(conn, config, defaultExitCode) + } +} + +func handleMockBackendConnection(conn net.Conn, config *ssh.ServerConfig, exitCode int) { + defer conn.Close() + + sshConn, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + defer sshConn.Close() + + go ssh.DiscardRequests(reqs) + + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + + channel, requests, err := newChannel.Accept() + if err != nil { + continue + } + + go func(ch ssh.Channel, reqs <-chan *ssh.Request) { + defer ch.Close() + + for req := range reqs { + switch req.Type { + case "exec", "shell": + if req.WantReply { + _ = req.Reply(true, nil) + } + + // Parse exit code from exec command if present + actualExitCode := exitCode + if req.Type == "exec" && len(req.Payload) > 4 { + cmdLen := binary.BigEndian.Uint32(req.Payload[:4]) + if int(cmdLen) <= len(req.Payload)-4 { + cmd := string(req.Payload[4 : 4+cmdLen]) + + var parsedCode int + if _, err := fmt.Sscanf(cmd, "exit %d", &parsedCode); err == nil { + actualExitCode = parsedCode + } + } + } + + payload := make([]byte, 4) + //nolint:gosec // exit code is always 0-255 in tests + binary.BigEndian.PutUint32(payload, uint32(actualExitCode)) + _, _ = ch.SendRequest("exit-status", false, payload) + + // Send EOF after exit-status + _ = ch.CloseWrite() + + // Drain any remaining input from client before closing. + // This ensures the gateway has time to forward exit-status + // before the channel is closed. + _, _ = io.Copy(io.Discard, ch) + + return + + case "pty-req", "env": + if req.WantReply { + _ = req.Reply(true, nil) + } + + default: + if req.WantReply { + _ = req.Reply(false, nil) + } + } + } + }(channel, requests) + } +} + +// runSSHCommand connects to the gateway and runs a command, returning the exit code +func runSSHCommand(t *testing.T, addr string, privateKey []byte, command string) (int, error) { + t.Helper() + + signer, err := ssh.ParsePrivateKey(privateKey) + if err != nil { + return -1, fmt.Errorf("failed to parse private key: %w", err) + } + + config := &ssh.ClientConfig{ + User: "testuser", + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + //nolint:gosec // acceptable for testing + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return -1, fmt.Errorf("failed to dial: %w", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return -1, fmt.Errorf("failed to create session: %w", err) + } + defer session.Close() + + // Request PTY to simulate interactive session + if err := session.RequestPty("xterm", 80, 40, ssh.TerminalModes{}); err != nil { + return -1, fmt.Errorf("failed to request pty: %w", err) + } + + err = session.Run(command) + if err != nil { + exitErr := &ssh.ExitError{} + if errors.As(err, &exitErr) { + return exitErr.ExitStatus(), nil + } + + return -1, err + } + + return 0, nil +} + +func mustAtoi(t *testing.T, s string) int { + t.Helper() + + var n int + + _, err := fmt.Sscanf(s, "%d", &n) + if err != nil { + t.Fatalf("Failed to parse int: %v", err) + } + + return n +} diff --git a/service/sshgate/go.mod b/service/sshgate/go.mod new file mode 100644 index 000000000000..e29d2e586419 --- /dev/null +++ b/service/sshgate/go.mod @@ -0,0 +1,63 @@ +module github.com/labring/sealos/service/sshgate + +go 1.25.0 + +require ( + github.com/caarlos0/env/v9 v9.0.0 + github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 + golang.org/x/crypto v0.45.0 + k8s.io/api v0.34.2 + k8s.io/apimachinery v0.34.2 + k8s.io/client-go v0.34.2 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/swag v0.25.3 // indirect + github.com/go-openapi/swag/cmdutils v0.25.3 // indirect + github.com/go-openapi/swag/conv v0.25.3 // indirect + github.com/go-openapi/swag/fileutils v0.25.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect + github.com/go-openapi/swag/jsonutils v0.25.3 // indirect + github.com/go-openapi/swag/loading v0.25.3 // indirect + github.com/go-openapi/swag/mangling v0.25.3 // indirect + github.com/go-openapi/swag/netutils v0.25.3 // indirect + github.com/go-openapi/swag/stringutils v0.25.3 // indirect + github.com/go-openapi/swag/typeutils v0.25.3 // indirect + github.com/go-openapi/swag/yamlutils v0.25.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/service/sshgate/go.sum b/service/sshgate/go.sum new file mode 100644 index 000000000000..b2b39938cc5f --- /dev/null +++ b/service/sshgate/go.sum @@ -0,0 +1,180 @@ +github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= +github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +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-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/swag v0.25.3 h1:FAa5wJXyDtI7yUztKDfZxDrSx+8WTg31MfCQ9s3PV+s= +github.com/go-openapi/swag v0.25.3/go.mod h1:tX9vI8Mj8Ny+uCEk39I1QADvIPI7lkndX4qCsEqhkS8= +github.com/go-openapi/swag/cmdutils v0.25.3 h1:EIwGxN143JCThNHnqfqs85R8lJcJG06qjJRZp3VvjLI= +github.com/go-openapi/swag/cmdutils v0.25.3/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E= +github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU= +github.com/go-openapi/swag/fileutils v0.25.3 h1:P52Uhd7GShkeU/a1cBOuqIcHMHBrA54Z2t5fLlE85SQ= +github.com/go-openapi/swag/fileutils v0.25.3/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw= +github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao= +github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y= +github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c= +github.com/go-openapi/swag/mangling v0.25.3 h1:rGIrEzXaYWuUW1MkFmG3pcH+EIA0/CoUkQnIyB6TUyo= +github.com/go-openapi/swag/mangling v0.25.3/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.3 h1:XWXHZfL/65ABiv8rvGp9dtE0C6QHTYkCrNV77jTl358= +github.com/go-openapi/swag/netutils v0.25.3/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w= +github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc= +github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg= +github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +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/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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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/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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +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/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.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= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +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-20251121143641-b6aabc6c6745 h1:c3rI/4s8ibM4vV5UOIlbgkBpwkylI5I9YiPlOtf2g4Q= +k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/service/sshgate/hostkey/hostkey.go b/service/sshgate/hostkey/hostkey.go new file mode 100644 index 000000000000..c0d7e6a79dd1 --- /dev/null +++ b/service/sshgate/hostkey/hostkey.go @@ -0,0 +1,45 @@ +package hostkey + +import ( + "crypto/ed25519" + "crypto/sha256" + "fmt" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +var logger = log.WithField("component", "hostkey") + +// Load loads or generates a deterministic SSH host key based on seed +func Load(seed string) (ssh.Signer, error) { + // Generate a deterministic key based on SSH_HOST_KEY_SEED + // This ensures multiple replicas generate the same key + return GenerateDeterministicKey(seed) +} + +// GenerateDeterministicKey generates a deterministic ed25519 key from a seed string +func GenerateDeterministicKey(seed string) (ssh.Signer, error) { + // Use SHA256 of seed as the ed25519 seed (32 bytes) + hash := sha256.Sum256([]byte(seed)) + + // Generate ed25519 key from seed + privateKey := ed25519.NewKeyFromSeed(hash[:]) + + signer, err := ssh.NewSignerFromKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create signer: %w", err) + } + + // Log the public key fingerprint for verification + publicKey := signer.PublicKey() + fingerprint := ssh.FingerprintSHA256(publicKey) + logger.WithField("fingerprint", fingerprint).Info("Host key generated") + + return signer, nil +} + +// GetFingerprint returns the SHA256 fingerprint of the host key +func GetFingerprint(signer ssh.Signer) string { + return ssh.FingerprintSHA256(signer.PublicKey()) +} diff --git a/service/sshgate/hostkey/hostkey_test.go b/service/sshgate/hostkey/hostkey_test.go new file mode 100644 index 000000000000..29375ff49a65 --- /dev/null +++ b/service/sshgate/hostkey/hostkey_test.go @@ -0,0 +1,130 @@ +package hostkey_test + +import ( + "strings" + "testing" + + "github.com/labring/sealos/service/sshgate/hostkey" + "golang.org/x/crypto/ssh" +) + +func TestGenerateDeterministicKey(t *testing.T) { + seed := "test-seed-123" + + // Generate key twice with same seed + signer1, err := hostkey.GenerateDeterministicKey(seed) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + signer2, err := hostkey.GenerateDeterministicKey(seed) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + // Keys should be identical + fp1 := ssh.FingerprintSHA256(signer1.PublicKey()) + fp2 := ssh.FingerprintSHA256(signer2.PublicKey()) + + if fp1 != fp2 { + t.Errorf("Fingerprints don't match: %s != %s", fp1, fp2) + } + + // Verify fingerprint format + if !strings.HasPrefix(fp1, "SHA256:") { + t.Errorf("Fingerprint doesn't start with SHA256:, got %s", fp1) + } +} + +func TestGenerateDeterministicKeyDifferentSeeds(t *testing.T) { + signer1, err := hostkey.GenerateDeterministicKey("seed1") + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + signer2, err := hostkey.GenerateDeterministicKey("seed2") + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + fp1 := ssh.FingerprintSHA256(signer1.PublicKey()) + fp2 := ssh.FingerprintSHA256(signer2.PublicKey()) + + if fp1 == fp2 { + t.Error("Different seeds produced same fingerprint") + } +} + +func TestLoad(t *testing.T) { + // Test with custom seed + seed := "test-seed" + + signer, err := hostkey.Load(seed) + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if signer == nil { + t.Fatal("Load() returned nil signer") + } + + // Verify it's a valid key + fp := ssh.FingerprintSHA256(signer.PublicKey()) + if !strings.HasPrefix(fp, "SHA256:") { + t.Errorf("Invalid fingerprint format: %s", fp) + } +} + +func TestLoadDefaultSeed(t *testing.T) { + // Test with default seed + seed := "sealos-devbox" + + signer1, err := hostkey.Load(seed) + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + // Load again to verify consistency + signer2, err := hostkey.Load(seed) + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + fp1 := ssh.FingerprintSHA256(signer1.PublicKey()) + fp2 := ssh.FingerprintSHA256(signer2.PublicKey()) + + if fp1 != fp2 { + t.Errorf("Default seed produced different keys: %s != %s", fp1, fp2) + } +} + +func TestGetFingerprint(t *testing.T) { + signer, err := hostkey.GenerateDeterministicKey("test") + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + fp := hostkey.GetFingerprint(signer) + if !strings.HasPrefix(fp, "SHA256:") { + t.Errorf("GetFingerprint() returned invalid format: %s", fp) + } + + // Verify it matches the expected fingerprint + expected := ssh.FingerprintSHA256(signer.PublicKey()) + if fp != expected { + t.Errorf("GetFingerprint() = %s, want %s", fp, expected) + } +} + +func TestKeyType(t *testing.T) { + signer, err := hostkey.GenerateDeterministicKey("test") + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + // Verify it's an ed25519 key + pubKey := signer.PublicKey() + if pubKey.Type() != "ssh-ed25519" { + t.Errorf("Key type = %s, want ssh-ed25519", pubKey.Type()) + } +} diff --git a/service/sshgate/informer/errors.go b/service/sshgate/informer/errors.go new file mode 100644 index 000000000000..807e91cf6417 --- /dev/null +++ b/service/sshgate/informer/errors.go @@ -0,0 +1,6 @@ +package informer + +import "errors" + +// ErrCacheSyncFailed is returned when informer cache sync fails +var ErrCacheSyncFailed = errors.New("failed to sync informer caches") diff --git a/service/sshgate/informer/informer.go b/service/sshgate/informer/informer.go new file mode 100644 index 000000000000..72f099cd3fc2 --- /dev/null +++ b/service/sshgate/informer/informer.go @@ -0,0 +1,226 @@ +package informer + +import ( + "context" + "fmt" + "time" + + "github.com/labring/sealos/service/sshgate/registry" + log "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" +) + +// Manager manages Kubernetes informers for the gateway +type Manager struct { + clientset kubernetes.Interface + registry *registry.Registry + resyncPeriod time.Duration + factory informers.SharedInformerFactory + cancel context.CancelFunc + logger *log.Entry +} + +// Option configures the informer manager +type Option func(*Manager) + +// WithResyncPeriod sets the resync period for the informers +func WithResyncPeriod(d time.Duration) Option { + return func(m *Manager) { + m.resyncPeriod = d + } +} + +// New creates a new informer manager +func New(clientset kubernetes.Interface, reg *registry.Registry, opts ...Option) *Manager { + m := &Manager{ + clientset: clientset, + registry: reg, + resyncPeriod: 30 * time.Second, // Default value + logger: log.WithField("component", "informer"), + } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// Start initializes and starts all informers +func (m *Manager) Start(ctx context.Context) error { + // Create a cancellable context for the informer lifecycle + ctx, m.cancel = context.WithCancel(ctx) + + // Create informer factory + m.factory = informers.NewSharedInformerFactory(m.clientset, m.resyncPeriod) + + // Setup secret informer + secretInformer := m.factory.Core().V1().Secrets().Informer() + + _, err := secretInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: m.handleSecretAdd, + UpdateFunc: m.handleSecretUpdate, + DeleteFunc: m.handleSecretDelete, + }) + if err != nil { + return err + } + + // Setup pod informer + podInformer := m.factory.Core().V1().Pods().Informer() + + _, err = podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: m.handlePodAdd, + UpdateFunc: m.handlePodUpdate, + DeleteFunc: m.handlePodDelete, + }) + if err != nil { + return err + } + + // Start informers + m.factory.Start(ctx.Done()) + + // Wait for cache sync + if !cache.WaitForCacheSync(ctx.Done(), secretInformer.HasSynced, podInformer.HasSynced) { + return ErrCacheSyncFailed + } + + m.logger.Info("Informers synced successfully") + + return nil +} + +// Stop stops all informers +func (m *Manager) Stop() { + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + + if m.factory != nil { + m.factory.Shutdown() + } +} + +// IsStarted returns true if the manager has been started and factory is initialized +func (m *Manager) IsStarted() bool { + return m.factory != nil +} + +// ProcessSecret processes a secret (for testing) +func (m *Manager) ProcessSecret(secret *corev1.Secret, action string) error { + switch action { + case "add": + m.handleSecretAdd(secret) + case "update": + m.handleSecretUpdate(nil, secret) + case "delete": + m.handleSecretDelete(secret) + default: + return fmt.Errorf("unknown action: %s", action) + } + + return nil +} + +// ProcessPod processes a pod (for testing) +func (m *Manager) ProcessPod(pod *corev1.Pod, action string) error { + switch action { + case "add", "update": + m.handlePodAdd(pod) + case "delete": + m.handlePodDelete(pod) + default: + return fmt.Errorf("unknown action: %s", action) + } + + return nil +} + +// Event handlers for secrets +func (m *Manager) handleSecretAdd(obj any) { + secret, ok := obj.(*corev1.Secret) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", obj)).Error("Expected *corev1.Secret") + return + } + + if err := m.registry.AddSecret(nil, secret); err != nil { + m.logger.WithError(err).Error("Error adding secret") + } +} + +func (m *Manager) handleSecretUpdate(oldObj, newObj any) { + var oldSecret *corev1.Secret + if oldObj != nil { + var ok bool + + oldSecret, ok = oldObj.(*corev1.Secret) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", oldObj)). + Error("Expected *corev1.Secret for old object") + return + } + } + + newSecret, ok := newObj.(*corev1.Secret) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", newObj)). + Error("Expected *corev1.Secret for new object") + return + } + + if err := m.registry.AddSecret(oldSecret, newSecret); err != nil { + m.logger.WithError(err).Error("Error updating secret") + } +} + +func (m *Manager) handleSecretDelete(obj any) { + secret, ok := obj.(*corev1.Secret) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", obj)).Error("Expected *corev1.Secret") + return + } + + m.registry.DeleteSecret(secret) +} + +// Event handlers for pods +func (m *Manager) handlePodAdd(obj any) { + pod, ok := obj.(*corev1.Pod) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", obj)).Error("Expected *corev1.Pod") + return + } + + if err := m.registry.UpdatePod(pod); err != nil { + m.logger.WithError(err).Error("Error adding pod") + } +} + +func (m *Manager) handlePodUpdate(_, newObj any) { + pod, ok := newObj.(*corev1.Pod) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", newObj)).Error("Expected *corev1.Pod") + return + } + + if err := m.registry.UpdatePod(pod); err != nil { + m.logger.WithError(err).Error("Error updating pod") + } +} + +func (m *Manager) handlePodDelete(obj any) { + pod, ok := obj.(*corev1.Pod) + if !ok { + m.logger.WithField("type", fmt.Sprintf("%T", obj)).Error("Expected *corev1.Pod") + return + } + + m.registry.DeletePod(pod) +} diff --git a/service/sshgate/informer/informer_test.go b/service/sshgate/informer/informer_test.go new file mode 100644 index 000000000000..1dbd493c72a4 --- /dev/null +++ b/service/sshgate/informer/informer_test.go @@ -0,0 +1,422 @@ +package informer_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "testing" + "time" + + "github.com/labring/sealos/service/sshgate/informer" + "github.com/labring/sealos/service/sshgate/registry" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +func generateTestKeys(t *testing.T) ([]byte, []byte) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("Failed to create SSH public key: %v", err) + } + + pubBytes := ssh.MarshalAuthorizedKey(sshPub) + + privPEM, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("Failed to marshal private key: %v", err) + } + + privBytes := pem.EncodeToMemory(privPEM) + + return pubBytes, privBytes +} + +func TestNew(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + + mgr := informer.New(clientset, reg) + + if mgr == nil { + t.Fatal("New() returned nil") + } +} + +func TestProcessSecretAdd(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + mgr := informer.New(clientset, reg) + + pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + // Process secret add + if err := mgr.ProcessSecret(secret, "add"); err != nil { + t.Fatalf("ProcessSecret failed: %v", err) + } + + // Verify secret was added to registry + pubKey, _, _, _, _ := ssh.ParseAuthorizedKey(pubBytes) + + info, ok := reg.GetByPublicKey(pubKey) + if !ok { + t.Fatal("Secret was not added to registry") + } + + if info.DevboxName != "test-devbox" { + t.Errorf("DevboxName = %s, want test-devbox", info.DevboxName) + } +} + +func TestProcessSecretUpdate(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + mgr := informer.New(clientset, reg) + + pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + // Process secret update + if err := mgr.ProcessSecret(secret, "update"); err != nil { + t.Fatalf("ProcessSecret failed: %v", err) + } + + // Verify secret was updated in registry + pubKey, _, _, _, _ := ssh.ParseAuthorizedKey(pubBytes) + + _, ok := reg.GetByPublicKey(pubKey) + if !ok { + t.Fatal("Secret was not updated in registry") + } +} + +func TestProcessSecretDelete(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + mgr := informer.New(clientset, reg) + + pubBytes, privBytes := generateTestKeys(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + // Add first + if err := mgr.ProcessSecret(secret, "add"); err != nil { + t.Fatalf("ProcessSecret failed: %v", err) + } + + pubKey, _, _, _, _ := ssh.ParseAuthorizedKey(pubBytes) + + // Verify it exists + _, ok := reg.GetByPublicKey(pubKey) + if !ok { + t.Fatal("Secret was not added") + } + + // Delete + if err := mgr.ProcessSecret(secret, "delete"); err != nil { + t.Fatalf("ProcessSecret failed: %v", err) + } + + // Verify it's deleted + _, ok = reg.GetByPublicKey(pubKey) + if ok { + t.Error("Secret was not deleted from registry") + } +} + +func TestProcessPodAdd(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + mgr := informer.New(clientset, reg) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + } + + // Process pod add + if err := mgr.ProcessPod(pod, "add"); err != nil { + t.Fatalf("ProcessPod failed: %v", err) + } + + // Verify pod was added + info, ok := reg.GetDevboxInfo("test-ns", "test-devbox") + if !ok { + t.Fatal("DevboxInfo not found after ProcessPod") + } + + if info.PodIP != "10.0.0.1" { + t.Errorf("PodIP = %s, want 10.0.0.1", info.PodIP) + } +} + +func TestProcessPodUpdate(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + mgr := informer.New(clientset, reg) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.2", + }, + } + + // Process pod update + if err := mgr.ProcessPod(pod, "update"); err != nil { + t.Fatalf("ProcessPod failed: %v", err) + } + + // Verify pod was updated + info, ok := reg.GetDevboxInfo("test-ns", "test-devbox") + if !ok { + t.Fatal("DevboxInfo not found after ProcessPod") + } + + if info.PodIP != "10.0.0.2" { + t.Errorf("PodIP = %s, want 10.0.0.2", info.PodIP) + } +} + +func TestStart(t *testing.T) { + // Create fake clientset with reactor for list operations + clientset := fake.NewSimpleClientset() + + // Add reactor to handle list operations + clientset.PrependReactor( + "list", + "secrets", + func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, &corev1.SecretList{}, nil + }, + ) + clientset.PrependReactor( + "list", + "pods", + func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, &corev1.PodList{}, nil + }, + ) + + reg := registry.New() + mgr := informer.New(clientset, reg) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Start in goroutine + errCh := make(chan error, 1) + go func() { + errCh <- mgr.Start(ctx) + }() + + // Wait for start to complete or timeout + select { + case err := <-errCh: + if err != nil { + t.Errorf("Start() failed: %v", err) + } + case <-time.After(3 * time.Second): + t.Error("Start() timed out") + } + + // Verify manager is started + if !mgr.IsStarted() { + t.Error("Manager not started after Start()") + } +} + +func TestStop(t *testing.T) { + clientset := fake.NewSimpleClientset() + reg := registry.New() + mgr := informer.New(clientset, reg) + + // Test that Stop doesn't panic when manager is not started + mgr.Stop() + + // Start the manager + ctx := context.Background() + + if err := mgr.Start(ctx); err != nil { + t.Fatalf("Failed to start manager: %v", err) + } + + // Test that Stop doesn't panic when manager is started + mgr.Stop() + + // Test that multiple Stop calls don't panic + mgr.Stop() +} + +func TestStartWithExistingResources(t *testing.T) { + // Test that manager can handle pre-existing resources + pubBytes, privBytes := generateTestKeys(t) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + } + + // Create clientset with pre-existing resources + clientset := fake.NewSimpleClientset(secret, pod) + + // Add reactors for list operations + clientset.PrependReactor( + "list", + "secrets", + func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, &corev1.SecretList{Items: []corev1.Secret{*secret}}, nil + }, + ) + clientset.PrependReactor( + "list", + "pods", + func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, &corev1.PodList{Items: []corev1.Pod{*pod}}, nil + }, + ) + + reg := registry.New() + mgr := informer.New(clientset, reg) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Start manager + err := mgr.Start(ctx) + if err != nil { + t.Errorf("Start() failed: %v", err) + } + + // Verify manager is started + if !mgr.IsStarted() { + t.Error("Manager not started after Start()") + } +} diff --git a/service/sshgate/logger/logger.go b/service/sshgate/logger/logger.go new file mode 100644 index 000000000000..99a2080f2cf2 --- /dev/null +++ b/service/sshgate/logger/logger.go @@ -0,0 +1,101 @@ +package logger + +import ( + stdlog "log" + "os" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +// Options holds logger configuration options +type Options struct { + Debug bool + Level string + Format string +} + +// Option is a function that configures Options +type Option func(*Options) + +// WithDebug enables or disables debug mode +func WithDebug(debug bool) Option { + return func(o *Options) { + o.Debug = debug + } +} + +// WithLevel sets the log level (debug, info, warn, error) +func WithLevel(level string) Option { + return func(o *Options) { + o.Level = level + } +} + +// WithFormat sets the log format (text or json) +func WithFormat(format string) Option { + return func(o *Options) { + o.Format = format + } +} + +// InitLog initializes the logger with the given options +func InitLog(opts ...Option) { + // Default options + options := &Options{ + Debug: false, + Level: "info", + Format: "text", + } + + // Apply provided options + for _, opt := range opts { + opt(options) + } + + l := log.StandardLogger() + + // Set log level based on configuration + level := strings.ToLower(options.Level) + switch level { + case "debug": + l.SetLevel(log.DebugLevel) + case "info": + l.SetLevel(log.InfoLevel) + case "warn": + l.SetLevel(log.WarnLevel) + case "error": + l.SetLevel(log.ErrorLevel) + default: + l.SetLevel(log.InfoLevel) + } + + // Enable caller reporting in debug mode + if options.Debug || level == "debug" { + l.SetReportCaller(true) + } else { + l.SetReportCaller(false) + } + + l.SetOutput(os.Stdout) + stdlog.SetOutput(l.Writer()) + + // Set formatter based on configuration + if options.Format == "json" { + l.SetFormatter(&log.JSONFormatter{ + TimestampFormat: time.DateTime, + }) + } else { + l.SetFormatter(&log.TextFormatter{ + ForceColors: true, + DisableColors: false, + ForceQuote: options.Debug, + DisableQuote: !options.Debug, + DisableSorting: false, + FullTimestamp: true, + TimestampFormat: time.DateTime, + QuoteEmptyFields: true, + }) + } +} diff --git a/service/sshgate/main.go b/service/sshgate/main.go new file mode 100644 index 000000000000..15ead05901ff --- /dev/null +++ b/service/sshgate/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "log" + "net" + "os" + "time" + _ "time/tzdata" + + "github.com/labring/sealos/service/sshgate/config" + "github.com/labring/sealos/service/sshgate/gateway" + "github.com/labring/sealos/service/sshgate/hostkey" + "github.com/labring/sealos/service/sshgate/informer" + "github.com/labring/sealos/service/sshgate/logger" + "github.com/labring/sealos/service/sshgate/pprof" + "github.com/labring/sealos/service/sshgate/registry" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func init() { + tz := os.Getenv("TZ") + if tz != "" { + return + } + + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + panic(err) + } + + time.Local = loc +} + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize logger with configuration + logger.InitLog( + logger.WithDebug(cfg.Debug), + logger.WithLevel(cfg.LogLevel), + logger.WithFormat(cfg.LogFormat), + ) + + // Start pprof server if enabled + if cfg.PprofEnabled { + go func() { + _ = pprof.RunPprofServer(cfg.PprofPort) + }() + } + + // Create Kubernetes client + clientset, err := createKubernetesClient() + if err != nil { + log.Fatalf("Failed to create Kubernetes client: %v", err) + } + + // Create devbox registry + reg := registry.New() + + // Setup and start informers + infMgr := informer.New(clientset, reg, + informer.WithResyncPeriod(cfg.InformerResyncPeriod), + ) + + ctx := context.Background() + if err := infMgr.Start(ctx); err != nil { + log.Fatalf("Failed to start informers: %v", err) + } + + // Load SSH server host key + hostKey, err := hostkey.Load(cfg.SSHHostKeySeed) + if err != nil { + log.Fatalf("Failed to load host key: %v", err) + } + + // Create gateway with embedded options + gw := gateway.New(hostKey, reg, gateway.WithOptions(cfg.Gateway)) + + // Start SSH server + //nolint:noctx + listener, err := net.Listen("tcp", cfg.SSHListenAddr) + if err != nil { + log.Fatal(err) + } + + log.Printf("SSH Gateway listening on %s", cfg.SSHListenAddr) + + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("Accept error: %v", err) + continue + } + + go gw.HandleConnection(conn) + } +} + +// createKubernetesClient creates a Kubernetes clientset +func createKubernetesClient() (*kubernetes.Clientset, error) { + // Try in-cluster config first + config, err := rest.InClusterConfig() + if err != nil { + // Fallback to kubeconfig + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = os.Getenv("HOME") + "/.kube/config" + } + + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, err + } + } + + return kubernetes.NewForConfig(config) +} diff --git a/service/sshgate/pprof/pprof.go b/service/sshgate/pprof/pprof.go new file mode 100644 index 000000000000..05cdfcccc6bb --- /dev/null +++ b/service/sshgate/pprof/pprof.go @@ -0,0 +1,39 @@ +package pprof + +import ( + "fmt" + "net" + "net/http" + //nolint:gosec + _ "net/http/pprof" + "time" + + "github.com/sirupsen/logrus" +) + +var pprofMux *http.ServeMux + +func init() { + pprofMux = http.DefaultServeMux + http.DefaultServeMux = http.NewServeMux() +} + +// RunPprofServer starts the pprof server on 127.0.0.1:port +func RunPprofServer(port int) error { + addr := fmt.Sprintf("127.0.0.1:%d", port) + server := http.Server{ + Addr: addr, + Handler: pprofMux, + ReadHeaderTimeout: time.Second * 5, + } + + //nolint:noctx + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + logrus.WithField("component", "pprof").Infof("pprof listening on %s", ln.Addr()) + + return server.Serve(ln) +} diff --git a/service/sshgate/registry/registry.go b/service/sshgate/registry/registry.go new file mode 100644 index 000000000000..f387d62c223c --- /dev/null +++ b/service/sshgate/registry/registry.go @@ -0,0 +1,261 @@ +package registry + +import ( + "bytes" + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // DevboxPublicKeyField is the secret data field containing the public key + DevboxPublicKeyField = "SEALOS_DEVBOX_PUBLIC_KEY" + // DevboxPrivateKeyField is the secret data field containing the private key + DevboxPrivateKeyField = "SEALOS_DEVBOX_PRIVATE_KEY" + // DevboxPartOfLabel is the label key for identifying devbox resources + DevboxPartOfLabel = "app.kubernetes.io/part-of" + // DevboxPartOfValue is the expected label value for devbox resources + DevboxPartOfValue = "devbox" + // DevboxOwnerKind is the owner reference kind for devbox resources + DevboxOwnerKind = "Devbox" +) + +// DevboxInfo stores information about a devbox +type DevboxInfo struct { + Namespace string + DevboxName string + PodIP string + PublicKey ssh.PublicKey + PrivateKey ssh.Signer +} + +// Registry manages the mapping between SSH public keys and devbox pods +type Registry struct { + mu sync.RWMutex + // publicKey (string) -> namespace/devboxName + publicKeyToNamespaceDevbox map[string]string + // namespace/devboxName -> DevboxInfo + devboxToInfo map[string]*DevboxInfo + logger *log.Entry +} + +// New creates a new Registry instance +func New() *Registry { + return &Registry{ + publicKeyToNamespaceDevbox: make(map[string]string), + devboxToInfo: make(map[string]*DevboxInfo), + logger: log.WithField("component", "registry"), + } +} + +// AddSecret processes a Secret and adds it to the registry. +// If old is provided, it will clean up stale data from the old secret. +func (r *Registry) AddSecret(oldSecret, newSecret *corev1.Secret) error { + // Check if this is a devbox secret + if newSecret.Labels[DevboxPartOfLabel] != DevboxPartOfValue { + return nil + } + + // Get public key from secret + publicKeyData, ok := newSecret.Data[DevboxPublicKeyField] + if !ok { + return fmt.Errorf( + "secret %s/%s missing %s", + newSecret.Namespace, + newSecret.Name, + DevboxPublicKeyField, + ) + } + + // Get first line of public key data + firstLine := bytes.SplitN(publicKeyData, []byte("\n"), 2)[0] + + // Parse public key + publicKey, _, _, _, err := ssh.ParseAuthorizedKey(firstLine) + if err != nil { + return fmt.Errorf("failed to parse public key: %w", err) + } + + // Get devbox name from ownerReferences + devboxName := getDevboxNameFromOwnerReferences(newSecret.OwnerReferences) + if devboxName == "" { + return fmt.Errorf("secret %s/%s has no Devbox owner", newSecret.Namespace, newSecret.Name) + } + + // Parse private key if available + var privateKey ssh.Signer + if privateKeyData, ok := newSecret.Data[DevboxPrivateKeyField]; ok { + privateKey, err = ssh.ParsePrivateKey(privateKeyData) + if err != nil { + r.logger.WithFields(log.Fields{ + "namespace": newSecret.Namespace, + "devbox": devboxName, + }).WithError(err).Warn("Failed to parse private key") + } + } + + devboxKey := fmt.Sprintf("%s/%s", newSecret.Namespace, devboxName) + pubKeyStr := string(publicKey.Marshal()) + + r.logger.WithFields(log.Fields{ + "namespace": newSecret.Namespace, + "devbox": devboxName, + }).Info("Adding secret") + + r.mu.Lock() + defer r.mu.Unlock() + + // Clean up old public key mapping if old secret provided + if oldSecret != nil { + if oldKeyData, ok := oldSecret.Data[DevboxPublicKeyField]; ok { + oldFirstLine := bytes.SplitN(oldKeyData, []byte("\n"), 2)[0] + if oldPubKey, _, _, _, err := ssh.ParseAuthorizedKey(oldFirstLine); err == nil { + oldPubKeyStr := string(oldPubKey.Marshal()) + if oldPubKeyStr != pubKeyStr { + delete(r.publicKeyToNamespaceDevbox, oldPubKeyStr) + } + } + } + } + + info, exists := r.devboxToInfo[devboxKey] + if !exists { + info = &DevboxInfo{ + Namespace: newSecret.Namespace, + DevboxName: devboxName, + } + r.devboxToInfo[devboxKey] = info + } + + info.PublicKey = publicKey + info.PrivateKey = privateKey + r.publicKeyToNamespaceDevbox[pubKeyStr] = devboxKey + + return nil +} + +// DeleteSecret removes a Secret from the registry +func (r *Registry) DeleteSecret(secret *corev1.Secret) { + devboxName := getDevboxNameFromOwnerReferences(secret.OwnerReferences) + if devboxName == "" { + return + } + + key := fmt.Sprintf("%s/%s", secret.Namespace, devboxName) + r.logger.WithFields(log.Fields{ + "namespace": secret.Namespace, + "devbox": devboxName, + }).Info("Removing secret") + + r.mu.Lock() + defer r.mu.Unlock() + + if info, ok := r.devboxToInfo[key]; ok { + if info.PublicKey != nil { + delete(r.publicKeyToNamespaceDevbox, string(info.PublicKey.Marshal())) + } + + delete(r.devboxToInfo, key) + } +} + +// UpdatePod updates the pod IP for a devbox. +func (r *Registry) UpdatePod(pod *corev1.Pod) error { + // Check if this is a devbox pod + if pod.Labels[DevboxPartOfLabel] != DevboxPartOfValue { + return nil + } + + // Get devbox name from ownerReferences + devboxName := getDevboxNameFromOwnerReferences(pod.OwnerReferences) + if devboxName == "" { + return fmt.Errorf("pod %s/%s has no Devbox owner", pod.Namespace, pod.Name) + } + + key := fmt.Sprintf("%s/%s", pod.Namespace, devboxName) + + r.logger.WithFields(log.Fields{ + "namespace": pod.Namespace, + "devbox": devboxName, + "pod_ip": pod.Status.PodIP, + }).Info("Updating pod IP") + + r.mu.Lock() + defer r.mu.Unlock() + + info, exists := r.devboxToInfo[key] + if !exists { + info = &DevboxInfo{ + Namespace: pod.Namespace, + DevboxName: devboxName, + } + r.devboxToInfo[key] = info + } + + // Update PodIP even if empty (pod may be restarting) + info.PodIP = pod.Status.PodIP + + return nil +} + +// DeletePod removes a pod from the registry +func (r *Registry) DeletePod(pod *corev1.Pod) { + devboxName := getDevboxNameFromOwnerReferences(pod.OwnerReferences) + if devboxName == "" { + return + } + + key := fmt.Sprintf("%s/%s", pod.Namespace, devboxName) + r.logger.WithFields(log.Fields{ + "namespace": pod.Namespace, + "devbox": devboxName, + }).Info("Removing pod IP") + + r.mu.Lock() + defer r.mu.Unlock() + + if info, ok := r.devboxToInfo[key]; ok { + info.PodIP = "" + } +} + +// GetByPublicKey retrieves DevboxInfo by SSH public key +func (r *Registry) GetByPublicKey(publicKey ssh.PublicKey) (*DevboxInfo, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + key, ok := r.publicKeyToNamespaceDevbox[string(publicKey.Marshal())] + if !ok { + return nil, false + } + + info, ok := r.devboxToInfo[key] + + return info, ok +} + +// GetDevboxInfo retrieves DevboxInfo by namespace and devbox name +func (r *Registry) GetDevboxInfo(namespace, devboxName string) (*DevboxInfo, bool) { + key := fmt.Sprintf("%s/%s", namespace, devboxName) + + r.mu.RLock() + defer r.mu.RUnlock() + + info, ok := r.devboxToInfo[key] + + return info, ok +} + +func getDevboxNameFromOwnerReferences(refs []metav1.OwnerReference) string { + for _, ref := range refs { + if ref.Kind == DevboxOwnerKind { + return ref.Name + } + } + + return "" +} diff --git a/service/sshgate/registry/registry_test.go b/service/sshgate/registry/registry_test.go new file mode 100644 index 000000000000..87617e799424 --- /dev/null +++ b/service/sshgate/registry/registry_test.go @@ -0,0 +1,349 @@ +package registry_test + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "testing" + + "github.com/labring/sealos/service/sshgate/registry" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func generateTestKeyPair(t *testing.T) (ssh.PublicKey, []byte, []byte) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("Failed to create SSH public key: %v", err) + } + + // Marshal keys for storage in secret + pubBytes := ssh.MarshalAuthorizedKey(sshPub) + + privPEM, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("Failed to marshal private key: %v", err) + } + + privBytes := pem.EncodeToMemory(privPEM) + + return sshPub, pubBytes, privBytes +} + +func TestNew(t *testing.T) { + r := registry.New() + if r == nil { + t.Fatal("New() returned nil") + } +} + +func TestAddSecret(t *testing.T) { + r := registry.New() + pubKey, pubBytes, privBytes := generateTestKeyPair(t) + + tests := []struct { + name string + secret *corev1.Secret + wantErr bool + }{ + { + name: "valid secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + }, + wantErr: false, + }, + { + name: "secret without devbox label", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-secret", + Namespace: "test-ns", + Labels: map[string]string{}, + }, + }, + wantErr: false, // Should skip without error + }, + { + name: "secret missing public key", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bad-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + }, + Data: map[string][]byte{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.AddSecret(nil, tt.secret) + if (err != nil) != tt.wantErr { + t.Errorf("AddSecret() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + // Verify the valid secret was added + info, ok := r.GetByPublicKey(pubKey) + if !ok { + t.Fatal("Failed to get devbox by public key") + } + + if info.Namespace != "test-ns" { + t.Errorf("Namespace = %s, want test-ns", info.Namespace) + } + + if info.DevboxName != "test-devbox" { + t.Errorf("DevboxName = %s, want test-devbox", info.DevboxName) + } +} + +func TestDeleteSecret(t *testing.T) { + r := registry.New() + _, pubBytes, privBytes := generateTestKeyPair(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + // Add secret + if err := r.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + // Delete secret + r.DeleteSecret(secret) + + // Verify it's deleted + pubKey, _, _, _, _ := ssh.ParseAuthorizedKey(pubBytes) + if _, ok := r.GetByPublicKey(pubKey); ok { + t.Error("Secret was not deleted from registry") + } +} + +func TestUpdatePod(t *testing.T) { + r := registry.New() + + tests := []struct { + name string + pod *corev1.Pod + wantErr bool + wantIP string + }{ + { + name: "valid pod with IP", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + }, + wantErr: false, + wantIP: "10.0.0.1", + }, + { + name: "pod without IP", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pending-pod", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "pending-devbox", + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "", + }, + }, + wantErr: false, + wantIP: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.UpdatePod(tt.pod) + if (err != nil) != tt.wantErr { + t.Errorf("UpdatePod() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantIP != "" { + // Verify the pod IP was updated using public API + info, ok := r.GetDevboxInfo("test-ns", "test-devbox") + if !ok { + t.Error("DevboxInfo not found after UpdatePod") + } else if info.PodIP != tt.wantIP { + t.Errorf("PodIP = %s, want %s", info.PodIP, tt.wantIP) + } + } + }) + } +} + +func TestGetByPublicKey(t *testing.T) { + r := registry.New() + pubKey, pubBytes, privBytes := generateTestKeyPair(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + if err := r.AddSecret(nil, secret); err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + // Test getting existing public key + info, ok := r.GetByPublicKey(pubKey) + if !ok { + t.Fatal("GetByPublicKey() returned false for existing public key") + } + + if info.DevboxName != "test-devbox" { + t.Errorf("DevboxName = %s, want test-devbox", info.DevboxName) + } + + // Test getting non-existent public key + otherPubKey, _, _ := generateTestKeyPair(t) + + _, ok = r.GetByPublicKey(otherPubKey) + if ok { + t.Error("GetByPublicKey() returned true for non-existent public key") + } +} + +func TestConcurrentAccess(t *testing.T) { + r := registry.New() + _, pubBytes, privBytes := generateTestKeyPair(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + Labels: map[string]string{ + registry.DevboxPartOfLabel: registry.DevboxPartOfValue, + }, + OwnerReferences: []metav1.OwnerReference{ + { + Kind: registry.DevboxOwnerKind, + Name: "test-devbox", + }, + }, + }, + Data: map[string][]byte{ + registry.DevboxPublicKeyField: pubBytes, + registry.DevboxPrivateKeyField: privBytes, + }, + } + + // Concurrent writes + done := make(chan bool, 10) + for range 10 { + go func() { + if err := r.AddSecret(nil, secret); err != nil { + t.Errorf("AddSecret failed: %v", err) + } + + done <- true + }() + } + + // Wait for all goroutines + for range 10 { + <-done + } + + // Concurrent reads + pubKey, _, _, _, _ := ssh.ParseAuthorizedKey(pubBytes) + + for range 10 { + go func() { + r.GetByPublicKey(pubKey) + + done <- true + }() + } + + for range 10 { + <-done + } +}