From efff89e0d7500a1a906cbc0d02a69aab300bb954 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Wed, 19 Jun 2024 08:17:31 +1000 Subject: [PATCH 1/7] feat: use sshkey-handler to allow for more sshkey types --- Makefile | 18 ++- docker-bake.hcl | 12 ++ docker-compose.yaml | 12 ++ .../01-populate-api-data-lagoon-demo.gql | 23 ++- ...populate-api-data-ci-local-control-k8s.gql | 5 +- node-packages/commons/src/util/func.ts | 67 +++++++++ services/api-sidecar-handler/Dockerfile | 26 ++++ services/api-sidecar-handler/README.md | 5 + services/api-sidecar-handler/go.mod | 10 ++ services/api-sidecar-handler/go.sum | 8 + .../internal/server/generate.go | 60 ++++++++ .../internal/server/generate_test.go | 72 +++++++++ .../internal/server/server.go | 57 ++++++++ .../internal/server/status.go | 11 ++ .../internal/server/validate.go | 103 +++++++++++++ .../internal/server/validate_test.go | 138 ++++++++++++++++++ services/api-sidecar-handler/main.go | 11 ++ .../migrations/20240506000000_sshkey.js | 41 ++++++ services/api/package.json | 1 - services/api/src/gitlab-sync/projects.ts | 12 +- .../api/src/helpers/reset-project-keys.ts | 38 ++--- services/api/src/resolvers.js | 3 + .../api/src/resources/project/resolvers.ts | 79 +++++----- services/api/src/resources/sshKey/index.ts | 21 --- .../api/src/resources/sshKey/resolvers.ts | 87 +++++------ services/api/src/routes/keys.ts | 45 +++--- services/api/src/typeDefs.js | 32 +++- services/auth-server/package.json | 1 - services/auth-server/src/routes.ts | 4 +- services/auth-server/src/util/routing.ts | 50 ------- services/webhooks2tasks/package.json | 3 +- .../src/handlers/gitlabProjectCreate.ts | 11 +- yarn.lock | 15 +- 33 files changed, 846 insertions(+), 235 deletions(-) create mode 100644 services/api-sidecar-handler/Dockerfile create mode 100644 services/api-sidecar-handler/README.md create mode 100644 services/api-sidecar-handler/go.mod create mode 100644 services/api-sidecar-handler/go.sum create mode 100644 services/api-sidecar-handler/internal/server/generate.go create mode 100644 services/api-sidecar-handler/internal/server/generate_test.go create mode 100644 services/api-sidecar-handler/internal/server/server.go create mode 100644 services/api-sidecar-handler/internal/server/status.go create mode 100644 services/api-sidecar-handler/internal/server/validate.go create mode 100644 services/api-sidecar-handler/internal/server/validate_test.go create mode 100644 services/api-sidecar-handler/main.go create mode 100644 services/api/database/migrations/20240506000000_sshkey.js delete mode 100644 services/api/src/resources/sshKey/index.ts delete mode 100644 services/auth-server/src/util/routing.ts diff --git a/Makefile b/Makefile index 2535de9a46..4b1ed23648 100644 --- a/Makefile +++ b/Makefile @@ -168,6 +168,7 @@ services := api \ actions-handler \ backup-handler \ broker \ + api-sidecar-handler \ keycloak \ keycloak-db \ logs2notifications \ @@ -193,6 +194,7 @@ build/api-redis: services/api-redis/Dockerfile build/actions-handler: services/actions-handler/Dockerfile build/backup-handler: services/backup-handler/Dockerfile build/broker: services/broker/Dockerfile +build/api-sidecar-handler: services/api-sidecar-handler/Dockerfile build/keycloak-db: services/keycloak-db/Dockerfile build/keycloak: services/keycloak/Dockerfile build/logs2notifications: services/logs2notifications/Dockerfile @@ -266,7 +268,7 @@ wait-for-keycloak: && echo "" # Define a list of which Lagoon Services are needed for running any deployment testing -main-test-services = actions-handler broker logs2notifications api api-db api-redis keycloak keycloak-db ssh auth-server local-git local-api-data-watcher-pusher local-minio +main-test-services = actions-handler broker api-sidecar-handler logs2notifications api api-db api-redis api-sidecar-handler keycloak keycloak-db ssh auth-server local-git local-api-data-watcher-pusher local-minio # List of Lagoon Services needed for webhook endpoint testing webhooks-test-services = webhook-handler webhooks2tasks backup-handler @@ -364,22 +366,22 @@ local-dev-yarn-stop: .PHONY: ui-development ui-development: build-ui-logs-development - IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db local-api-data-watcher-pusher ui keycloak keycloak-db broker api-redis + IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db api-sidecar-handler local-api-data-watcher-pusher ui keycloak keycloak-db broker api-redis $(MAKE) wait-for-keycloak .PHONY: api-development api-development: build-ui-logs-development - IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db local-api-data-watcher-pusher keycloak keycloak-db broker api-redis + IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db api-sidecar-handler local-api-data-watcher-pusher keycloak keycloak-db broker api-redis $(MAKE) wait-for-keycloak .PHONY: ui-logs-development ui-logs-development: build-ui-logs-development - IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db actions-handler local-api-data-watcher-pusher ui keycloak keycloak-db broker api-redis logs2notifications local-minio mailhog + IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db api-sidecar-handler actions-handler local-api-data-watcher-pusher ui keycloak keycloak-db broker api-redis logs2notifications local-minio mailhog $(MAKE) wait-for-keycloak .PHONY: api-logs-development api-logs-development: build-ui-logs-development - IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db actions-handler local-api-data-watcher-pusher keycloak keycloak-db broker api-redis logs2notifications local-minio mailhog + IMAGE_REPO=$(CI_BUILD_TAG) docker compose -p $(CI_BUILD_TAG) --compatibility up -d api api-db api-sidecar-handler actions-handler local-api-data-watcher-pusher keycloak keycloak-db broker api-redis logs2notifications local-minio mailhog $(MAKE) wait-for-keycloak ## CI targets @@ -392,7 +394,7 @@ STERN_VERSION = v2.6.1 CHART_TESTING_VERSION = v3.11.0 K3D_IMAGE = docker.io/rancher/k3s:v1.30.1-k3s1 TESTS = [nginx,api,features-kubernetes,bulk-deployment,features-kubernetes-2,features-variables,active-standby-kubernetes,tasks,drush,python,gitlab,github,bitbucket,services,workflows] -CHARTS_TREEISH = lagoon-220 +CHARTS_TREEISH = sshkey-handler TASK_IMAGES = task-activestandby # the name of the docker network to create @@ -506,7 +508,7 @@ ifeq ($(ARCH), darwin) tcp-listen:32080,fork,reuseaddr tcp-connect:target:32080 endif -K3D_SERVICES = api api-db api-redis auth-server actions-handler broker keycloak keycloak-db logs2notifications webhook-handler webhooks2tasks local-api-data-watcher-pusher local-git ssh tests workflows $(TASK_IMAGES) +K3D_SERVICES = api api-db api-redis auth-server actions-handler broker api-sidecar-handler keycloak keycloak-db logs2notifications webhook-handler webhooks2tasks local-api-data-watcher-pusher local-git ssh tests workflows $(TASK_IMAGES) K3D_TESTS = local-api-data-watcher-pusher local-git tests K3D_TOOLS = k3d helm kubectl jq stern @@ -535,7 +537,7 @@ k3d/test: k3d/setup "quay.io/helmpack/chart-testing:$(CHART_TESTING_VERSION)" \ ct install --helm-extra-args "--timeout 60m" -LOCAL_DEV_SERVICES = api auth-server actions-handler logs2notifications webhook-handler webhooks2tasks +LOCAL_DEV_SERVICES = api auth-server actions-handler api-sidecar-handler logs2notifications webhook-handler webhooks2tasks # install lagoon charts in a Kind cluster .PHONY: k3d/setup diff --git a/docker-bake.hcl b/docker-bake.hcl index 94a737aa2b..30df12fd1e 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -51,6 +51,7 @@ group "default" { "auth-server", "backup-handler", "broker", + "api-sidecar-handler", "keycloak-db", "keycloak", "local-api-data-watcher-pusher", @@ -72,6 +73,7 @@ group "ui-logs-development" { "api-redis", "api", "broker", + "api-sidecar-handler", "keycloak-db", "keycloak", "local-api-data-watcher-pusher", @@ -95,6 +97,7 @@ group "prod-images" { "auth-server", "backup-handler", "broker", + "api-sidecar-handler", "keycloak-db", "keycloak", "logs2notifications", @@ -182,6 +185,15 @@ target "broker" { tags = ["${IMAGE_REPO}/broker:${TAG}"] } +target "api-sidecar-handler" { + inherits = ["default"] + context = "services/api-sidecar-handler" + labels = { + "org.opencontainers.image.title": "lagoon-core/api-sidecar-handler - the api-sidecar-handler service for Lagoon" + } + tags = ["${IMAGE_REPO}/api-sidecar-handler:${TAG}"] +} + target "keycloak" { inherits = ["default"] context = "services/keycloak" diff --git a/docker-compose.yaml b/docker-compose.yaml index 286e41f2d8..746deb690a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -41,6 +41,12 @@ services: depends_on: broker: condition: service_started + api-sidecar-handler: + # this is neded for the internal dns references + container_name: apisidecarhandler + image: ${IMAGE_REPO:-lagoon}/api-sidecar-handler + ports: + - '3333:3333' logs2notifications: image: ${IMAGE_REPO:-lagoon}/logs2notifications environment: @@ -58,6 +64,7 @@ services: - ./node-packages:/app/node-packages:delegated environment: - CONSOLE_LOGGING_LEVEL=trace + - SIDECAR_HANDLER_HOST=sshkeyhandler api-db-init: image: ${IMAGE_REPO:-lagoon}/api command: > @@ -86,6 +93,8 @@ services: condition: service_completed_successfully # don't start the lagoon migrations until the db migrations is completed keycloak: condition: service_started + api-sidecar-handler: + condition: service_started api: image: ${IMAGE_REPO:-lagoon}/api command: ./node_modules/.bin/tsc-watch --build --incremental --onSuccess "node -r dotenv-extended/config dist/index" @@ -104,11 +113,14 @@ services: - S3_BAAS_ACCESS_KEY_ID=minio - S3_BAAS_SECRET_ACCESS_KEY=minio123 - CONSOLE_LOGGING_LEVEL=debug + - SIDECAR_HANDLER_HOST=sshkeyhandler depends_on: api-lagoon-migrations: condition: service_started keycloak: condition: service_started + keycloak: + condition: service_started ports: - '3000:3000' # Uncomment for local new relic tracking diff --git a/local-dev/api-data-watcher-pusher/api-data/01-populate-api-data-lagoon-demo.gql b/local-dev/api-data-watcher-pusher/api-data/01-populate-api-data-lagoon-demo.gql index 0635e291ae..3abfd9d1fe 100644 --- a/local-dev/api-data-watcher-pusher/api-data/01-populate-api-data-lagoon-demo.gql +++ b/local-dev/api-data-watcher-pusher/api-data/01-populate-api-data-lagoon-demo.gql @@ -757,12 +757,11 @@ mutation PopulateApi { ) { name } - PlatformOwnerSshKeyEd25519: addSshKey( + PlatformOwnerSshKeyEd25519: addUserSSHPublicKey( input: { id: 5 name: "sshkey-platform-owner-ed25519" - keyValue: "AAAAC3NzaC1lZDI1NTE5AAAAIMdEs1h19jv2UrbtKcqPDatUxT9lPYcbGlEAbInsY8Ka" - keyType: SSH_ED25519 + publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMdEs1h19jv2UrbtKcqPDatUxT9lPYcbGlEAbInsY8Ka" user: { email: "platformowner@example.com" } @@ -770,12 +769,24 @@ mutation PopulateApi { ) { id } - MaintainerSshKeyEcdsa: addSshKey( + MaintainerSshKeyEcdsa: addUserSSHPublicKey( input: { id: 6 name: "sshkey-maintainer-ecdsa" - keyValue: "AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD8E5wfvLg8vvfO9mmHVsZQK8dNgdKM5FrTxL4ORDq66Z50O8zUzBwF1VTO5Zx+qwB7najMdWsnW00BC6PMysSNJQD5HI4CokyKqmGdeSXcROYwvYOjlDQ+jD5qOSmkllRZZnkEYXE5FVBXaZWToyfGUGIoECvKGUQZxkBDHsbK13JdfA==" - keyType: ECDSA_SHA2_NISTP521 + publicKey: "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD8E5wfvLg8vvfO9mmHVsZQK8dNgdKM5FrTxL4ORDq66Z50O8zUzBwF1VTO5Zx+qwB7najMdWsnW00BC6PMysSNJQD5HI4CokyKqmGdeSXcROYwvYOjlDQ+jD5qOSmkllRZZnkEYXE5FVBXaZWToyfGUGIoECvKGUQZxkBDHsbK13JdfA==" + user: { + email: "maintainer@example.com" + } + } + ) { + id + } + + MaintainerSshKeyEd25519SK: addUserSSHPublicKey( + input: { + id: 7 + name: "sshkey-maintainer-ed25519-sk" + publicKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIPjqGSQd+w7qQxioI6qj+KWX/pEg9mNvVGZ7aUoXfsC0AAAABHNzaDo=" user: { email: "maintainer@example.com" } diff --git a/local-dev/api-data-watcher-pusher/api-data/03-populate-api-data-ci-local-control-k8s.gql b/local-dev/api-data-watcher-pusher/api-data/03-populate-api-data-ci-local-control-k8s.gql index bd9ac68894..f897a2927d 100644 --- a/local-dev/api-data-watcher-pusher/api-data/03-populate-api-data-ci-local-control-k8s.gql +++ b/local-dev/api-data-watcher-pusher/api-data/03-populate-api-data-ci-local-control-k8s.gql @@ -38,12 +38,11 @@ mutation PopulateApi { } ### SSH Keys - CiCustomerSshKeyRsa: addSshKey( + CiCustomerSshKeyRsa: addUserSSHPublicKey( input: { id: 4 name: "sshkey-ci-customer-user-rsa" - keyValue: "AAAAB3NzaC1yc2EAAAADAQABAAACAQDEZlms5XsiyWjmnnUyhpt93VgHypse9Bl8kNkmZJTiM3Ex/wZAfwogzqd2LrTEiIOWSH1HnQazR+Cc9oHCmMyNxRrLkS/MEl0yZ38Q+GDfn37h/llCIZNVoHlSgYkqD0MQrhfGL5AulDUKIle93dA6qdCUlnZZjDPiR0vEXR36xGuX7QYAhK30aD2SrrBruTtFGvj87IP/0OEOvUZe8dcU9G/pCoqrTzgKqJRpqs/s5xtkqLkTIyR/SzzplO21A+pCKNax6csDDq3snS8zfx6iM8MwVfh8nvBW9seax1zBvZjHAPSTsjzmZXm4z32/ujAn/RhIkZw3ZgRKrxzryttGnWJJ8OFyF31JTJgwWWuPdH53G15PC83ZbmEgSV3win51RZRVppN4uQUuaqZWG9wwk2a6P5aen1RLCSLpTkd2mAEk9PlgmJrf8vITkiU9pF9n68ENCoo556qSdxW2pxnjrzKVPSqmqO1Xg5K4LOX4/9N4n4qkLEOiqnzzJClhFif3O28RW86RPxERGdPT81UI0oDAcU5euQr8Emz+Hd+PY1115UIld3CIHib5PYL9Ee0bFUKiWpR/acSe1fHB64mCoHP7hjFepGsq7inkvg2651wUDKBshGltpNkMj6+aZedNc0/rKYyjl80nT8g8QECgOSRzpmYp0zli2HpFoLOiWw==" - keyType: SSH_RSA + publicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDEZlms5XsiyWjmnnUyhpt93VgHypse9Bl8kNkmZJTiM3Ex/wZAfwogzqd2LrTEiIOWSH1HnQazR+Cc9oHCmMyNxRrLkS/MEl0yZ38Q+GDfn37h/llCIZNVoHlSgYkqD0MQrhfGL5AulDUKIle93dA6qdCUlnZZjDPiR0vEXR36xGuX7QYAhK30aD2SrrBruTtFGvj87IP/0OEOvUZe8dcU9G/pCoqrTzgKqJRpqs/s5xtkqLkTIyR/SzzplO21A+pCKNax6csDDq3snS8zfx6iM8MwVfh8nvBW9seax1zBvZjHAPSTsjzmZXm4z32/ujAn/RhIkZw3ZgRKrxzryttGnWJJ8OFyF31JTJgwWWuPdH53G15PC83ZbmEgSV3win51RZRVppN4uQUuaqZWG9wwk2a6P5aen1RLCSLpTkd2mAEk9PlgmJrf8vITkiU9pF9n68ENCoo556qSdxW2pxnjrzKVPSqmqO1Xg5K4LOX4/9N4n4qkLEOiqnzzJClhFif3O28RW86RPxERGdPT81UI0oDAcU5euQr8Emz+Hd+PY1115UIld3CIHib5PYL9Ee0bFUKiWpR/acSe1fHB64mCoHP7hjFepGsq7inkvg2651wUDKBshGltpNkMj6+aZedNc0/rKYyjl80nT8g8QECgOSRzpmYp0zli2HpFoLOiWw==" user: { email: "ci-customer-user-rsa@example.com" } diff --git a/node-packages/commons/src/util/func.ts b/node-packages/commons/src/util/func.ts index dc25b5b320..ec4950d7cb 100644 --- a/node-packages/commons/src/util/func.ts +++ b/node-packages/commons/src/util/func.ts @@ -1,4 +1,7 @@ import { unless, is, isNil, isEmpty, partialRight, complement } from 'ramda'; +import http from 'http'; +import querystring from 'querystring'; +import { getConfigFromEnv } from './config'; export const isNumber = is(Number); export const isArray = is(Array); @@ -28,3 +31,67 @@ export const jsonMerge = function(a, b, prop) { // a2 = [1,2,3,5] // arrayDiff(a1,a2) = [4] export const arrayDiff = (a:Array, b:Array) => a.filter(e => !b.includes(e)); + +// helper that will use the crypto handler service to check if a public or private key is valid or not +export async function validateKey(key, type) { + const data = querystring.stringify({'key': key}); + const options = { + hostname: getConfigFromEnv("SIDECAR_HANDLER_HOST", "localhost"), + port: 3333, + path: `/validate/${type}`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(data) + }, + }; + let p = new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + res.setEncoding('utf8'); + let responseBody = ''; + + res.on('data', (chunk) => { + responseBody += chunk; + }); + + res.on('end', () => { + resolve(JSON.parse(responseBody)); + }); + }); + req.on('error', (err) => { + reject(err); + }); + req.write(data) + req.end(); + }); + return await p; +} + +// helper that will use the crypto handler service to generate a private key with associated public key +export async function generatePrivateKey() { + const options = { + hostname: getConfigFromEnv("SIDECAR_HANDLER_HOST", "localhost"), + port: 3333, + path: '/generate/ed25519', + method: 'GET', + }; + let p = new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + res.setEncoding('utf8'); + let responseBody = ''; + + res.on('data', (chunk) => { + responseBody += chunk; + }); + + res.on('end', () => { + resolve(JSON.parse(responseBody)); + }); + }); + req.on('error', (err) => { + reject(err); + }); + req.end(); + }); + return await p; +} \ No newline at end of file diff --git a/services/api-sidecar-handler/Dockerfile b/services/api-sidecar-handler/Dockerfile new file mode 100644 index 0000000000..f423b73515 --- /dev/null +++ b/services/api-sidecar-handler/Dockerfile @@ -0,0 +1,26 @@ +# build the binary +ARG UPSTREAM_REPO +ARG UPSTREAM_TAG +FROM golang:1.21-alpine AS builder +# bring in all the packages +COPY . /go/src/github.com/uselagoon/lagoon/services/api-sidecar-handler/ +WORKDIR /go/src/github.com/uselagoon/lagoon/services/api-sidecar-handler/ + +# compile +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${ARCH} go build -a -o api-sidecar-handler . + +# put the binary into container +# use the commons image to get entrypoints +FROM ${UPSTREAM_REPO:-uselagoon}/commons:${UPSTREAM_TAG:-latest} + +ARG LAGOON_VERSION +ENV LAGOON_VERSION=$LAGOON_VERSION + +WORKDIR /app/ +COPY --from=builder /go/src/github.com/uselagoon/lagoon/services/api-sidecar-handler/api-sidecar-handler . + +ENV LAGOON=api-sidecar-handler + + +ENTRYPOINT ["/sbin/tini", "--", "/lagoon/entrypoints.sh"] +CMD ["/app/api-sidecar-handler"] \ No newline at end of file diff --git a/services/api-sidecar-handler/README.md b/services/api-sidecar-handler/README.md new file mode 100644 index 0000000000..a7535d9c38 --- /dev/null +++ b/services/api-sidecar-handler/README.md @@ -0,0 +1,5 @@ +# api-sidecar-handler + +This is just a simple microservice that is run as a sidecar to `api` and `webhooks2tasks` to perform validations and generations on ssh keys used in Lagoon. + +The purpose of this sidecar is to initially replace the functionality of the node `sshpk` package, as it doesn't support all types of ssh-keys that could be used. \ No newline at end of file diff --git a/services/api-sidecar-handler/go.mod b/services/api-sidecar-handler/go.mod new file mode 100644 index 0000000000..584a27c61d --- /dev/null +++ b/services/api-sidecar-handler/go.mod @@ -0,0 +1,10 @@ +module github.com/uselagoon/lagoon/services/api-sidecar-handler + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 + golang.org/x/crypto v0.20.0 +) + +require golang.org/x/sys v0.17.0 // indirect diff --git a/services/api-sidecar-handler/go.sum b/services/api-sidecar-handler/go.sum new file mode 100644 index 0000000000..3673842f97 --- /dev/null +++ b/services/api-sidecar-handler/go.sum @@ -0,0 +1,8 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= diff --git a/services/api-sidecar-handler/internal/server/generate.go b/services/api-sidecar-handler/internal/server/generate.go new file mode 100644 index 0000000000..c5e89c2aa2 --- /dev/null +++ b/services/api-sidecar-handler/internal/server/generate.go @@ -0,0 +1,60 @@ +package server + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/pem" + "fmt" + "log" + "net/http" + "strings" + + "golang.org/x/crypto/ssh" +) + +// curl -X GET "http://localhost:3333/generate/ed25519" + +var ( + Rand = rand.Reader +) + +func generateED25519Key(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + resp := ParsedPrivateKeyResponse{} + pub, priv, err := ed25519.GenerateKey(Rand) + if err != nil { + log.Println(resp.String()) + resp.Error = err.Error() + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + p, err := ssh.MarshalPrivateKey(crypto.PrivateKey(priv), "") + if err != nil { + log.Println(resp.String()) + resp.Error = err.Error() + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + privateKeyString := string(pem.EncodeToMemory(p)) + publicKey, err := ssh.NewPublicKey(pub) + if err != nil { + log.Println(resp.String()) + resp.Error = err.Error() + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + resp.PublicKey = "ssh-ed25519" + " " + base64.StdEncoding.EncodeToString(publicKey.Marshal()) + resp.PrivateKeyPEM = privateKeyString + resp.SHA256Fingerprint = ssh.FingerprintSHA256(publicKey) + resp.MD5Fingerprint = ssh.FingerprintLegacyMD5(publicKey) + resp.Type = publicKey.Type() + resp.Value = strings.Split(resp.PublicKey, " ")[1] + log.Printf("generated private key with public fingerprint %s/n", resp.SHA256Fingerprint) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, resp.String()) +} diff --git a/services/api-sidecar-handler/internal/server/generate_test.go b/services/api-sidecar-handler/internal/server/generate_test.go new file mode 100644 index 0000000000..dc5810773b --- /dev/null +++ b/services/api-sidecar-handler/internal/server/generate_test.go @@ -0,0 +1,72 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func Test_generateED25519Key(t *testing.T) { + tt := []struct { + name string + method string + input string + want ParsedPrivateKeyResponse + statusCode int + }{ + { + name: "with ed25519", + method: http.MethodGet, + want: ParsedPrivateKeyResponse{ + PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDtqJ7zOtqQtYqOo0CpvDXNlMhV3HeJDpjrASKGLWdop", + SHA256Fingerprint: "SHA256:tAXFyTXI8xtDaujAEcwJslAYc9/6FKcUkd2Lw0xDhPo", + MD5Fingerprint: "27:c6:3f:84:be:6f:a4:5e:eb:f9:4d:6e:bd:c8:bb:48", + Type: "ssh-ed25519", + Value: "AAAAC3NzaC1lZDI1NTE5AAAAIDtqJ7zOtqQtYqOo0CpvDXNlMhV3HeJDpjrASKGLWdop", + }, + statusCode: http.StatusOK, + }, + { + name: "with bad method", + method: http.MethodPost, + want: ParsedPrivateKeyResponse{}, + statusCode: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + request, _ := http.NewRequest(tc.method, "/generate/ed25519", nil) + responseRecorder := httptest.NewRecorder() + + // replace the random reader with zero for repeatable result in tests + var zero zeroReader + Rand = zero + generateED25519Key(responseRecorder, request) + + if responseRecorder.Code != tc.statusCode { + t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code) + } + + var got ParsedPrivateKeyResponse + _ = json.Unmarshal(responseRecorder.Body.Bytes(), &got) + got.PrivateKeyPEM = "" // remove this because it contains signatures that change during generation + if !reflect.DeepEqual(got, tc.want) { + lValues, _ := json.Marshal(got) + wValues, _ := json.Marshal(tc.want) + t.Errorf("Want '%s', got '%s'", string(wValues), string(lValues)) + } + }) + } +} + +type zeroReader struct{} + +func (zeroReader) Read(buf []byte) (int, error) { + for i := range buf { + buf[i] = 0 + } + return len(buf), nil +} diff --git a/services/api-sidecar-handler/internal/server/server.go b/services/api-sidecar-handler/internal/server/server.go new file mode 100644 index 0000000000..1e6cfe31c0 --- /dev/null +++ b/services/api-sidecar-handler/internal/server/server.go @@ -0,0 +1,57 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" +) + +type ParsedPublicKeyResponse struct { + Error string `json:"error,omitempty"` + PublicKey string `json:"publickey,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + SHA256Fingerprint string `json:"sha256fingerprint,omitempty"` + MD5Fingerprint string `json:"md5fingerprint,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func (p *ParsedPublicKeyResponse) String() string { + b, err := json.Marshal(p) + if err != nil { + return "" + } + return string(b) +} + +type ParsedPrivateKeyResponse struct { + Error string `json:"error,omitempty"` + PublicKey string `json:"publickey,omitempty"` + PublicKeyPEM string `json:"publickeypem,omitempty"` + SHA256Fingerprint string `json:"sha256fingerprint,omitempty"` + MD5Fingerprint string `json:"md5fingerprint,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + PrivateKeyPEM string `json:"privatekeypem,omitempty"` +} + +func (p *ParsedPrivateKeyResponse) String() string { + b, err := json.Marshal(p) + if err != nil { + return "" + } + return string(b) +} +func Run() error { + r := mux.NewRouter() + r.HandleFunc("/status", status).Methods("GET") + r.HandleFunc("/validate/public", validatePublicKey).Methods("POST") + r.HandleFunc("/validate/private", validatePrivateKey).Methods("POST") + r.HandleFunc("/generate/ed25519", generateED25519Key).Methods("GET") + + if err := http.ListenAndServe(":3333", r); err != nil { + return err + } + return nil +} diff --git a/services/api-sidecar-handler/internal/server/status.go b/services/api-sidecar-handler/internal/server/status.go new file mode 100644 index 0000000000..937047b76a --- /dev/null +++ b/services/api-sidecar-handler/internal/server/status.go @@ -0,0 +1,11 @@ +package server + +import ( + "fmt" + "net/http" +) + +func status(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") +} diff --git a/services/api-sidecar-handler/internal/server/validate.go b/services/api-sidecar-handler/internal/server/validate.go new file mode 100644 index 0000000000..c828887e8e --- /dev/null +++ b/services/api-sidecar-handler/internal/server/validate.go @@ -0,0 +1,103 @@ +package server + +import ( + "encoding/base64" + "fmt" + "log" + "net/http" + "strings" + + "golang.org/x/crypto/ssh" +) + +// curl -X POST "http://localhost:3333/validate/public" -d key=$(echo -n "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIKdtLKvpwRRMdmoo1Exj8/MxSVOb5zN47eJmVg9ttVP2AAAABHNzaDo=" | base64 -w0) +// curl -X POST "http://localhost:3333/validate/public" -d key=$(echo -n "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBAlulpLk2cp9XsbCWxwpxgKIBpxUlSki4Y3k+0huraRzVtYy4FaKyXGZ4kyCpkdhsSrkSD8ptbeks9lzV1tGe2wAAAAEc3NoOg==" | base64 -w0) +// curl -X POST "http://localhost:3333/validate/public" -d key=$(echo -n "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDEZlms5XsiyWjmnnUyhpt93VgHypse9Bl8kNkmZJTiM3Ex/wZAfwogzqd2LrTEiIOWSH1HnQazR+Cc9oHCmMyNxRrLkS/MEl0yZ38Q+GDfn37h/llCIZNVoHlSgYkqD0MQrhfGL5AulDUKIle93dA6qdCUlnZZjDPiR0vEXR36xGuX7QYAhK30aD2SrrBruTtFGvj87IP/0OEOvUZe8dcU9G/pCoqrTzgKqJRpqs/s5xtkqLkTIyR/SzzplO21A+pCKNax6csDDq3snS8zfx6iM8MwVfh8nvBW9seax1zBvZjHAPSTsjzmZXm4z32/ujAn/RhIkZw3ZgRKrxzryttGnWJJ8OFyF31JTJgwWWuPdH53G15PC83ZbmEgSV3win51RZRVppN4uQUuaqZWG9wwk2a6P5aen1RLCSLpTkd2mAEk9PlgmJrf8vITkiU9pF9n68ENCoo556qSdxW2pxnjrzKVPSqmqO1Xg5K4LOX4/9N4n4qkLEOiqnzzJClhFif3O28RW86RPxERGdPT81UI0oDAcU5euQr8Emz+Hd+PY1115UIld3CIHib5PYL9Ee0bFUKiWpR/acSe1fHB64mCoHP7hjFepGsq7inkvg2651wUDKBshGltpNkMj6+aZedNc0/rKYyjl80nT8g8QECgOSRzpmYp0zli2HpFoLOiWw== local-cli" | base64 -w0) +// curl -X POST "http://localhost:3333/validate/public" -d key=$(echo -n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMdEs1h19jv2UrbtKcqPDatUxT9lPYcbGlEAbInsY8Ka local-cli" | base64 -w0) +// curl -X POST "http://localhost:3333/validate/public" -d key=$(echo -n "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD8E5wfvLg8vvfO9mmHVsZQK8dNgdKM5FrTxL4ORDq66Z50O8zUzBwF1VTO5Zx+qwB7najMdWsnW00BC6PMysSNJQD5HI4CokyKqmGdeSXcROYwvYOjlDQ+jD5qOSmkllRZZnkEYXE5FVBXaZWToyfGUGIoECvKGUQZxkBDHsbK13JdfA== local-cli" | base64 -w0) + +func validatePublicKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + r.ParseForm() + key := r.Form.Get("key") + resp := ParsedPublicKeyResponse{} + kb, err := base64.StdEncoding.DecodeString(key) + if err != nil { + resp.Error = err.Error() + log.Println(resp.String()) + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + pub, comment, _, _, err := ssh.ParseAuthorizedKey(kb) + if err != nil { + resp.Error = err.Error() + log.Println(resp.String()) + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + resp.Type = pub.Type() + resp.PublicKey = string(kb) + resp.Value = strings.Split(string(kb), " ")[1] + resp.Comment = comment + resp.SHA256Fingerprint = ssh.FingerprintSHA256(pub) + resp.MD5Fingerprint = ssh.FingerprintLegacyMD5(pub) + log.Printf("validated public key with fingerprint %s", resp.SHA256Fingerprint) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, resp.String()) +} + +// curl -X POST "http://localhost:3333/validate/private" -d key=$(echo -n "-----BEGIN OPENSSH PRIVATE KEY----- +// b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS +// 1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQA/BOcH7y4PL73zvZph1bGUCvHTYHS +// jORa08S+DkQ6uumedDvM1MwcBdVUzuWcfqsAe52ozHVrJ1tNAQujzMrEjSUA+RyOAqJMiq +// phnXkl3ETmML2Do5Q0Pow+ajkppJZUWWZ5BGFxORVQV2mVk6MnxlBiKBAryhlEGcZAQx7G +// ytdyXXwAAAEQ2qoa0tqqGtIAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ +// AAAIUEAPwTnB+8uDy+9872aYdWxlArx02B0ozkWtPEvg5EOrrpnnQ7zNTMHAXVVM7lnH6r +// AHudqMx1aydbTQELo8zKxI0lAPkcjgKiTIqqYZ15JdxE5jC9g6OUND6MPmo5KaSWVFlmeQ +// RhcTkVUFdplZOjJ8ZQYigQK8oZRBnGQEMexsrXcl18AAAAQVr/ti+u4L5jRkZFILddaexL +// mOE274AeMUG6NKlCQWsDdD2hroKJuUQ59TQdpe6e5jBoUZ300EHjA40wmbU+oC/8AAAAE3 +// RvYnliZWxsd29vZEBwb3Atb3M= +// -----END OPENSSH PRIVATE KEY-----" | base64 -w0) + +func validatePrivateKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + r.ParseForm() + key := r.Form.Get("key") + resp := ParsedPrivateKeyResponse{} + kb, err := base64.StdEncoding.DecodeString(key) + if err != nil { + resp.Error = err.Error() + log.Println(resp.String()) + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + signer, err := ssh.ParsePrivateKey([]byte(kb)) + if err != nil { + resp.Error = err.Error() + log.Println(resp.String()) + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + sshPubKey := ssh.MarshalAuthorizedKey(signer.PublicKey()) + resp.PublicKey = string(sshPubKey) + pub, _, _, _, err := ssh.ParseAuthorizedKey(sshPubKey) + if err != nil { + resp.Error = err.Error() + log.Println(resp.String()) + http.Error(w, resp.String(), http.StatusInternalServerError) + return + } + resp.SHA256Fingerprint = ssh.FingerprintSHA256(pub) + resp.MD5Fingerprint = ssh.FingerprintLegacyMD5(pub) + resp.Type = pub.Type() + resp.Value = strings.Split(string(sshPubKey), " ")[1] + log.Printf("validated private key with public fingerprint %s", resp.SHA256Fingerprint) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, resp.String()) +} diff --git a/services/api-sidecar-handler/internal/server/validate_test.go b/services/api-sidecar-handler/internal/server/validate_test.go new file mode 100644 index 0000000000..9a58f7cebe --- /dev/null +++ b/services/api-sidecar-handler/internal/server/validate_test.go @@ -0,0 +1,138 @@ +package server + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +var ( + ed25519Key string = base64.StdEncoding.EncodeToString([]byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS +1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQA/BOcH7y4PL73zvZph1bGUCvHTYHS +jORa08S+DkQ6uumedDvM1MwcBdVUzuWcfqsAe52ozHVrJ1tNAQujzMrEjSUA+RyOAqJMiq +phnXkl3ETmML2Do5Q0Pow+ajkppJZUWWZ5BGFxORVQV2mVk6MnxlBiKBAryhlEGcZAQx7G +ytdyXXwAAAEQ2qoa0tqqGtIAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ +AAAIUEAPwTnB+8uDy+9872aYdWxlArx02B0ozkWtPEvg5EOrrpnnQ7zNTMHAXVVM7lnH6r +AHudqMx1aydbTQELo8zKxI0lAPkcjgKiTIqqYZ15JdxE5jC9g6OUND6MPmo5KaSWVFlmeQ +RhcTkVUFdplZOjJ8ZQYigQK8oZRBnGQEMexsrXcl18AAAAQVr/ti+u4L5jRkZFILddaexL +mOE274AeMUG6NKlCQWsDdD2hroKJuUQ59TQdpe6e5jBoUZ300EHjA40wmbU+oC/8AAAAE3 +RvYnliZWxsd29vZEBwb3Atb3M= +-----END OPENSSH PRIVATE KEY-----`)) + ed25519Pub string = base64.StdEncoding.EncodeToString([]byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMdEs1h19jv2UrbtKcqPDatUxT9lPYcbGlEAbInsY8Ka local-cli")) + ed25519SKPub string = base64.StdEncoding.EncodeToString([]byte("sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIKdtLKvpwRRMdmoo1Exj8/MxSVOb5zN47eJmVg9ttVP2AAAABHNzaDo=")) +) + +func Test_validatePublicKey(t *testing.T) { + tt := []struct { + name string + method string + input string + want string + statusCode int + }{ + { + name: "without key", + method: http.MethodPost, + want: `{"error":"ssh: no key found"}`, + statusCode: http.StatusInternalServerError, + }, + { + name: "with public ed25519", + method: http.MethodPost, + input: fmt.Sprintf("key=%s", ed25519Pub), + want: `{"publickey":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMdEs1h19jv2UrbtKcqPDatUxT9lPYcbGlEAbInsY8Ka local-cli","type":"ssh-ed25519","value":"AAAAC3NzaC1lZDI1NTE5AAAAIMdEs1h19jv2UrbtKcqPDatUxT9lPYcbGlEAbInsY8Ka","sha256fingerprint":"SHA256:inQGcrMz0Bp0fTovkhOQgH70z8sMU8jjZbrHSw2MPN4","md5fingerprint":"a4:1d:32:73:d7:76:d0:15:8e:24:dd:10:f6:fd:d0:d6","comment":"local-cli"}`, + statusCode: http.StatusOK, + }, + { + name: "with public sk-ed25519", + method: http.MethodPost, + input: fmt.Sprintf("key=%s", ed25519SKPub), + want: `{"publickey":"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIKdtLKvpwRRMdmoo1Exj8/MxSVOb5zN47eJmVg9ttVP2AAAABHNzaDo=","type":"sk-ssh-ed25519@openssh.com","value":"AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIKdtLKvpwRRMdmoo1Exj8/MxSVOb5zN47eJmVg9ttVP2AAAABHNzaDo=","sha256fingerprint":"SHA256:8BN0c1Mhxdsc02+KTwDSujhKYqa5Aucv9oL3IYr53aE","md5fingerprint":"fc:25:a3:b4:f0:d1:47:e8:ef:8c:85:d5:9b:9c:9f:7c"}`, + statusCode: http.StatusOK, + }, + { + name: "with public invalid ed25519", + method: http.MethodPost, + input: fmt.Sprintf("key=%sinvalid", ed25519Pub), + want: `{"error":"illegal base64 data at input byte 124"}`, + statusCode: http.StatusInternalServerError, + }, + { + name: "with bad method public", + method: http.MethodGet, + want: "Method not allowed", + statusCode: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + request, _ := http.NewRequest(tc.method, "/validate/public", strings.NewReader(tc.input)) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + responseRecorder := httptest.NewRecorder() + + validatePublicKey(responseRecorder, request) + + if responseRecorder.Code != tc.statusCode { + t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code) + } + + if strings.TrimSpace(responseRecorder.Body.String()) != tc.want { + t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body) + } + }) + } +} + +func Test_validatePrivateKey(t *testing.T) { + tt := []struct { + name string + method string + input string + want string + statusCode int + }{ + { + name: "with private ed25519", + method: http.MethodPost, + input: fmt.Sprintf("key=%s", ed25519Key), + want: `{"publickey":"ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD8E5wfvLg8vvfO9mmHVsZQK8dNgdKM5FrTxL4ORDq66Z50O8zUzBwF1VTO5Zx+qwB7najMdWsnW00BC6PMysSNJQD5HI4CokyKqmGdeSXcROYwvYOjlDQ+jD5qOSmkllRZZnkEYXE5FVBXaZWToyfGUGIoECvKGUQZxkBDHsbK13JdfA==\n","sha256fingerprint":"SHA256:RBRWA2mJFPK/8DtsxVoVzoSShFiuRAzlUBws7cXkwG0","md5fingerprint":"72:86:48:50:59:1b:97:81:21:27:e7:55:98:fa:35:95","type":"ecdsa-sha2-nistp521","value":"AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD8E5wfvLg8vvfO9mmHVsZQK8dNgdKM5FrTxL4ORDq66Z50O8zUzBwF1VTO5Zx+qwB7najMdWsnW00BC6PMysSNJQD5HI4CokyKqmGdeSXcROYwvYOjlDQ+jD5qOSmkllRZZnkEYXE5FVBXaZWToyfGUGIoECvKGUQZxkBDHsbK13JdfA==\n"}`, + statusCode: http.StatusOK, + }, + { + name: "with invalid private ed25519", + method: http.MethodPost, + input: fmt.Sprintf("key=%sinvalid", ed25519Key), + want: `{"error":"illegal base64 data at input byte 984"}`, + statusCode: http.StatusInternalServerError, + }, + { + name: "with bad method private", + method: http.MethodGet, + want: "Method not allowed", + statusCode: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + request, _ := http.NewRequest(tc.method, "/validate/private", strings.NewReader(tc.input)) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + responseRecorder := httptest.NewRecorder() + + validatePrivateKey(responseRecorder, request) + + if responseRecorder.Code != tc.statusCode { + t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code) + } + + if strings.TrimSpace(responseRecorder.Body.String()) != tc.want { + t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body) + } + }) + } +} diff --git a/services/api-sidecar-handler/main.go b/services/api-sidecar-handler/main.go new file mode 100644 index 0000000000..94d463a7c3 --- /dev/null +++ b/services/api-sidecar-handler/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "github.com/uselagoon/lagoon/services/api-sidecar-handler/internal/server" +) + +func main() { + if err := server.Run(); err != nil { + panic(err) + } +} diff --git a/services/api/database/migrations/20240506000000_sshkey.js b/services/api/database/migrations/20240506000000_sshkey.js new file mode 100644 index 0000000000..8ef9b88d35 --- /dev/null +++ b/services/api/database/migrations/20240506000000_sshkey.js @@ -0,0 +1,41 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + return knex.schema.table('ssh_key', (table) => { + table.string('key_type_new').notNullable().defaultTo('ssh-rsa'); + }).then(() => { + return knex('ssh_key').update({ + key_type_new: knex.ref('key_type') + }); + }).then(function () { + return knex.schema.table('ssh_key', (table) => { + table.dropColumn('key_type'); + table.renameColumn('key_type_new', 'key_type'); + }) + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + return knex.schema.table('ssh_key', (table) => { + table.enu('key_type_new', ['ssh-rsa', 'ssh-ed25519','ecdsa-sha2-nistp256','ecdsa-sha2-nistp384','ecdsa-sha2-nistp521']).notNullable().defaultTo('ssh-rsa'); + }).then(() => { + return knex('ssh_key') + .whereNotIn('key_type', ['ssh-rsa', 'ssh-ed25519','ecdsa-sha2-nistp256','ecdsa-sha2-nistp384','ecdsa-sha2-nistp521']) + .del(); + }).then(function () { + return knex('ssh_key').update({ + key_type_new: knex.ref('key_type') + }); + }).then(function () { + return knex.schema.table('ssh_key', (table) => { + table.dropColumn('key_type'); + table.renameColumn('key_type_new', 'key_type'); + }) + }); +}; diff --git a/services/api/package.json b/services/api/package.json index 75b1cbfb36..d26eb61363 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -59,7 +59,6 @@ "ramda": "0.25.0", "redis": "^3.0.2", "snakecase-keys": "^1.2.0", - "sshpk": "^1.14.2", "validator": "^10.8.0", "winston": "^3", "winston-transport": "^4.4.0" diff --git a/services/api/src/gitlab-sync/projects.ts b/services/api/src/gitlab-sync/projects.ts index 5d4c8647b9..c75321c623 100644 --- a/services/api/src/gitlab-sync/projects.ts +++ b/services/api/src/gitlab-sync/projects.ts @@ -1,5 +1,4 @@ import * as R from 'ramda'; -import * as sshpk from 'sshpk'; import * as gitlabApi from '@lagoon/commons/dist/gitlab/api'; import { sanitizeGroupName, @@ -10,6 +9,7 @@ import { addUserToGroup } from '@lagoon/commons/dist/api'; import { logger } from '@lagoon/commons/dist/logs/local-logger'; +import { validateKey } from '../util/func'; interface GitlabProject { id: number, @@ -56,14 +56,10 @@ const syncProject = async (project) => { } try { - const privateKey = R.pipe( - R.prop('privateKey'), - sshpk.parsePrivateKey, - )(lagoonProject); - //@ts-ignore - const publicKey = privateKey.toPublic(); + const privkey = new Buffer((R.prop('privateKey', lagoonProject))).toString('base64') + const publickey = await validateKey(privkey, "private") - await gitlabApi.addDeployKeyToProject(id, publicKey.toString()); + await gitlabApi.addDeployKeyToProject(id, publickey['publickey']); } catch (err) { if (!err.message.includes('has already been taken')) { throw new Error(`Could not add deploy_key to gitlab project ${id}, reason: ${err}`); diff --git a/services/api/src/helpers/reset-project-keys.ts b/services/api/src/helpers/reset-project-keys.ts index 98daccae65..bbcfe5e6f4 100644 --- a/services/api/src/helpers/reset-project-keys.ts +++ b/services/api/src/helpers/reset-project-keys.ts @@ -1,5 +1,4 @@ import * as R from 'ramda'; -import { parsePrivateKey } from 'sshpk'; import { logger } from '@lagoon/commons/dist/logs/local-logger'; import { sanitizeGroupName } from '@lagoon/commons/dist/api'; import * as gitlabApi from '@lagoon/commons/dist/gitlab/api'; @@ -10,10 +9,7 @@ import redisClient from '../clients/redisClient'; import { query } from '../util/db'; import { Group } from '../models/group'; import { User } from '../models/user'; -import { - generatePrivateKey, - getSshKeyFingerprint -} from '../resources/sshKey'; +import { validateKey, generatePrivateKey as genpk } from '../util/func'; import { Sql as sshKeySql } from '../resources/sshKey/sql'; interface GitlabProject { @@ -27,8 +23,6 @@ interface GitlabProject { }; } -const generatePrivateKeyEd25519 = R.partial(generatePrivateKey, ['ed25519']); - (async () => { const keycloakAdminClient = await getKeycloakAdminClient(); @@ -83,13 +77,13 @@ const generatePrivateKeyEd25519 = R.partial(generatePrivateKey, ['ed25519']); if (R.prop('privateKey', project)) { let keyPair = {} as any; try { - const privateKey = parsePrivateKey(R.prop('privateKey', project)); - const publicKey = privateKey.toPublic(); - + const privkey = new Buffer((R.prop('privateKey', project))).toString('base64') + const publickey = await validateKey(privkey, "private") keyPair = { ...keyPair, - private: R.replace(/\n/g, '\n', privateKey.toString('openssh')), - public: publicKey.toString() + private: R.replace(/\n/g, '\n', (R.prop('privateKey', project)).toString('openssh')), + public: publickey['publickey'], + fingerprint: publickey['sha256fingerprint'] }; } catch (err) { throw new Error( @@ -101,9 +95,7 @@ const generatePrivateKeyEd25519 = R.partial(generatePrivateKey, ['ed25519']); // Delete users with current key const userRows = await query( sqlClientPool, - sshKeySql.selectUserIdsBySshKeyFingerprint( - getSshKeyFingerprint(keyPair.public) - ) + sshKeySql.selectUserIdsBySshKeyFingerprint(keyPair.fingerprint) ); for (const userRow of userRows) { @@ -151,12 +143,12 @@ const generatePrivateKeyEd25519 = R.partial(generatePrivateKey, ['ed25519']); // //////////////// // Generate new keypair - const privateKey = generatePrivateKeyEd25519(); - const publicKey = privateKey.toPublic(); - + const genkey = await genpk() const keyPair = { - private: R.replace(/\n/g, '\n', privateKey.toString('openssh')), - public: publicKey.toString() + private: genkey['privatekeypem'], + public: genkey['publickey'], + fingerprint: genkey['sha256fingerprint'], + type: genkey['type'] }; // Save the newly generated key @@ -172,9 +164,7 @@ const generatePrivateKeyEd25519 = R.partial(generatePrivateKey, ['ed25519']); // Find or create a user that has the public key linked to them const userRows = await query( sqlClientPool, - sshKeySql.selectUserIdsBySshKeyFingerprint( - getSshKeyFingerprint(keyPair.public) - ) + sshKeySql.selectUserIdsBySshKeyFingerprint(keyPair.fingerprint) ); const userId = R.path([0, 'usid'], userRows) as string; @@ -196,7 +186,7 @@ const generatePrivateKeyEd25519 = R.partial(generatePrivateKey, ['ed25519']); name: 'auto-add via reset', keyValue: keyParts[1], keyType: keyParts[0], - keyFingerprint: getSshKeyFingerprint(keyPair.public) + keyFingerprint: keyPair.fingerprint }) ); await query( diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index 333bffe2fc..d756a2f824 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -654,6 +654,9 @@ const resolvers = { updateSshKey, deleteSshKey, deleteSshKeyById, + addUserSSHPublicKey: addSshKey, + updateUserSSHPublicKey: updateSshKey, + deleteUserSSHPublicKey: deleteSshKeyById, deleteAllSshKeys, removeAllSshKeysFromAllUsers, addUser, diff --git a/services/api/src/resources/project/resolvers.ts b/services/api/src/resources/project/resolvers.ts index 0735422ffd..ab3b7761fa 100644 --- a/services/api/src/resources/project/resolvers.ts +++ b/services/api/src/resources/project/resolvers.ts @@ -1,16 +1,15 @@ import * as R from 'ramda'; import validator from 'validator'; -import sshpk from 'sshpk'; import { ResolverFn } from '../'; import { logger } from '../../loggers/logger'; import { knex, query, isPatchEmpty } from '../../util/db'; +import { validateKey, generatePrivateKey as genpk } from '../../util/func'; import { Helpers } from './helpers'; import { KeycloakOperations } from './keycloak'; import { OpendistroSecurityOperations } from '../group/opendistroSecurity'; import { Sql } from './sql'; import { Sql as SshKeySql} from '../sshKey/sql'; import * as OS from '../openshift/sql'; -import { generatePrivateKey, getSshKeyFingerprint } from '../sshKey'; import { Sql as sshKeySql } from '../sshKey/sql'; import { Helpers as organizationHelpers } from '../organization/helpers'; import { Helpers as notificationHelpers } from '../notification/helpers'; @@ -51,10 +50,10 @@ export const getProjectDeployKey: ResolverFn = async ( { hasPermission } ) => { try { - const privateKey = sshpk.parsePrivateKey(R.prop('privateKey', project)) + const privkey = new Buffer((R.prop('privateKey', project))).toString('base64') + const publickey = await validateKey(privkey, "private") - const keyParts = privateKey.toPublic().toString().split(' '); - return keyParts[0] + " " + keyParts[1] + return publickey['publickey'] } catch (err) { return null; } @@ -314,19 +313,29 @@ export const addProject = async ( let keyPair: any = {}; try { - const privateKey = R.cond([ - [R.isNil, generatePrivateKey], - [R.isEmpty, generatePrivateKey], - [R.T, sshpk.parsePrivateKey] - ])(R.prop('privateKey', input)); - - const publicKey = privateKey.toPublic(); - - keyPair = { - ...keyPair, - private: R.replace(/\n/g, '\n', privateKey.toString('openssh')), - public: publicKey.toString() - }; + if (R.prop('privateKey', input)) { + const privkey = new Buffer((R.prop('privateKey', input))).toString('base64') + const publickey = await validateKey(privkey, "private") + if (!publickey['sha256fingerprint']) { + throw new Error('private key failed validation'); + } + keyPair = { + ...keyPair, + private: R.replace(/\n/g, '\n', (R.prop('privateKey', input)).toString('openssh')), + public: publickey['publickey'], + fingerprint: publickey['sha256fingerprint'], + type: publickey['type'] + }; + } else { + const genkey = await genpk() + keyPair = { + ...keyPair, + private: genkey['privatekeypem'], + public: genkey['publickey'], + fingerprint: genkey['sha256fingerprint'], + type: genkey['type'] + }; + } } catch (err) { throw new Error(`There was an error with the privateKey: ${err.message}`); } @@ -415,12 +424,9 @@ export const addProject = async ( // Find or create a user that has the public key linked to them const userRows = await query( sqlClientPool, - sshKeySql.selectUserIdsBySshKeyFingerprint( - getSshKeyFingerprint(keyPair.public) - ) + sshKeySql.selectUserIdsBySshKeyFingerprint(keyPair.fingerprint) ); const userId = R.path([0, 'usid'], userRows); - let user; if (!userId) { try { @@ -431,7 +437,6 @@ export const addProject = async ( }); const keyParts = keyPair.public.split(' '); - const { insertId } = await query( sqlClientPool, sshKeySql.insertSshKey({ @@ -439,7 +444,7 @@ export const addProject = async ( name: `default-user@${project.name}`, keyValue: keyParts[1], keyType: keyParts[0], - keyFingerprint: getSshKeyFingerprint(keyPair.public) + keyFingerprint: keyPair.fingerprint }) ); await query( @@ -701,15 +706,18 @@ export const updateProject: ResolverFn = async ( let keyPair: any = {}; try { - const privateKey = sshpk.parsePrivateKey(R.prop('privateKey', patch)) - const publicKey = privateKey.toPublic(); - + const privkey = new Buffer((R.prop('privateKey', patch))).toString('base64') + const publickey = await validateKey(privkey, "private") + if (!publickey['sha256fingerprint']) { + throw new Error('new private key failed validation'); + } keyPair = { ...keyPair, - private: R.replace(/\n/g, '\n', privateKey.toString('openssh')), - public: publicKey.toString() + private: R.replace(/\n/g, '\n', (R.prop('privateKey', patch)).toString('openssh')), + public: publickey['publickey'], + fingerprint: publickey['sha256fingerprint'], + type: publickey['type'] }; - const keyParts = keyPair.public.split(' '); try { @@ -717,10 +725,10 @@ export const updateProject: ResolverFn = async ( sqlClientPool, sshKeySql.insertSshKey({ id: null, - name: 'auto-add via api', + name: `default-user@${oldProject.name}`, keyValue: keyParts[1], keyType: keyParts[0], - keyFingerprint: getSshKeyFingerprint(keyPair.public) + keyFingerprint: keyPair.fingerprint }) ); const user = await models.UserModel.loadUserByUsername( @@ -732,9 +740,14 @@ export const updateProject: ResolverFn = async ( ); // remove the old public key from the default user + const oldprivkey = new Buffer((R.prop('privateKey', oldProject))).toString('base64') + const oldKey = await validateKey(oldprivkey, "private") + if (!oldKey['sha256fingerprint']) { + throw new Error('old private key failed validation'); + } const skidResult = await query( sqlClientPool, - SshKeySql.selectSshKeyByFingerprint(getSshKeyFingerprint(sshpk.parsePrivateKey(R.prop('privateKey', oldProject)).toPublic())) + SshKeySql.selectSshKeyByFingerprint(oldKey['sha256fingerprint']) ); const skid = R.path(['0', 'id'], skidResult) as number; await query( diff --git a/services/api/src/resources/sshKey/index.ts b/services/api/src/resources/sshKey/index.ts deleted file mode 100644 index 7577eda18c..0000000000 --- a/services/api/src/resources/sshKey/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import sshpk from 'sshpk'; - -export const validateSshKey = (key: string): boolean => { - // Validate the format of the ssh key. This fails with an exception - // if the key is invalid. We are not actually interested in the - // result of the parsing and just use this for validation. - try { - sshpk.parseKey(key, 'ssh'); - return true; - } catch (e) { - return false; - } -}; - -export const getSshKeyFingerprint = (key: string): string => { - const parsed = sshpk.parseKey(key, 'ssh'); - return parsed.fingerprint('sha256', 'ssh').toString(); -}; - -export const generatePrivateKey = (type = 'ed25519') => - sshpk.generatePrivateKey(type); diff --git a/services/api/src/resources/sshKey/resolvers.ts b/services/api/src/resources/sshKey/resolvers.ts index 1e6cd88177..c97747f238 100644 --- a/services/api/src/resources/sshKey/resolvers.ts +++ b/services/api/src/resources/sshKey/resolvers.ts @@ -1,21 +1,11 @@ import * as R from 'ramda'; import { ResolverFn } from '../'; import { query, isPatchEmpty } from '../../util/db'; -import { validateSshKey, getSshKeyFingerprint } from '.'; import { Sql } from './sql'; - +import { validateKey, generatePrivateKey as genpk } from '../../util/func'; const formatSshKey = ({ keyType, keyValue }) => `${keyType} ${keyValue}`; -const sshKeyTypeToString = R.cond([ - [R.equals('SSH_RSA'), R.always('ssh-rsa')], - [R.equals('SSH_ED25519'), R.always('ssh-ed25519')], - [R.equals('ECDSA_SHA2_NISTP256'), R.always('ecdsa-sha2-nistp256')], - [R.equals('ECDSA_SHA2_NISTP384'), R.always('ecdsa-sha2-nistp384')], - [R.equals('ECDSA_SHA2_NISTP521'), R.always('ecdsa-sha2-nistp521')], - [R.T, R.identity] -]); - export const getUserSshKeys: ResolverFn = async ( { id: userId }, args, @@ -31,19 +21,26 @@ export const getUserSshKeys: ResolverFn = async ( export const addSshKey: ResolverFn = async ( root, { - input: { id, name, keyValue, keyType: unformattedKeyType, user: userInput } + input: { id, name, publicKey, keyValue, keyType, user: userInput } }, { sqlClientPool, hasPermission, models, userActivityLogger } ) => { - const keyType = sshKeyTypeToString(unformattedKeyType); - // handle key being sent as "ssh-rsa SSHKEY foo@bar.baz" as well as just the SSHKEY - const keyValueParts = keyValue.split(' '); - const keyFormatted = formatSshKey({ - keyType, - keyValue: keyValueParts.length > 1 ? keyValueParts[1] : keyValue - }); + let keyFormatted = "" + if (!publicKey) { + keyType = keyType.replaceAll('_', '-').toLowerCase(); + // handle key being sent as "ssh-rsa SSHKEY foo@bar.baz" as well as just the SSHKEY + const keyValueParts = keyValue.split(' '); + keyFormatted = formatSshKey({ + keyType, + keyValue: keyValueParts.length > 1 ? keyValueParts[1] : keyValue + }); + } else { + keyFormatted = publicKey + } - if (!validateSshKey(keyFormatted)) { + const pkey = new Buffer(keyFormatted).toString('base64') + const vkey = await validateKey(pkey, "public") + if (!vkey['sha256fingerprint']) { throw new Error('Invalid SSH key format! Please verify keyType + keyValue'); } @@ -63,9 +60,9 @@ export const addSshKey: ResolverFn = async ( Sql.insertSshKey({ id, name, - keyValue, - keyType, - keyFingerprint: getSshKeyFingerprint(keyFormatted) + keyValue: vkey['value'], + keyType: vkey['type'], + keyFingerprint: vkey['sha256fingerprint'] }) )); } catch(error) { @@ -91,9 +88,9 @@ export const addSshKey: ResolverFn = async ( input: { id, name, - keyValue, - keyType, - keyFingerprint: getSshKeyFingerprint(keyFormatted) + keyValue: vkey['value'], + keyType: vkey['type'], + keyFingerprint: vkey['sha256fingerprint'] }, data: { sshKeyId: insertId, @@ -111,13 +108,11 @@ export const updateSshKey: ResolverFn = async ( input: { id, patch, - patch: { name, keyType: unformattedKeyType, keyValue } + patch: { name, publicKey, keyType, keyValue } } }, { sqlClientPool, hasPermission, userActivityLogger } ) => { - const keyType = sshKeyTypeToString(unformattedKeyType); - const perms = await query(sqlClientPool, Sql.selectUserIdsBySshKeyId(id)); const userIds = R.map(R.prop('usid'), perms); @@ -129,17 +124,25 @@ export const updateSshKey: ResolverFn = async ( throw new Error('Input patch requires at least 1 attribute'); } - let keyFingerprint = null; - if (keyType || keyValue) { - const keyFormatted = formatSshKey({ keyType, keyValue }); - - if (!validateSshKey(keyFormatted)) { - throw new Error( - 'Invalid SSH key format! Please verify keyType + keyValue' - ); - } + let keyFormatted = "" + if (!publicKey) { + keyType = keyType.replaceAll('_', '-').toLowerCase(); + // handle key being sent as "ssh-rsa SSHKEY foo@bar.baz" as well as just the SSHKEY + const keyValueParts = keyValue.split(' '); + keyFormatted = formatSshKey({ + keyType, + keyValue: keyValueParts.length > 1 ? keyValueParts[1] : keyValue + }); + } else { + keyFormatted = publicKey + } - keyFingerprint = getSshKeyFingerprint(keyFormatted); + const pkey = new Buffer(keyFormatted).toString('base64') + const vkey = await validateKey(pkey, "public") + if (!vkey['sha256fingerprint']) { + throw new Error( + 'Invalid SSH key format! Please verify keyType + keyValue' + ); } try { @@ -149,9 +152,9 @@ export const updateSshKey: ResolverFn = async ( id, patch: { name, - keyType, - keyValue, - keyFingerprint + keyType: vkey['type'], + keyValue: vkey['value'], + keyFingerprint: vkey['sha256fingerprint'] } }) ); diff --git a/services/api/src/routes/keys.ts b/services/api/src/routes/keys.ts index 98c7871778..2191aeb511 100644 --- a/services/api/src/routes/keys.ts +++ b/services/api/src/routes/keys.ts @@ -1,23 +1,35 @@ import * as R from 'ramda'; -import sshpk from 'sshpk'; import bodyParser from 'body-parser'; import { Request, Response } from 'express'; import { RequestWithAuthData } from '../authMiddleware'; import { logger } from '../loggers/logger'; import { knex, query } from '../util/db'; import { sqlClientPool } from '../clients/sqlClient'; +import { validateKey } from '../util/func'; -const toFingerprint = sshKey => { +const toFingerprint2 = async (sshKey) => { try { - return sshpk - .parseKey(sshKey, 'ssh') - .fingerprint() - .toString(); + const pkey = new Buffer(sshKey).toString('base64') + const pubkey = await validateKey(pkey, "public") + if (pubkey['sha256fingerprint']) { + return pubkey['sha256fingerprint'] + } else { + throw new Error('not valid key') + } } catch (e) { logger.error(`Invalid ssh key: ${sshKey}`); } }; +const mapFingerprints = async (keys) => { + const fingerprintKeyMap = await Promise.all( + keys.map(async sshKey => { + const fp = await toFingerprint2(sshKey) + return {fingerprint: fp, key: sshKey} + })) + return fingerprintKeyMap +}; + const keysRoute = async ( { body: { fingerprint }, legacyCredentials }: RequestWithAuthData, res: Response, @@ -40,23 +52,10 @@ const keysRoute = async ( ); const keys = R.map(R.prop('sshKey'), rows); - // Object of fingerprints mapping to SSH keys - // Ex. { : } - const fingerprintKeyMap = R.compose( - // Transform back to object from pairs - R.fromPairs, - // Remove undefined fingerprints - // @ts-ignore - R.reject(([sshKeyFingerprint]) => sshKeyFingerprint === undefined), - // Transform from single-level array to array of pairs, with the SSH key fingerprint as the first value - // @ts-ignore - R.map(sshKey => [toFingerprint(sshKey), sshKey]), - // @ts-ignore error TS2554: Expected 0 arguments, but got 1. - )(keys); - - const result = R.propOr('', fingerprint, fingerprintKeyMap); + const fingerprintKeyMap = await mapFingerprints(keys) + const found = fingerprintKeyMap.filter(el => {if (el.fingerprint === fingerprint) { return el.key }}); - if (!result) { + if (found) { logger.debug(`Unknown fingerprint: ${fingerprint}`); } @@ -82,7 +81,7 @@ const keysRoute = async ( ); } - res.send(result); + res.send(found[0].key); }; export default [bodyParser.json(), keysRoute]; diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index 5e19113794..b38d8e9b87 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -1476,6 +1476,17 @@ const typeDefs = gql` id: Int! } + input AddUserSSHPublicKeyInput { + id: Int + name: String! + publicKey: String! + user: UserInput! + } + + input DeleteUserSSHPublicKeyByIdInput { + id: Int! + } + input AddProjectInput { id: Int name: String! @@ -2098,6 +2109,16 @@ const typeDefs = gql` patch: UpdateSshKeyPatchInput! } + input UpdateUserSSHPublicKeyPatchInput { + name: String + publicKey: String + } + + input UpdateUserSSHPublicKeyInput { + id: Int! + patch: UpdateUserSSHPublicKeyPatchInput! + } + input UpdateEnvironmentPatchInput { project: Int deployType: DeployType @@ -2367,12 +2388,15 @@ const typeDefs = gql` updateProject(input: UpdateProjectInput!): Project deleteProject(input: DeleteProjectInput!): String deleteAllProjects: String - addSshKey(input: AddSshKeyInput!): SshKey - updateSshKey(input: UpdateSshKeyInput!): SshKey - deleteSshKey(input: DeleteSshKeyInput!): String - deleteSshKeyById(input: DeleteSshKeyByIdInput!): String + addSshKey(input: AddSshKeyInput!): SshKey @deprecated(reason: "Use addUserSSHPublicKey instead") + updateSshKey(input: UpdateSshKeyInput!): SshKey @deprecated(reason: "Use updateUserSSHPublicKey instead") + deleteSshKey(input: DeleteSshKeyInput!): String @deprecated(reason: "Use deleteUserSSHPublicKey instead") + deleteSshKeyById(input: DeleteSshKeyByIdInput!): String @deprecated(reason: "Use deleteUserSSHPublicKey instead") deleteAllSshKeys: String removeAllSshKeysFromAllUsers: String + addUserSSHPublicKey(input: AddUserSSHPublicKeyInput!): SshKey + updateUserSSHPublicKey(input: UpdateUserSSHPublicKeyPatchInput!): SshKey + deleteUserSSHPublicKey(input: DeleteUserSSHPublicKeyByIdInput!): String addUser(input: AddUserInput!): User updateUser(input: UpdateUserInput!): User """ diff --git a/services/auth-server/package.json b/services/auth-server/package.json index 700e3f0d72..0fc8488a66 100644 --- a/services/auth-server/package.json +++ b/services/auth-server/package.json @@ -32,7 +32,6 @@ "morgan": "^1.9.0", "nano": "^6.4.3", "ramda": "0.25.0", - "sshpk": "^1.14.1", "winston": "^3" }, "devDependencies": { diff --git a/services/auth-server/src/routes.ts b/services/auth-server/src/routes.ts index e7780694d4..054da01a9e 100644 --- a/services/auth-server/src/routes.ts +++ b/services/auth-server/src/routes.ts @@ -1,6 +1,8 @@ import R from 'ramda'; import { Request, Response } from 'express'; -import { parseJson } from './util/routing'; +import bodyParser from 'body-parser'; + +const parseJson = bodyParser.json(); declare type keycloakGrant = { access_token: string, diff --git a/services/auth-server/src/util/routing.ts b/services/auth-server/src/util/routing.ts deleted file mode 100644 index e84d0476da..0000000000 --- a/services/auth-server/src/util/routing.ts +++ /dev/null @@ -1,50 +0,0 @@ -import R from 'ramda'; -import sshpk from 'sshpk'; -import bodyParser from 'body-parser'; -import { Request, Response, NextFunction } from 'express'; - -export function validateKey(req: Request, res: Response, next: NextFunction): void { - const key = - req.body && req.body.key && typeof req.body.key === 'string' - ? req.body.key - : ''; - - if (!key) { - return next(new Error('Missing key parameter in request body.')); - } - - try { - // Validate the format of the ssh key. This fails with an exception - // if the key is invalid. We are not actually interested in the - // result of the parsing and just use this for validation. - sshpk.parseKey(key, 'ssh'); - - // TODO: In hiera, we don't store comment / type information in the key - // itself, that means we need to extract the base64 string of the - // given ssh key and use that for the token payload... otherwise - // string comparison won't work in the authorization part (api) - - // 0 1 2 - // ssh-rsa base-64 [comment] - const parsedKey = R.compose( - R.nth(1), - R.split(' '), - R.defaultTo(''), - // @ts-ignore - )(key); - - if (parsedKey == null) { - next(new Error('Could not derive base64 key from ssh key...')); - return; - } - - // @ts-ignore - req.parsedKey = parsedKey; - - next(); - } catch (e) { - next(new Error('Invalid body.key format... is this an ssh key?')); - } -} - -export const parseJson = bodyParser.json(); diff --git a/services/webhooks2tasks/package.json b/services/webhooks2tasks/package.json index ab381b5817..f16cdbe82b 100644 --- a/services/webhooks2tasks/package.json +++ b/services/webhooks2tasks/package.json @@ -27,8 +27,7 @@ "amqp-connection-manager": "^1.4.2", "amqplib": "^0.7.1", "async-retry": "^1.2.3", - "ramda": "0.25.0", - "sshpk": "^1.16.1" + "ramda": "0.25.0" }, "devDependencies": { "@types/amqp-connection-manager": "^2.0.12", diff --git a/services/webhooks2tasks/src/handlers/gitlabProjectCreate.ts b/services/webhooks2tasks/src/handlers/gitlabProjectCreate.ts index 1177f6154d..4641e2366f 100644 --- a/services/webhooks2tasks/src/handlers/gitlabProjectCreate.ts +++ b/services/webhooks2tasks/src/handlers/gitlabProjectCreate.ts @@ -1,8 +1,8 @@ import R from 'ramda'; -import sshpk from 'sshpk'; import { sendToLagoonLogs } from '@lagoon/commons/dist/logs/lagoon-logger'; import { getProject, addDeployKeyToProject } from '@lagoon/commons/dist/gitlab/api'; import { addProject, addGroupToProject, sanitizeGroupName } from '@lagoon/commons/dist/api'; +import { validateKey } from '@lagoon/commons/dist/util/func'; import { WebhookRequestData } from '../types'; @@ -40,13 +40,10 @@ export async function gitlabProjectCreate(webhook: WebhookRequestData) { const lagoonProject = await addProject(projectName, gitUrl, openshift, productionenvironment); try { - const privateKey: any = R.pipe( - R.path(['addProject', 'privateKey']), - sshpk.parsePrivateKey, - )(lagoonProject); - const publicKey = privateKey.toPublic(); + const privkey = new Buffer((R.prop('privateKey', lagoonProject))).toString('base64') + const publickey = await validateKey(privkey, "private") - await addDeployKeyToProject(id, publicKey.toString()); + await addDeployKeyToProject(id, publickey['publickey']); } catch (err) { sendToLagoonLogs( 'error', diff --git a/yarn.lock b/yarn.lock index 6d01475ec7..29308ab39d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6761,6 +6761,11 @@ serialised-error@1.1.3: stack-trace "0.0.9" uuid "^3.0.0" +serialize-javascript@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" + integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== + serve-static@1.15.0: version "1.15.0" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" @@ -6930,7 +6935,15 @@ sqlstring@2.3.1: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" integrity sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ== -sshpk@^1.14.1, sshpk@^1.14.2, sshpk@^1.16.1, sshpk@^1.7.0: +srcset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/srcset/-/srcset-1.0.0.tgz#a5669de12b42f3b1d5e83ed03c71046fc48f41ef" + integrity sha512-UH8e80l36aWnhACzjdtLspd4TAWldXJMa45NuOkTTU+stwekswObdqM63TtQixN4PPd/vO/kxLa6RD+tUPeFMg== + dependencies: + array-uniq "^1.0.2" + number-is-nan "^1.0.0" + +sshpk@^1.14.1, sshpk@^1.7.0: version "1.18.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028" integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ== From a0b6d724716a6d9d74a7712b66e7f243da5670f3 Mon Sep 17 00:00:00 2001 From: Toby Bellwood Date: Wed, 19 Jun 2024 08:55:07 +1000 Subject: [PATCH 2/7] chore: fixup docker-compose errors --- docker-compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 746deb690a..bbabc6d0b7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -64,7 +64,7 @@ services: - ./node-packages:/app/node-packages:delegated environment: - CONSOLE_LOGGING_LEVEL=trace - - SIDECAR_HANDLER_HOST=sshkeyhandler + - SIDECAR_HANDLER_HOST=apisidecarhandler api-db-init: image: ${IMAGE_REPO:-lagoon}/api command: > @@ -113,13 +113,13 @@ services: - S3_BAAS_ACCESS_KEY_ID=minio - S3_BAAS_SECRET_ACCESS_KEY=minio123 - CONSOLE_LOGGING_LEVEL=debug - - SIDECAR_HANDLER_HOST=sshkeyhandler + - SIDECAR_HANDLER_HOST=apisidecarhandler depends_on: api-lagoon-migrations: condition: service_started keycloak: condition: service_started - keycloak: + api-sidecar-handler: condition: service_started ports: - '3000:3000' From 7dd967f098c1b268a4e206a3bbcc8c141902d236 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Wed, 19 Jun 2024 09:09:57 +1000 Subject: [PATCH 3/7] fix: update sshkey input type --- services/api/src/typeDefs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index b38d8e9b87..2f7d51bada 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -2395,7 +2395,7 @@ const typeDefs = gql` deleteAllSshKeys: String removeAllSshKeysFromAllUsers: String addUserSSHPublicKey(input: AddUserSSHPublicKeyInput!): SshKey - updateUserSSHPublicKey(input: UpdateUserSSHPublicKeyPatchInput!): SshKey + updateUserSSHPublicKey(input: UpdateUserSSHPublicKeyInput!): SshKey deleteUserSSHPublicKey(input: DeleteUserSSHPublicKeyByIdInput!): String addUser(input: AddUserInput!): User updateUser(input: UpdateUserInput!): User From 62b8aa887618e27acede8b7ca4456484c6d4d538 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Tue, 25 Jun 2024 11:53:12 +1000 Subject: [PATCH 4/7] chore: rename migration --- .../{20240506000000_sshkey.js => 20240730000000_sshkey_types.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename services/api/database/migrations/{20240506000000_sshkey.js => 20240730000000_sshkey_types.js} (100%) diff --git a/services/api/database/migrations/20240506000000_sshkey.js b/services/api/database/migrations/20240730000000_sshkey_types.js similarity index 100% rename from services/api/database/migrations/20240506000000_sshkey.js rename to services/api/database/migrations/20240730000000_sshkey_types.js From eb641e1e881b3e7b9c36e73270b124526a29fb1e Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Mon, 1 Jul 2024 08:27:07 +1000 Subject: [PATCH 5/7] chore: rename function --- services/api/src/routes/keys.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/src/routes/keys.ts b/services/api/src/routes/keys.ts index 2191aeb511..69f7a460ef 100644 --- a/services/api/src/routes/keys.ts +++ b/services/api/src/routes/keys.ts @@ -7,7 +7,7 @@ import { knex, query } from '../util/db'; import { sqlClientPool } from '../clients/sqlClient'; import { validateKey } from '../util/func'; -const toFingerprint2 = async (sshKey) => { +const toFingerprint = async (sshKey) => { try { const pkey = new Buffer(sshKey).toString('base64') const pubkey = await validateKey(pkey, "public") @@ -24,7 +24,7 @@ const toFingerprint2 = async (sshKey) => { const mapFingerprints = async (keys) => { const fingerprintKeyMap = await Promise.all( keys.map(async sshKey => { - const fp = await toFingerprint2(sshKey) + const fp = await toFingerprint(sshKey) return {fingerprint: fp, key: sshKey} })) return fingerprintKeyMap From 973a36cfd1d1dff42d5bb9becca8bc93e6f14d3c Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Mon, 1 Jul 2024 09:40:05 +1000 Subject: [PATCH 6/7] fix: return value --- services/api/src/routes/keys.ts | 46 +++++++++++++++++---------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/services/api/src/routes/keys.ts b/services/api/src/routes/keys.ts index 69f7a460ef..9d6d249745 100644 --- a/services/api/src/routes/keys.ts +++ b/services/api/src/routes/keys.ts @@ -53,35 +53,37 @@ const keysRoute = async ( const keys = R.map(R.prop('sshKey'), rows); const fingerprintKeyMap = await mapFingerprints(keys) - const found = fingerprintKeyMap.filter(el => {if (el.fingerprint === fingerprint) { return el.key }}); + const found = await fingerprintKeyMap.filter(el => {if (el.fingerprint === fingerprint) { return el.key }})[0]; - if (found) { + if (!found) { logger.debug(`Unknown fingerprint: ${fingerprint}`); - } - - // update key used timestamp - const foundkey = await query( - sqlClientPool, - knex('ssh_key') - .select('id') - .where('key_fingerprint', fingerprint) - .toString(), - ); - // check if a key is found - if (foundkey.length > 0) { - var date = new Date(); - const convertDateFormat = R.init; - var lastUsed = convertDateFormat(date.toISOString()); - await query( + // drop out + res.send(); + } else { + // update key used timestamp + const foundkey = await query( sqlClientPool, knex('ssh_key') - .where('id', foundkey[0].id) - .update({lastUsed: lastUsed}) + .select('id') + .where('key_fingerprint', fingerprint) .toString(), ); + // check if a key is found + if (foundkey.length > 0) { + var date = new Date(); + const convertDateFormat = R.init; + var lastUsed = convertDateFormat(date.toISOString()); + await query( + sqlClientPool, + knex('ssh_key') + .where('id', foundkey[0].id) + .update({lastUsed: lastUsed}) + .toString(), + ); + } + // return key + res.send(found.key); } - - res.send(found[0].key); }; export default [bodyParser.json(), keysRoute]; From 5e46ae46d565ca96584bcccec05e39aafd229189 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Tue, 2 Jul 2024 16:39:57 +1000 Subject: [PATCH 7/7] chore: rename migration --- ...40730000000_sshkey_types.js => 20240630000000_sshkey_types.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename services/api/database/migrations/{20240730000000_sshkey_types.js => 20240630000000_sshkey_types.js} (100%) diff --git a/services/api/database/migrations/20240730000000_sshkey_types.js b/services/api/database/migrations/20240630000000_sshkey_types.js similarity index 100% rename from services/api/database/migrations/20240730000000_sshkey_types.js rename to services/api/database/migrations/20240630000000_sshkey_types.js