diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 59bc948..0000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,30 +0,0 @@ -name-template: 'v$RESOLVED_VERSION' -tag-template: 'v$RESOLVED_VERSION' - -template: | - ## General Changes - - $CHANGES - -categories: -- title: '🚀 Features' - labels: - - 'feature' - - 'enhancement' -- title: '🐛 Bug Fixes' - labels: - - 'fix' - - 'bugfix' - - 'bug' - -version-resolver: - major: - labels: - - 'major' - minor: - labels: - - 'minor' - patch: - labels: - - 'patch' - default: patch diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..12f8a6f --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,23 @@ +--- +name: metal-stack component release + +on: + pull_request: + branches: + - master + release: + types: + - published + push: + branches: + - master + +jobs: + draft: + uses: metal-stack/actions-common/.github/workflows/release-drafter.yaml@v1 + + go-build: + uses: metal-stack/actions-common/.github/workflows/go-build.yaml@v1 + secrets: inherit + with: + build-command: make console diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml deleted file mode 100644 index f30f4d9..0000000 --- a/.github/workflows/docker.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: Docker Build Action - -on: - pull_request: - branches: - - master - push: - branches: - - master - release: - types: - - published - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Docker Login - uses: docker/login-action@v3 - with: - registry: ${{ secrets.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_REGISTRY_USER }} - password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} - if: ${{ github.event_name != 'pull_request' }} - - - name: Set up Go 1.25 - uses: actions/setup-go@v5 - with: - go-version: '1.25.x' - - - name: Lint - uses: golangci/golangci-lint-action@v8 - - - name: Make tag - run: | - [ "${GITHUB_EVENT_NAME}" == 'pull_request' ] && echo "tag=pr-${GITHUB_HEAD_REF##*/}" >> $GITHUB_ENV || true - [ "${GITHUB_EVENT_NAME}" == 'release' ] && echo "tag=${GITHUB_REF##*/}" >> $GITHUB_ENV || true - [ "${GITHUB_EVENT_NAME}" == 'push' ] && echo "tag=latest" >> $GITHUB_ENV || true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - sbom: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.tag }} diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml deleted file mode 100644 index f0e8eae..0000000 --- a/.github/workflows/release-drafter.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Release Drafter Action - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100755 index 63418fe..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing - -Please check out the [contributing section](https://docs.metal-stack.io/stable/development/contributing/) in our [docs](https://docs.metal-stack.io/). \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index de60ce0..0763bfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM golang:1.25 AS builder +FROM golang:1.27 AS builder WORKDIR /work COPY . . RUN make -FROM gcr.io/distroless/static-debian12:nonroot +FROM gcr.io/distroless/static-debian13:nonroot COPY --from=builder /work/bin/metal-console / CMD ["/metal-console"] diff --git a/README.md b/README.md index 62562d6..4a33df4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,24 @@ If the machine uuid is a valid machine, it will then use the provided private ke `metal-console` figures out in which partition the machine is located and then opens a tls socket connection to `metal-bmc` running on the management server in this partition. `metal-bmc` checks if the tls client certificate matches. If this is the case, it looks up the machine ipmi details from `metal-api` and starts a ipmi sol session to the machine. +## Configuration + +The `metal-console` can be configured through environment variables. +Every configuration needs to be prefixed with `METAL_CONSOLE_`. + +All configuration options can be found in the implementation [internal/console/spec.go](./internal/console/spec.go). + +## Possible access patterns + +| machine state | who wants access | allowed | how is access granted | +|--------------------|------------------|---------|-----------------------------------------------------------------------------------------------------------------------------| +| waiting machine | end user | no | only admins can connect to waiting machines | +| waiting machine | admin editor | yes | provided token is checked if it contains admin editor rights | +| allocated machine | end user | yes | allowed if token rights allow fetching this machine and provided ssh keys match publickeys stored in the machine allocation | +| allocated machine | admin editor | yes | provided token is checked if it contains admin editor rights | +| allocated firewall | end user | no | denied because only admins are allowed to connect to firewall | +| allocated firewall | admin editor | yes | provided token is checked if it contains admin editor rights | + ## TODO - If a second console access starts to same machine, kill existing one diff --git a/go.mod b/go.mod index 38e4e10..79c6dcf 100644 --- a/go.mod +++ b/go.mod @@ -1,63 +1,77 @@ module github.com/metal-stack/metal-console -go 1.25.4 +go 1.27 require ( - github.com/gliderlabs/ssh v0.3.8 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/kelseyhightower/envconfig v1.4.0 - github.com/metal-stack/metal-go v0.42.0 - github.com/metal-stack/metal-lib v0.23.3 + github.com/metal-stack/api v0.5.5 + github.com/metal-stack/metal-go v0.45.0 + github.com/metal-stack/metal-lib v0.26.3 github.com/metal-stack/v v1.0.3 - github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.54.0 + github.com/stretchr/testify v1.12.1 + github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 + golang.org/x/crypto v0.56.0 ) require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + connectrpc.com/connect v1.20.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coreos/go-oidc/v3 v3.15.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/coreos/go-oidc/v3 v3.21.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.5 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.2 // indirect - github.com/go-openapi/jsonpointer v0.21.2 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/loads v0.22.0 // indirect - github.com/go-openapi/runtime v0.28.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/strfmt v0.23.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect - github.com/go-openapi/validate v0.24.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/go-openapi/analysis v1.0.0 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.1 // indirect + github.com/go-openapi/jsonreference v1.0.2 // indirect + github.com/go-openapi/loads v0.25.3 // indirect + github.com/go-openapi/runtime v0.33.2 // indirect + github.com/go-openapi/runtime/server-middleware v0.33.2 // indirect + github.com/go-openapi/spec v1.0.1 // indirect + github.com/go-openapi/strfmt v0.27.2 // indirect + github.com/go-openapi/swag v0.29.1 // indirect + github.com/go-openapi/swag/cmdutils v0.29.1 // indirect + github.com/go-openapi/swag/conv v0.29.1 // indirect + github.com/go-openapi/swag/fileutils v0.29.1 // indirect + github.com/go-openapi/swag/jsonutils v0.29.1 // indirect + github.com/go-openapi/swag/loading v0.29.1 // indirect + github.com/go-openapi/swag/mangling v0.29.1 // indirect + github.com/go-openapi/swag/netutils v0.29.1 // indirect + github.com/go-openapi/swag/pools v0.29.1 // indirect + github.com/go-openapi/swag/stringutils v0.29.1 // indirect + github.com/go-openapi/swag/typeutils v0.29.1 // indirect + github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-openapi/validate v1.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/connect-compress/v2 v2.1.1 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect - github.com/mailru/easyjson v0.9.0 // indirect - github.com/metal-stack/security v0.9.4 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/oklog/ulid v1.3.1 // indirect - github.com/opentracing/opentracing-go v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/segmentio/asm v1.2.0 // indirect - go.mongodb.org/mongo-driver v1.17.7 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect + github.com/lestrrat-go/jwx/v3 v3.2.0 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/metal-stack/security v0.9.7 // indirect + github.com/minio/minlz v1.2.0 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.10 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.16.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index cb573d5..fc4de34 100644 --- a/go.sum +++ b/go.sum @@ -1,133 +1,160 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 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/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= -github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +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/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA= +github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= -github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= -github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= -github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= -github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= -github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= -github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= -github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -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 v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= +github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= +github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= +github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= +github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= +github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= +github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= +github.com/go-openapi/runtime v0.33.2 h1:HSxskMs0WmpCdQvBWVxHt2t2mXMwn8DDav3VtVidwig= +github.com/go-openapi/runtime v0.33.2/go.mod h1:NQpSLiIsEAIpZDEs6xUrhjm6ZiQh85X8bHKlqLPgNP8= +github.com/go-openapi/runtime/server-middleware v0.33.2 h1:BVFjAaW4Jh/kZ4QSsgDrGzLuSuaKzSiyvappUFKxgUk= +github.com/go-openapi/runtime/server-middleware v0.33.2/go.mod h1:E3mWY61/UgBJ5EUmKh+h6A1oQQdD1U4raowVMmPmQug= +github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= +github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= +github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= +github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= +github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ= +github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA= +github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY= +github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= +github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= +github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0= +github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI= +github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= +github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= +github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= +github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= +github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= +github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= +github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4= +github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno= +github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= +github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= +github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= +github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= +github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= +github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= +github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= +github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= +github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= +github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v1.0.0 h1:dFsYCLVUQUL6Vi2lQSexgwmCXDuHe7eWRDhQxkE+xYA= +github.com/go-openapi/validate v1.0.0/go.mod h1:wwXGRqMQzOZ7PCqBcgNk+DD9+Cacnxv7we5T0M/eA3Y= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -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/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/connect-compress/v2 v2.1.1 h1:ycZNp4rWOZBodVE2Ls5AzK4aHkyK+GteEfzRZgKNs+c= +github.com/klauspost/connect-compress/v2 v2.1.1/go.mod h1:9oilsPHJMzGKkjafSBk9J7iVo4mO+dw0G0KSdVpnlVE= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= +github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/metal-stack/metal-go v0.42.0 h1:IccaJufN8WeNbBYLI06A22vgM/EGfWW8dxQ0lNZvFkg= -github.com/metal-stack/metal-go v0.42.0/go.mod h1:T0lbyrc+aHlI/GWAu2Og4Fmj4oaj5RD55736jBG0R8Y= -github.com/metal-stack/metal-lib v0.23.3 h1:ZcouyuCXy2bMZn+CB0yx1JdAl8SEkfjmlzNLyrmD/kA= -github.com/metal-stack/metal-lib v0.23.3/go.mod h1:29JMehA4KSeDovG9y1xgQkdc5pAk2/MBvOufRNSk5Bc= -github.com/metal-stack/security v0.9.4 h1:qgB4Yx48Wgi57a/ePWE1gdUn4RjilVySQAFRVIoPKBU= -github.com/metal-stack/security v0.9.4/go.mod h1:Qya3sKnaZ+1VmayGcMGgCBsVhzBvWYYx9hcvinfXB1Q= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA= +github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/metal-stack/api v0.5.5 h1:AzPjTKi8Y4XNiJaEcRDCAjuOmIaLuuOEhKXVWliTtWc= +github.com/metal-stack/api v0.5.5/go.mod h1:U8c+awSMxXaRJjzWpo1IMcvlNxOi1ewu/fk1BzDM7hI= +github.com/metal-stack/metal-go v0.45.0 h1:X5BCppdE3RYNScfwQxv9JTehyFIv6LhVzhbG25k27KQ= +github.com/metal-stack/metal-go v0.45.0/go.mod h1:GSfXrAj55LGsUSMHWGDsmq5n056NG0yb1JM8bgfvKOw= +github.com/metal-stack/metal-lib v0.26.3 h1:K5gLoD65m6p3l6qCPrfavIdvNdWfmF2QdXrvU2URaZs= +github.com/metal-stack/metal-lib v0.26.3/go.mod h1:cNXjPBs8SFnjqfBobuSbm5mDk6E/jS8PVeIOrV/7POE= +github.com/metal-stack/security v0.9.7 h1:JAKj/01gIyO4rY7DrrIGBK6X+27uvMX+XPgW15U2d7g= +github.com/metal-stack/security v0.9.7/go.mod h1:6DFjP2KjdwrXF2aboQDrleo6/dZlGXoYtrV/YioKoFY= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= github.com/metal-stack/v v1.0.3/go.mod h1:YTahEu7/ishwpYKnp/VaW/7nf8+PInogkfGwLcGPdXg= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/minio/minlz v1.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A= +github.com/minio/minlz v1.2.0/go.mod h1:Ls9H7nlkASeCcdl5thjVD5Eraj6z+zGa7xtq57jIKD4= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= -go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= -go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/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/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= diff --git a/internal/console/metal.go b/internal/console/metal.go new file mode 100644 index 0000000..799d0bf --- /dev/null +++ b/internal/console/metal.go @@ -0,0 +1,78 @@ +package console + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/golang-jwt/jwt/v5" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" +) + +const ( + // oidcTokenEnv environment variable passed through ssh to forward the token + oidcTokenEnv = "LC_METAL_STACK_OIDC_TOKEN" + // projectEnv environment variable passed through ssh to forward the metal project + projectEnv = "LC_METAL_STACK_PROJECT" +) + +type ( + machine struct { + id string + role apiv2.MachineAllocationType + allocated bool + managementServerAddresses []string + sshPublicKeys []string + createdAt time.Time + } +) + +type metal interface { + getMachine(ctx context.Context, machineID string) (*machine, error) + // checkIsAuthenticated returns true if the user is admin, an error is returned of authentication failed + checkIsAuthenticated(context.Context) (bool, error) +} + +func newMetal(log *slog.Logger, token, project string, spec Specification) (metal, error) { + isV2Token, err := isV2TokenType(log, token) + if err != nil { + return nil, err + } + + if isV2Token { + return newV2(log, spec.MetalAPIServerURL, token, project) + } else { + return newV1(log, spec.MetalAPIURL, token, spec.AdminGroupName) + } +} + +func isV2TokenType(log *slog.Logger, token string) (bool, error) { + claims := &jwt.MapClaims{} + parser := jwt.NewParser() + // using parseUnverified because result is not used for authorization only for inspection + _, _, err := parser.ParseUnverified(token, claims) + if err != nil { + return false, err + } + + log.Debug("isV2Token", "token", claims) + + // APIv2 Token must contain either: + // "type": "TOKEN_TYPE_API" + // "type": "TOKEN_TYPE_USER" + + // APIv1 Token must contain a "roles" slice + for k, v := range *claims { + switch k { + case "type": + if v == apiv2.TokenType_TOKEN_TYPE_API.String() || v == apiv2.TokenType_TOKEN_TYPE_USER.String() { + return true, nil + } + case "roles": + return false, nil + } + } + + return false, fmt.Errorf("unable to detect token api version from claims: %v", claims) +} diff --git a/internal/console/metal_test.go b/internal/console/metal_test.go new file mode 100644 index 0000000..6fade6b --- /dev/null +++ b/internal/console/metal_test.go @@ -0,0 +1,66 @@ +package console + +import ( + "io" + "log/slog" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func mustToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test-secret")) + require.NoError(t, err) + return tok +} + +func TestIsV2TokenType(t *testing.T) { + discard := slog.New(slog.NewTextHandler(io.Discard, nil)) + + tests := []struct { + name string + token string + want bool + wantErr bool + }{ + { + name: "v2 api token", + token: mustToken(t, jwt.MapClaims{"type": "TOKEN_TYPE_API"}), + want: true, + }, + { + name: "v2 user token", + token: mustToken(t, jwt.MapClaims{"type": "TOKEN_TYPE_USER"}), + want: true, + }, + { + name: "v1 token with roles", + token: mustToken(t, jwt.MapClaims{"roles": []string{"role-a"}}), + want: false, + }, + { + name: "no recognizable claim", + token: mustToken(t, jwt.MapClaims{"foo": "bar"}), + wantErr: true, + }, + { + name: "unparsable token", + token: "not-a-valid-jwt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := isV2TokenType(discard, tt.token) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/console/metalv1.go b/internal/console/metalv1.go new file mode 100644 index 0000000..a2f0d4f --- /dev/null +++ b/internal/console/metalv1.go @@ -0,0 +1,87 @@ +package console + +import ( + "context" + "fmt" + "log/slog" + "slices" + "time" + + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + metalgo "github.com/metal-stack/metal-go" + metalmachine "github.com/metal-stack/metal-go/api/client/machine" + "github.com/metal-stack/metal-go/api/client/user" + "github.com/metal-stack/metal-go/api/models" + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +type metalv1 struct { + log *slog.Logger + client metalgo.Client + adminGroupName string + token string + isadmin bool +} + +func newV1(log *slog.Logger, metalapiv1Url, token, adminGroupName string) (metal, error) { + if token == "" { + return nil, fmt.Errorf("unable to find OIDC token stored in %s env variable which is required for machine console access", oidcTokenEnv) + } + + metal, err := metalgo.NewDriver(metalapiv1Url, token, "") + if err != nil { + return nil, fmt.Errorf("failed to create metal client: %w", err) + } + return &metalv1{ + log: log, + client: metal, + token: token, + adminGroupName: adminGroupName, + }, nil +} + +func (m *metalv1) getMachine(ctx context.Context, machineID string) (*machine, error) { + resp, err := m.client.Machine().FindMachine(metalmachine.NewFindMachineParams().WithID(machineID).WithContext(ctx), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch requested machine %s %w", machineID, err) + } + + var role apiv2.MachineAllocationType + if resp.Payload != nil && resp.Payload.Allocation != nil && resp.Payload.Allocation.Role != nil { + switch *resp.Payload.Allocation.Role { + case models.V1MachineAllocationRoleMachine: + role = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE + case models.V1MachineAllocationRoleFirewall: + role = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL + } + } + if resp.Payload == nil { + return nil, fmt.Errorf("machine is nil") + } + ms := resp.Payload + + return &machine{ + id: pointer.SafeDeref(ms.ID), + role: role, + allocated: ms.Allocation != nil, + managementServerAddresses: []string{pointer.SafeDeref(ms.Partition).Mgmtserviceaddress}, + sshPublicKeys: pointer.SafeDeref(ms.Allocation).SSHPubKeys, + createdAt: time.Time(pointer.SafeDeref(pointer.SafeDeref(ms.Allocation).Created)), + }, nil +} + +func (m *metalv1) checkIsAuthenticated(ctx context.Context) (bool, error) { + + user, err := m.client.User().GetMe(user.NewGetMeParams().WithContext(ctx), nil) + if err != nil { + m.log.Error("failed to fetch user details from oidc token", "error", err, "token", m.token) + return false, fmt.Errorf("given oidc token is invalid") + } + + if slices.Contains(user.Payload.Groups, m.adminGroupName) { + m.isadmin = true + return true, nil + } + + return false, nil +} diff --git a/internal/console/metalv2.go b/internal/console/metalv2.go new file mode 100644 index 0000000..6789f19 --- /dev/null +++ b/internal/console/metalv2.go @@ -0,0 +1,85 @@ +package console + +import ( + "context" + "fmt" + "log/slog" + + "github.com/metal-stack/api/go/client" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +type metalv2 struct { + log *slog.Logger + client client.Client + token string + project string + isadmin bool +} + +func newV2(log *slog.Logger, baseUrl, token, project string) (metal, error) { + if token == "" { + return nil, fmt.Errorf("unable to find OIDC token stored in %s env variable which is required for machine console access", oidcTokenEnv) + } + + client, err := client.New(&client.DialConfig{ + BaseURL: baseUrl, + Token: token, + }) + if err != nil { + return nil, fmt.Errorf("failed to create metal-apiserver client: %w", err) + } + return &metalv2{ + log: log, + client: client, + token: token, + project: project, + }, nil +} + +func (m *metalv2) getMachine(ctx context.Context, machineID string) (*machine, error) { + var ms *apiv2.Machine + if m.project == "" || m.isadmin { + resp, err := m.client.Adminv2().Machine().Get(ctx, &adminv2.MachineServiceGetRequest{ + Uuid: machineID, + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch requested machine %s %w", machineID, err) + } + ms = resp.Machine + } else { + resp, err := m.client.Apiv2().Machine().Get(ctx, &apiv2.MachineServiceGetRequest{ + Uuid: machineID, + Project: m.project, + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch requested machine %s %w", machineID, err) + } + ms = resp.Machine + } + return &machine{ + id: ms.Uuid, + role: pointer.SafeDeref(ms.Allocation).AllocationType, + allocated: ms.Allocation != nil, + managementServerAddresses: pointer.SafeDeref(ms.Partition).MgmtServiceAddresses, + sshPublicKeys: pointer.SafeDeref(ms.Allocation).SshPublicKeys, + createdAt: pointer.SafeDeref(pointer.SafeDeref(ms.Allocation).Meta).CreatedAt.AsTime(), + }, nil +} + +func (m *metalv2) checkIsAuthenticated(ctx context.Context) (bool, error) { + resp, err := m.client.Apiv2().Method().TokenScopedList(ctx, &apiv2.MethodServiceTokenScopedListRequest{}) + if err != nil { + m.log.Error("failed to fetch user details from oidc token", "error", err) + return false, err + } + + if pointer.SafeDeref(resp.AdminRole) == apiv2.AdminRole_ADMIN_ROLE_EDITOR { + m.isadmin = true + return true, nil + } + + return false, nil +} diff --git a/internal/console/server.go b/internal/console/server.go index ce58dda..21d676a 100644 --- a/internal/console/server.go +++ b/internal/console/server.go @@ -12,43 +12,54 @@ import ( "sync" "time" - "github.com/golang-jwt/jwt/v5" - metalgo "github.com/metal-stack/metal-go" - "github.com/metal-stack/metal-go/api/client/machine" - "github.com/metal-stack/metal-go/api/client/user" - "github.com/metal-stack/metal-go/api/models" - "github.com/metal-stack/metal-lib/pkg/pointer" - - "github.com/gliderlabs/ssh" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + ssh "github.com/tailscale/gliderssh" + gossh "golang.org/x/crypto/ssh" ) type consoleServer struct { log *slog.Logger - client metalgo.Client spec *Specification createdAts *sync.Map + + // newMetal creates the metal adapter for a session. It is a field to allow + // test injection of a mock adapter. + newMetal func(log *slog.Logger, token, project string, spec Specification) (metal, error) + // connectMachine establishes the connection to the machine's serial console and + // returns a cleanup func and the machine-side ssh session. It is a field to allow + // test injection. + connectMachine func(mgmtServiceAddress, machineID string) (func(), *gossh.Session, error) } -func NewServer(log *slog.Logger, spec *Specification, client metalgo.Client) *consoleServer { - return &consoleServer{ +func NewServer(log *slog.Logger, spec *Specification) *consoleServer { + cs := &consoleServer{ log: log, - client: client, spec: spec, createdAts: new(sync.Map), } + cs.newMetal = newMetal + cs.connectMachine = cs.realConnectMachine + return cs } // Run starts ssh server and listen for console connections. func (cs *consoleServer) Run() error { s := &ssh.Server{ - Addr: fmt.Sprintf(":%d", cs.spec.Port), - Handler: cs.sessionHandler, - PublicKeyHandler: cs.publicKeyHandler, - PasswordHandler: cs.passwordHandler, + Addr: fmt.Sprintf(":%d", cs.spec.Port), + Handler: cs.sessionHandler, + // does not have access to the token, must be called to be able to store the publicKey in the session + // which will be then checked against the stored publickey in the machine by sessionHandler + PublicKeyHandler: cs.noopPublicKeyHandler, + Banner: "metal-stack.io console server\n", + } + + serverKey, err := os.ReadFile(cs.spec.PrivateKeyFile) + if err != nil { + return fmt.Errorf("failed to load private host key:%w", err) } - hostKey, err := loadHostKey() + hostKey, err := gossh.ParsePrivateKey(serverKey) if err != nil { return fmt.Errorf("failed to load host key %w", err) } @@ -62,107 +73,112 @@ func (cs *consoleServer) Run() error { return nil } -const oidcEnv = "LC_METAL_STACK_OIDC_TOKEN" - func (cs *consoleServer) sessionHandler(s ssh.Session) { - machineID := s.User() + var ( + machineID = s.User() + token, project = tokenAndProjectFromSessionEnv(s) + ) - resp, err := cs.client.Machine().FindMachine(machine.NewFindMachineParams().WithID(machineID), nil) - if err != nil || resp == nil || resp.Payload == nil { - cs.log.Error("failed to fetch requested machine", "machineID", machineID, "error", err) - cs.exitSession(s) + metal, err := cs.newMetal(cs.log, token, project, *cs.spec) + if err != nil { + cs.log.Error("error constructing metal adapter", "error", err) + cs.exitSession(s, err) return } - var ( - m = resp.Payload - role = pointer.SafeDeref(pointer.SafeDeref(m.Allocation).Role) - isAdmin = false - token = oidcTokenFromSessionEnv(s) - ) - - if role != models.V1MachineAllocationRoleMachine || s.PublicKey() == nil { - // If the machine is a not a regular machine, i.e. a firewall, or an admin wants access to an arbitrary machine - // check if the ssh session contains the oidc token and the user is member of admin group - // ssh client can pass environment variables, but only environment variables starting with LC_ are passed - // OIDC token must be stored in LC_METAL_STACK_OIDC_TOKEN - var claims jwt.Claims - claims, err = cs.checkIsAdmin(token) - if err != nil { - cs.log.Error("prevented admin access to a machine console", "machineID", machineID, "role", role, "claims", claims, "from", s.RemoteAddr(), "error", err) - _, _ = io.WriteString(s, err.Error()+"\n") - cs.exitSession(s) - return - } + isAdmin, err := metal.checkIsAuthenticated(s.Context()) + if err != nil { + cs.log.Error("check for authentication failed", "error", err) + cs.exitSession(s, err) + return + } - isAdmin = true + machine, err := metal.getMachine(s.Context(), machineID) + if err != nil { + cs.log.Error("failed to fetch machine", "error", err) + cs.exitSession(s, err) + return + } - cs.log.Info("allowed admin access to a machine console", "machineID", machineID, "role", role, "claims", claims, "from", s.RemoteAddr()) - } else { - _, claims, err := cs.checkIsAuthenticatedUser(token) - if err != nil { - cs.log.Error("prevented user access to a machine console", "machineID", machineID, "role", role, "claims", claims, "from", s.RemoteAddr(), "error", err) - _, _ = io.WriteString(s, err.Error()+"\n") - cs.exitSession(s) + if !isAdmin { + if err := cs.checkAuthorizedKeys(*machine, s.PublicKey()); err != nil { + cs.log.Error("public key does not match", "error", err) + cs.exitSession(s, err) return } - - cs.log.Info("allowed user access to a machine", "machineID", machineID, "role", role, "claims", claims, "from", s.RemoteAddr()) } - mgmtServiceAddress := m.Partition.Mgmtserviceaddress + cs.createdAts.Store(machineID, machine.createdAt.String()) + defer cs.createdAts.Delete(machineID) - if cs.spec.DevMode() { - mgmtServiceAddress = cs.spec.BmcReverseProxyAddress + if !isAdmin && machine.role == apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL { + // If the machine is a not a regular machine, i.e. a firewall, or an admin wants access to an arbitrary machine + // check if the ssh session contains the oidc token and the user is member of admin group + // ssh client can pass environment variables, but only environment variables starting with LC_ are passed + // OIDC token must be stored in LC_METAL_STACK_OIDC_TOKEN + cs.log.Error("prevented non admin access to a firewall console", "machineID", machineID, "role", machine.role, "from", s.RemoteAddr()) + cs.exitSession(s, fmt.Errorf("only admins can access firewall console")) + return } - tcpConn, err := cs.connectToManagementNetwork(mgmtServiceAddress) - if err != nil { - cs.log.Error("failed to connect to management network", "error", err) + mgmtServiceAddresses := machine.managementServerAddresses + if len(mgmtServiceAddresses) == 0 { + cs.log.Error("failed to connect to management network, no management server address given") + cs.exitSession(s, err) return } - sshConn, sshClient, sshSession, err := cs.connectSSH(tcpConn, mgmtServiceAddress, machineID) + // TODO try all available addresses round robin + mgmtServiceAddress := mgmtServiceAddresses[0] + + cleanup, sshSession, err := cs.connectMachine(mgmtServiceAddress, machineID) if err != nil { - cs.log.Error("failed to establish SSH connection via already established TCP connection", "error", err) + cs.log.Error("failed to connect to machine console", "error", err) return } - defer func() { - _ = tcpConn.Close() - _ = sshSession.Close() - _ = sshClient.Close() - _ = sshConn.Close() - }() - - cs.requestPTY(sshSession) + defer cleanup() - done := make(chan bool) + resize := cs.requestPTY(s, sshSession) - cs.redirectIO(s, sshSession, done) + wait := cs.redirectIO(s, sshSession) if !isAdmin { // check periodically if the session is still allowed. // admins don't need to be disconnected from machines - go cs.terminateIfPublicKeysChanged(s) + go cs.terminateIfPublicKeysChanged(s, metal) } - err = sshSession.Start("bash") - if err != nil { + if err := sshSession.Start("bash"); err != nil { cs.log.Error("failed to start bash via SSH session", "error", err) return } - // wait till connection is closed - <-done + // forward terminal window-size changes from the caller to the machine console + if resize != nil { + go func() { + for w := range resize { + if w.Width == 0 || w.Height == 0 { + continue + } + if err := sshSession.WindowChange(w.Height, w.Width); err != nil { + cs.log.Debug("failed to forward window size change", "error", err) + return + } + } + }() + } + + // wait till all io is done + wait() } -func (cs *consoleServer) terminateIfPublicKeysChanged(s ssh.Session) { +func (cs *consoleServer) terminateIfPublicKeysChanged(s ssh.Session, metal metal) { machineID := s.User() createdAt, ok := cs.createdAts.Load(machineID) if !ok { _, _ = io.WriteString(s, "machine allocation not known, terminating console session\n") cs.log.Info("machine allocation not known, terminating ssh session", "machineID", machineID) - cs.exitSession(s) + cs.exitSession(s, fmt.Errorf("machine allocation not known, terminating ssh session")) return } @@ -176,93 +192,126 @@ func (cs *consoleServer) terminateIfPublicKeysChanged(s ssh.Session) { return case <-ticker.C: cs.log.Info("checking if machine is still owned by the same user", "machineID", machineID) - - m, err := cs.client.Machine().FindMachine(machine.NewFindMachineParams().WithID(machineID), nil) + // we must use adminv2 because otherwise project must be passed which is not known here + m, err := metal.getMachine(s.Context(), machineID) if err != nil { cs.log.Error("unable to load machine", "machineID", machineID, "error", err) continue } - if m.Payload.Allocation == nil { + if !m.allocated { _, _ = io.WriteString(s, "machine is not allocated anymore, terminating console session\n") cs.log.Info("machine is not allocated anymore, terminating ssh session", "machineID", machineID) - cs.exitSession(s) + cs.exitSession(s, fmt.Errorf("machine is not allocated anymore, terminating ssh session")) return } - if createdAt != m.Payload.Allocation.Created.String() { + if createdAt != m.createdAt.String() { _, _ = io.WriteString(s, "machine allocation changed, terminating console session\n") - cs.log.Info("machine allocation changed, terminating ssh session", "machineID", machineID, "old-ts", createdAt, "new-ts", m.Payload.Allocation.Created.String()) - cs.exitSession(s) + cs.log.Info("machine allocation changed, terminating ssh session", "machineID", machineID, "old-ts", createdAt, "new-ts", m.createdAt.String()) + cs.exitSession(s, fmt.Errorf("machine allocation changed, terminating ssh session")) return } } } } -func (cs *consoleServer) exitSession(session ssh.Session) { - err := session.Exit(1) - if err != nil { +func (cs *consoleServer) exitSession(session ssh.Session, err error) { + _, _ = io.WriteString(session, err.Error()+"\n") + if err := session.Exit(1); err != nil { cs.log.Error("failed to exit SSH session", "error", err) } } -func (cs *consoleServer) redirectIO(callerSSHSession ssh.Session, machineSSHSession *gossh.Session, done chan<- bool) { +func (cs *consoleServer) redirectIO(callerSSHSession ssh.Session, machineSSHSession *gossh.Session) func() { + var wg sync.WaitGroup + stdin, err := machineSSHSession.StdinPipe() if err != nil { cs.log.Error("failed to fetch stdin for SSH session", "error", err) } else { - go func() { - _, err = io.Copy(stdin, callerSSHSession) - if err != nil && !errors.Is(err, io.EOF) { - cs.log.Error("failed to copy caller stdin to machine", "error", err) + wg.Go(func() { + _, copyErr := io.Copy(stdin, callerSSHSession) + if copyErr != nil && !errors.Is(copyErr, io.EOF) { + cs.log.Error("failed to copy caller stdin to machine", "error", copyErr) } - - done <- true - }() + }) } stdout, err := machineSSHSession.StdoutPipe() if err != nil { cs.log.Error("failed to fetch stdout for SSH session", "error", err) } else { - go func() { - _, err = io.Copy(callerSSHSession, stdout) - if err != nil && !errors.Is(err, io.EOF) { - cs.log.Error("failed to copy machine stdout to caller", "error", err) + wg.Go(func() { + _, copyErr := io.Copy(callerSSHSession, stdout) + if copyErr != nil && !errors.Is(copyErr, io.EOF) { + cs.log.Error("failed to copy machine stdout to caller", "error", copyErr) } - - done <- true - }() + }) } stderr, err := machineSSHSession.StderrPipe() if err != nil { cs.log.Error("failed to fetch stderr for SSH session", "error", err) } else { - go func() { - _, err = io.Copy(callerSSHSession, stderr) - if err != nil && !errors.Is(err, io.EOF) { - cs.log.Error("failed to copy machine stderr to caller", "error", err) + wg.Go(func() { + _, copyErr := io.Copy(callerSSHSession, stderr) + if copyErr != nil && !errors.Is(copyErr, io.EOF) { + cs.log.Error("failed to copy machine stderr to caller", "error", copyErr) } - - done <- true - }() + }) } + + return wg.Wait } -func (cs *consoleServer) requestPTY(sshSession *gossh.Session) { +func (cs *consoleServer) requestPTY(callerSSHSession ssh.Session, machineSSHSession *gossh.Session) <-chan ssh.Window { + // use the caller's terminal geometry if a pty was requested, else a sensible default + pty, resize, ok := callerSSHSession.Pty() + win := ssh.Window{Width: 80, Height: 40} + if !ok { + resize = nil + } else if pty.Window.Width > 0 && pty.Window.Height > 0 { + win = pty.Window + } + modes := gossh.TerminalModes{ gossh.ECHO: 0, // disable echoing gossh.TTY_OP_ISPEED: 115200, // input speed in baud gossh.TTY_OP_OSPEED: 115200, // output speed in baud } - if err := sshSession.RequestPty("xterm", 80, 40, modes); err != nil { + if err := machineSSHSession.RequestPty("xterm", win.Height, win.Width, modes); err != nil { cs.log.Error("failed to request PTY", "error", err) } + return resize +} + +func (cs *consoleServer) realConnectMachine(mgmtServiceAddress, machineID string) (func(), *gossh.Session, error) { + tcpConn, err := cs.connectToManagementNetwork(mgmtServiceAddress) + if err != nil { + return nil, nil, err + } + + sshConn, sshClient, sshSession, err := cs.connectSSH(tcpConn, mgmtServiceAddress, machineID) + if err != nil { + _ = tcpConn.Close() + return nil, nil, err + } + + cleanup := func() { + _ = tcpConn.Close() + _ = sshSession.Close() + _ = sshClient.Close() + _ = sshConn.Close() + } + return cleanup, sshSession, nil } func (cs *consoleServer) connectSSH(tcpConn *tls.Conn, mgmtServiceAddress, machineID string) (gossh.Conn, *gossh.Client, *gossh.Session, error) { - pubHostKey, err := loadPublicHostKey() + bb, err := os.ReadFile(cs.spec.PublicKeyFile) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load public host key:%w", err) + } + pubHostKey, _, _, _, err := ssh.ParseAuthorizedKey(bb) if err != nil { cs.log.Error("failed to load public host key", "error", err) return nil, nil, nil, err @@ -291,15 +340,15 @@ func (cs *consoleServer) connectSSH(tcpConn *tls.Conn, mgmtServiceAddress, machi } func (cs *consoleServer) connectToManagementNetwork(mgmtServiceAddress string) (*tls.Conn, error) { - clientCert, err := tls.LoadX509KeyPair("/certs/client.pem", "/certs/client-key.pem") + clientCert, err := tls.LoadX509KeyPair(cs.spec.BMCCertFile, cs.spec.BMCKeyFile) if err != nil { - cs.log.Error("failed to load client certificate", "cert", "/certs/client.pem", "key", "/certs/client-key.pem", "error", err) + cs.log.Error("failed to load client certificate", "cert", cs.spec.BMCCertFile, "key", cs.spec.BMCKeyFile, "error", err) return nil, err } - caCert, err := os.ReadFile("/certs/ca.pem") + caCert, err := os.ReadFile(cs.spec.BMCCACertFile) if err != nil { - cs.log.Error("failed to load CA certificate", "cert", "/certs/ca.pem", "error", err) + cs.log.Error("failed to load CA certificate", "cert", cs.spec.BMCCACertFile, "error", err) return nil, err } caCertPool := x509.NewCertPool() @@ -325,145 +374,48 @@ func (cs *consoleServer) connectToManagementNetwork(mgmtServiceAddress string) ( return tcpConn, nil } -func (cs *consoleServer) publicKeyHandler(ctx ssh.Context, publicKey ssh.PublicKey) bool { - machineID := ctx.User() - - cs.log.Info("evaluating machine console access with public key access", "machineID", machineID, "publicKey", publicKey) - - knownAuthorizedKeys, err := cs.getAuthorizedKeysForMachine(machineID) - if err != nil { - cs.log.Error("abort establishment of console session", "machineID", machineID, "error", err) - return false - } - for _, key := range knownAuthorizedKeys { - same := ssh.KeysEqual(publicKey, key) - if same { - cs.log.Info("found matching public key for machine access", "machineID", machineID) - return true - } +func (cs *consoleServer) checkAuthorizedKeys(machine machine, publicKey ssh.PublicKey) error { + if publicKey == nil { + return fmt.Errorf("publicKey is nil") } - - cs.log.Warn("no matching authorized key found", "machineID", machineID) - - return false -} - -func (cs *consoleServer) getAuthorizedKeysForMachine(machineID string) ([]ssh.PublicKey, error) { - resp, err := cs.client.Machine().FindMachine(machine.NewFindMachineParams().WithID(machineID), nil) - if err != nil { - cs.log.Error("failed to fetch requested machine", "machineID", machineID, "error", err) - return nil, err - } - if resp.Payload == nil || resp.Payload.Allocation == nil { - cs.log.Error("requested machine is nil", "machineID", machineID) - return nil, fmt.Errorf("no machine found with id: %s", machineID) - } - alloc := resp.Payload.Allocation - - cs.createdAts.Store(machineID, alloc.Created.String()) - - if cs.spec.DevMode() { - bb, err := os.ReadFile(cs.spec.PublicKey) + for _, key := range machine.sshPublicKeys { + cs.log.Debug("check if public key matches", "machine key", key, "authorized key", publicKey.Type()) + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) if err != nil { - cs.log.Error("failed to read public key", "file", cs.spec.PublicKey) - return nil, err - } - alloc.SSHPubKeys = []string{ - string(bb), + return fmt.Errorf("error parsing public key:%w", err) } - } - - var pubKeys []ssh.PublicKey - for _, key := range alloc.SSHPubKeys { - pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) - if err != nil { - return nil, fmt.Errorf("error parsing public key:%w", err) + same := ssh.KeysEqual(publicKey, key) + if same { + cs.log.Info("found matching public key for machine access", "machineID", machine.id) + return nil } - pubKeys = append(pubKeys, pubKey) } - - return pubKeys, nil + return fmt.Errorf("no matching authorized key found for machineID:%s", machine.id) } -func loadHostKey() (gossh.Signer, error) { - bb, err := os.ReadFile("/certs/server-key.pem") - if err != nil { - return nil, fmt.Errorf("failed to load private host key:%w", err) - } - return gossh.ParsePrivateKey(bb) -} - -func loadPublicHostKey() (gossh.PublicKey, error) { - bb, err := os.ReadFile("/certs/server-key.pub") - if err != nil { - return nil, fmt.Errorf("failed to load public host key:%w", err) - } - pubKey, _, _, _, err := ssh.ParseAuthorizedKey(bb) - return pubKey, err -} - -func (cs *consoleServer) passwordHandler(ctx ssh.Context, password string) bool { - _, err := cs.checkIsAdmin(password) - if err != nil { - cs.log.Error("error evaluating if user is admin", "machineID", ctx.User(), "error", err) - return false - } - - return true +func (cs *consoleServer) noopPublicKeyHandler(ctx ssh.Context, publicKey ssh.PublicKey) error { + // This publicKeyHandler is only called to ensure the publicKey is stored in the ssh.Session. + // without a publicKeyHandler it is not stored in the session + machineID := ctx.User() + cs.log.Info("evaluating machine console access with public key access", "machineID", machineID) + return nil } -func oidcTokenFromSessionEnv(s ssh.Session) string { +func tokenAndProjectFromSessionEnv(s ssh.Session) (string, string) { + var ( + token string + project string + ) for _, env := range s.Environ() { - _, t, found := strings.Cut(env, oidcEnv+"=") - if found { - return t + _, t, tfound := strings.Cut(env, oidcTokenEnv+"=") + if tfound { + token = t } - } - - return "" -} - -func (cs *consoleServer) checkIsAuthenticatedUser(token string) (*models.V1User, jwt.Claims, error) { - if token == "" { - return nil, nil, fmt.Errorf("unable to find OIDC token stored in %s env variable which is required for machine console access", oidcEnv) - } - - claims := &jwt.MapClaims{} - _, _, err := new(jwt.Parser).ParseUnverified(token, claims) - if err != nil { - return nil, nil, fmt.Errorf("unable to parse jwt %q: %w", token, err) - } - - metal, err := metalgo.NewDriver(cs.spec.MetalAPIURL, token, "") - if err != nil { - return nil, claims, fmt.Errorf("failed to create metal client: %w", err) - } - - user, err := metal.User().GetMe(user.NewGetMeParams(), nil) - if err != nil { - cs.log.Error("failed to fetch user details from oidc token", "error", err, "token", token) - return nil, claims, fmt.Errorf("given oidc token is invalid") - } - - return user.Payload, claims, nil -} - -func (cs *consoleServer) checkIsAdmin(token string) (jwt.Claims, error) { - user, claims, err := cs.checkIsAuthenticatedUser(token) - if err != nil { - return claims, err - } - - isAdmin := false - for _, g := range user.Groups { - if g == cs.spec.AdminGroupName { - isAdmin = true - break + _, p, pfound := strings.Cut(env, projectEnv+"=") + if pfound { + project = p } } - if !isAdmin { - return claims, fmt.Errorf("you are not member of required admin group:%s to access this machine console", cs.spec.AdminGroupName) - } - return claims, nil + return token, project } diff --git a/internal/console/server_test.go b/internal/console/server_test.go index df4d74a..24a48e8 100644 --- a/internal/console/server_test.go +++ b/internal/console/server_test.go @@ -1,19 +1,327 @@ package console import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "io" + "log/slog" + "net" + "strings" + "sync/atomic" "testing" + "time" - "github.com/gliderlabs/ssh" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/stretchr/testify/require" + ssh "github.com/tailscale/gliderssh" + gossh "golang.org/x/crypto/ssh" ) -func TestLoadPublicHostKey(t *testing.T) { - // given - pubHostKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzH+R+UhjVicUtI0daNUcedYhfvgT1dbZXgY33Ibm4MOo+X84Iwuzirm3QFnYf2O3uyZjNyrA6fj9qFE7Ekul4bD6PCstQupXPwfPMjns2M7tkHsKnLYjNxWNql/rCUxoH2B6nPyztcRCass3lIc2clfXkCY9Jtf7kgC2e/dmchywPV5PrFqtlHgZUnyoPyWBH7OjPLVxYwtCJn96sFkrjaG9QDOeoeiNvcGlk4DJp/g9L4f2AaEq69x8+gBTFUqAFsD8ecO941cM8sa1167rsRPx7SK3270Ji5EUF3lZsgpaiIgMhtIB/7QNTkN9ZjQBazxxlNVN6WthF8okb7OSt" +type mockSession struct { + ssh.Session + env []string +} + +func (m *mockSession) Environ() []string { return m.env } + +func TestTokenAndProjectFromSessionEnv(t *testing.T) { + tests := []struct { + name string + env []string + wantToken string + wantProj string + }{ + { + name: "all values set", + env: []string{ + "LC_METAL_STACK_OIDC_TOKEN=abc123", + "LC_METAL_STACK_PROJECT=proj-1", + "PATH=/usr/bin", + }, + wantToken: "abc123", + wantProj: "proj-1", + }, + { + name: "only token", + env: []string{"LC_METAL_STACK_OIDC_TOKEN=xyz"}, + wantToken: "xyz", + }, + { + name: "only project", + env: []string{"LC_METAL_STACK_PROJECT=p-2"}, + wantProj: "p-2", + }, + { + name: "empty env", + env: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &mockSession{env: tt.env} + token, project := tokenAndProjectFromSessionEnv(s) + require.Equal(t, tt.wantToken, token) + require.Equal(t, tt.wantProj, project) + }) + } +} + +func newTestPublicKey(t *testing.T) ssh.PublicKey { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + signer, err := gossh.NewSignerFromKey(priv) + require.NoError(t, err) + return signer.PublicKey() +} + +func TestCheckAuthorizedKeys(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + cs := NewServer(logger, &Specification{}) + + pub := newTestPublicKey(t) + authorized := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub))) + + m := machine{ + id: "m-1", + sshPublicKeys: []string{authorized}, + } + + t.Run("matching key is accepted", func(t *testing.T) { + require.NoError(t, cs.checkAuthorizedKeys(m, pub)) + }) + + t.Run("non matching key is rejected", func(t *testing.T) { + other := newTestPublicKey(t) + require.Error(t, cs.checkAuthorizedKeys(m, other)) + }) + + t.Run("no keys on machine is rejected", func(t *testing.T) { + empty := machine{id: "m-2"} + require.Error(t, cs.checkAuthorizedKeys(empty, pub)) + }) - // when - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubHostKey)) + t.Run("invalid stored key is rejected", func(t *testing.T) { + invalid := machine{id: "m-3", sshPublicKeys: []string{"not-a-valid-ssh-key"}} + require.Error(t, cs.checkAuthorizedKeys(invalid, pub)) + }) +} + +// mockMetal is a metal adapter that records the token and machine it is given +// and returns a fixed, valid machine so sessionHandler can proceed. +type mockMetal struct { + token string + project string + + machineID atomic.Value // string + calls atomic.Int32 +} + +func (m *mockMetal) getMachine(_ context.Context, machineID string) (*machine, error) { + m.calls.Add(1) + m.machineID.Store(machineID) + return &machine{ + id: machineID, + role: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + allocated: true, + managementServerAddresses: []string{"machine.example.internal:2222"}, + sshPublicKeys: nil, + createdAt: time.Now(), + }, nil +} + +func (m *mockMetal) checkIsAuthenticated(context.Context) (bool, error) { + return false, nil +} - // then +// startMachineSSHServer runs a minimal ssh server that stands in for the +// machine's serial console. It accepts any connection, answers pty/env/exec +// requests and echoes stdin back to stdout. +func startMachineSSHServer(t *testing.T) (addr string, cleanup func()) { + t.Helper() + + hostKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + hostSigner, err := gossh.NewSignerFromKey(hostKey) require.NoError(t, err) + + config := &gossh.ServerConfig{ + PublicKeyCallback: func(gossh.ConnMetadata, gossh.PublicKey) (*gossh.Permissions, error) { + return &gossh.Permissions{}, nil + }, + PasswordCallback: func(gossh.ConnMetadata, []byte) (*gossh.Permissions, error) { + return &gossh.Permissions{}, nil + }, + } + config.AddHostKey(hostSigner) + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { + for { + nConn, err := l.Accept() + if err != nil { + return + } + go func(n net.Conn) { + _, chans, reqs, err := gossh.NewServerConn(n, config) + if err != nil { + _ = n.Close() + return + } + go gossh.DiscardRequests(reqs) + for newCh := range chans { + if newCh.ChannelType() != "session" { + _ = newCh.Reject(gossh.UnknownChannelType, "unsupported channel") + continue + } + ch, requests, err := newCh.Accept() + if err != nil { + continue + } + go func(in <-chan *gossh.Request) { + for req := range in { + ok := req.Type == "pty-req" || req.Type == "shell" || + req.Type == "exec" || req.Type == "env" + _ = req.Reply(ok, nil) + } + }(requests) + go func() { + _, _ = io.Copy(ch, ch) // echo + _, _ = ch.SendRequest("exit-status", false, gossh.Marshal(struct{ Status uint32 }{0})) + _ = ch.Close() + }() + } + }(nConn) + } + }() + + return l.Addr().String(), func() { _ = l.Close() } +} + +// consoleSSHServer starts a metal-console-style ssh.Server on a local listener +// (same handlers as Run) and returns its address. +func consoleSSHServer(t *testing.T, cs *consoleServer) (addr string, cleanup func()) { + t.Helper() + + hostKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + hostSigner, err := gossh.NewSignerFromKey(hostKey) + require.NoError(t, err) + + s := &ssh.Server{ + Handler: cs.sessionHandler, + PublicKeyHandler: cs.noopPublicKeyHandler, + Banner: "metal-stack.io console server\n", + } + s.AddHostKey(hostSigner) + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { + _ = s.Serve(l) + }() + + return l.Addr().String(), func() { _ = l.Close(); _ = s.Close() } +} + +func TestSSHSessionWithPrivateKeyAndTokenEnv(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + cs := NewServer(logger, &Specification{}) + + mock := &mockMetal{} + cs.newMetal = func(_ *slog.Logger, token, project string, _ Specification) (metal, error) { + mock.token = token + mock.project = project + return mock, nil + } + + machineAddr, machineCleanup := startMachineSSHServer(t) + defer machineCleanup() + + cs.connectMachine = func(_ string, machineID string) (func(), *gossh.Session, error) { + client, err := gossh.Dial("tcp", machineAddr, &gossh.ClientConfig{ + User: machineID, + Auth: []gossh.AuthMethod{gossh.Password("x")}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }) + if err != nil { + return nil, nil, err + } + session, err := client.NewSession() + if err != nil { + _ = client.Close() + return nil, nil, err + } + return func() { + _ = session.Close() + _ = client.Close() + }, session, nil + } + + consoleAddr, consoleCleanup := consoleSSHServer(t, cs) + defer consoleCleanup() + + // Client: authenticate with a private key and forward the oidc token and + // project via ssh environment variables. + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + clientSigner, err := gossh.NewSignerFromKey(clientKey) + require.NoError(t, err) + + client, err := gossh.Dial("tcp", consoleAddr, &gossh.ClientConfig{ + User: "test-machine-uuid", + Auth: []gossh.AuthMethod{gossh.PublicKeys(clientSigner)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }) + require.NoError(t, err) + defer func() { + _ = client.Close() + }() + + session, err := client.NewSession() + require.NoError(t, err) + defer func() { + _ = session.Close() + }() + + require.NoError(t, session.Setenv("LC_METAL_STACK_OIDC_TOKEN", "test-oidc-token")) + require.NoError(t, session.Setenv("LC_METAL_STACK_PROJECT", "test-project")) + + var stdout bytes.Buffer + session.Stdout = &stdout + session.Stderr = io.Discard + session.Stdin = strings.NewReader("") + + runDone := make(chan error, 1) + go func() { + runDone <- session.Run("") + }() + + // sessionHandler should receive the forwarded token/project env vars and + // call the mock metal's getMachine with the ssh username. + require.Eventually(t, func() bool { + return mock.calls.Load() >= 1 + }, 5*time.Second, 10*time.Millisecond) + + require.Equal(t, "test-oidc-token", mock.token) + require.Equal(t, "test-project", mock.project) + require.Equal(t, "test-machine-uuid", mock.machineID.Load()) + + // Closing the client session unblocks the io redirection in sessionHandler + // so it can return. The client-side session may report an exit error here + // because the session was closed mid-flight; that is expected and not part + // of what this test asserts. + _ = session.Close() + select { + case <-runDone: + case <-time.After(5 * time.Second): + t.Fatal("session.Run did not return after closing the session") + } } diff --git a/internal/console/spec.go b/internal/console/spec.go index e268fec..e9d046c 100644 --- a/internal/console/spec.go +++ b/internal/console/spec.go @@ -2,15 +2,23 @@ package console // Specification configures the application via environment. type Specification struct { - BindAddress string `default:"localhost"` - MetalAPIURL string `default:"http://localhost:8080" envconfig:"metal_api_url"` - Port int `default:"2222"` - HMACKey string `default:"" envconfig:"hmac_key"` - PublicKey string `default:"" split_words:"true"` - BmcReverseProxyAddress string `default:"" split_words:"true"` - AdminGroupName string `default:"maas-all-all-admin" envconfig:"admin_group_name" split_words:"true"` -} + // Port where to listen for ssh connections + Port int `default:"2222"` + + // SSH Keys + PrivateKeyFile string `default:"/certs/server-key.pem" envconfig:"private_key_file"` + PublicKeyFile string `default:"/certs/server-key.pub" envconfig:"public_key_file"` + + // metal-bmc server mtls keys + BMCCACertFile string `default:"/certs/ca.pem" envconfig:"bmc_ca_cert_file"` + BMCCertFile string `default:"/certs/client.pem" envconfig:"bmc_cert_file"` + BMCKeyFile string `default:"/certs/client-key.pem" envconfig:"bmc_key_file"` + + // metal-apiserver (v2) configuration items + MetalAPIServerURL string `default:"http://localhost:8080" envconfig:"metal_apiserver_url"` + TokenFile string `default:"" envconfig:"token_file"` -func (s *Specification) DevMode() bool { - return len(s.PublicKey) > 0 + // old metal-api based configuration items, can be removed once v2 migration is complete + MetalAPIURL string `default:"http://localhost:8080" envconfig:"metal_api_url"` + AdminGroupName string `default:"maas-all-all-admin" envconfig:"admin_group_name" split_words:"true"` } diff --git a/main.go b/main.go index 74e9112..acea373 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,14 @@ package main import ( + "context" "log/slog" "os" + "time" + apiclient "github.com/metal-stack/api/go/client" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/metal-stack/metal-console/internal/console" - metalgo "github.com/metal-stack/metal-go" "github.com/kelseyhightower/envconfig" "github.com/metal-stack/v" @@ -14,25 +17,40 @@ import ( func main() { spec := &console.Specification{} - jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{}) + jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) log := slog.New(jsonHandler) err := envconfig.Process("METAL_CONSOLE", spec) if err != nil { log.Error("failed to read env config", "error", err) - panic(err) + os.Exit(1) } - // FIXME metal-view is enough - client, err := metalgo.NewDriver(spec.MetalAPIURL, "", spec.HMACKey) + apiv2client, err := apiclient.New(&apiclient.DialConfig{ + BaseURL: spec.MetalAPIServerURL, + TokenFile: spec.TokenFile, + Log: log, + }) if err != nil { - log.Error("failed to create metal client", "error", err) - panic(err) + log.Error("failed to create metal-apiserver v2 client", "error", err) + os.Exit(1) } - log.Info("metal-console", "version", v.V.String(), "port", spec.Port, "metal-api", spec.MetalAPIURL, "devmode", spec.DevMode()) - if err := console.NewServer(log, spec, client).Run(); err != nil { + // Ping apiserver every 5min + apiv2client.Ping(context.Background(), &apiclient.PingConfig{ + ComponentType: apiv2.ComponentType_COMPONENT_TYPE_METAL_CONSOLE, + StartedAt: time.Now(), + Version: apiv2.Version{ + Version: v.Version, + Revision: v.Revision, + GitSha1: v.GitSHA1, + BuildDate: v.BuildDate, + }, + }) + + log.Info("metal-console", "version", v.V.String(), "port", spec.Port, "metal-apiserver", spec.MetalAPIServerURL) + if err := console.NewServer(log, spec).Run(); err != nil { log.Error("unable to start console server", "error", err) - panic(err) + os.Exit(1) } }