From f23c2bb8cddf5384424ebee6d416a7554ee33eed Mon Sep 17 00:00:00 2001 From: mustard Date: Fri, 10 Jun 2022 07:44:21 +0000 Subject: [PATCH 1/4] [server,db,protocol] support ssh public key Co-authored-by: George Tsiolis --- components/gitpod-db/src/tables.ts | 6 ++ .../typeorm/entity/db-user-ssh-public-key.ts | 56 +++++++++++++++ .../1654842204415-UserSshPublicKey.ts | 17 +++++ .../gitpod-db/src/typeorm/user-db-impl.ts | 44 ++++++++++++ components/gitpod-db/src/user-db.ts | 8 +++ .../gitpod-protocol/go/gitpod-service.go | 69 +++++++++++++++++++ components/gitpod-protocol/go/mock.go | 59 ++++++++++++++++ .../gitpod-protocol/src/gitpod-service.ts | 8 +++ components/gitpod-protocol/src/protocol.ts | 62 +++++++++++++++++ .../pkg/apiv1/workspace_test.go | 16 +++++ components/server/src/auth/rate-limiter.ts | 4 ++ .../src/workspace/gitpod-server-impl.ts | 58 ++++++++++++++++ .../server/src/workspace/workspace-starter.ts | 6 ++ 13 files changed, 413 insertions(+) create mode 100644 components/gitpod-db/src/typeorm/entity/db-user-ssh-public-key.ts create mode 100644 components/gitpod-db/src/typeorm/migration/1654842204415-UserSshPublicKey.ts diff --git a/components/gitpod-db/src/tables.ts b/components/gitpod-db/src/tables.ts index 9dbdcd1ce8d8c8..c0dc721dd57094 100644 --- a/components/gitpod-db/src/tables.ts +++ b/components/gitpod-db/src/tables.ts @@ -268,6 +268,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider deletionColumn: "deleted", timeColumn: "_lastModified", }, + { + name: "d_b_user_ssh_public_key", + primaryKeys: ["id"], + deletionColumn: "deleted", + timeColumn: "_lastModified", + }, ]; public getSortedTables(): TableDescription[] { diff --git a/components/gitpod-db/src/typeorm/entity/db-user-ssh-public-key.ts b/components/gitpod-db/src/typeorm/entity/db-user-ssh-public-key.ts new file mode 100644 index 00000000000000..5b38a5349445e6 --- /dev/null +++ b/components/gitpod-db/src/typeorm/entity/db-user-ssh-public-key.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { PrimaryColumn, Column, Entity, Index } from "typeorm"; +import { TypeORM } from "../typeorm"; +import { UserSSHPublicKey } from "@gitpod/gitpod-protocol"; +import { Transformer } from "../transformer"; +import { encryptionService } from "../user-db-impl"; + +@Entity("d_b_user_ssh_public_key") +export class DBUserSshPublicKey implements UserSSHPublicKey { + @PrimaryColumn(TypeORM.UUID_COLUMN_TYPE) + id: string; + + @Column(TypeORM.UUID_COLUMN_TYPE) + @Index("ind_userId") + userId: string; + + @Column("varchar") + name: string; + + @Column({ + type: "simple-json", + // Relies on the initialization of the var in UserDbImpl + transformer: Transformer.compose( + Transformer.SIMPLE_JSON([]), + Transformer.encrypted(() => encryptionService), + ), + }) + key: string; + + @Column("varchar") + fingerprint: string; + + @Column({ + type: "timestamp", + precision: 6, + default: () => "CURRENT_TIMESTAMP(6)", + transformer: Transformer.MAP_ISO_STRING_TO_TIMESTAMP_DROP, + }) + @Index("ind_creationTime") + creationTime: string; + + @Column({ + default: "", + transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED, + }) + lastUsedTime?: string; + + // This column triggers the db-sync deletion mechanism. It's not intended for public consumption. + @Column() + deleted: boolean; +} diff --git a/components/gitpod-db/src/typeorm/migration/1654842204415-UserSshPublicKey.ts b/components/gitpod-db/src/typeorm/migration/1654842204415-UserSshPublicKey.ts new file mode 100644 index 00000000000000..9393517f40b6db --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1654842204415-UserSshPublicKey.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserSshPublicKey1654842204415 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "CREATE TABLE IF NOT EXISTS `d_b_user_ssh_public_key` ( `id` char(36) NOT NULL, `userId` char(36) NOT NULL, `name` varchar(255) NOT NULL, `key` text NOT NULL, `fingerprint` varchar(255) NOT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `creationTime` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `lastUsedTime` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), KEY ind_userId (`userId`), KEY ind_creationTime (`creationTime`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;", + ); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/components/gitpod-db/src/typeorm/user-db-impl.ts b/components/gitpod-db/src/typeorm/user-db-impl.ts index e8cda7ed23d03d..94cbd8c9de9ff9 100644 --- a/components/gitpod-db/src/typeorm/user-db-impl.ts +++ b/components/gitpod-db/src/typeorm/user-db-impl.ts @@ -10,10 +10,12 @@ import { GitpodTokenType, Identity, IdentityLookup, + SSHPublicKeyValue, Token, TokenEntry, User, UserEnvVar, + UserSSHPublicKey, } from "@gitpod/gitpod-protocol"; import { EncryptionService } from "@gitpod/gitpod-protocol/lib/encryption/encryption-service"; import { @@ -41,6 +43,7 @@ import { DBTokenEntry } from "./entity/db-token-entry"; import { DBUser } from "./entity/db-user"; import { DBUserEnvVar } from "./entity/db-user-env-vars"; import { DBWorkspace } from "./entity/db-workspace"; +import { DBUserSshPublicKey } from "./entity/db-user-ssh-public-key"; import { TypeORM } from "./typeorm"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; @@ -95,6 +98,10 @@ export class TypeORMUserDBImpl implements UserDB { return (await this.getEntityManager()).getRepository(DBUserEnvVar); } + protected async getSSHPublicKeyRepo(): Promise> { + return (await this.getEntityManager()).getRepository(DBUserSshPublicKey); + } + public async newUser(): Promise { const user: User = { id: uuidv4(), @@ -397,6 +404,43 @@ export class TypeORMUserDBImpl implements UserDB { await repo.save(envVar); } + public async hasSSHPublicKey(userId: string): Promise { + const repo = await this.getSSHPublicKeyRepo(); + return !!(await repo.findOne({ where: { userId, deleted: false } })); + } + + public async getSSHPublicKeys(userId: string): Promise { + const repo = await this.getSSHPublicKeyRepo(); + return repo.find({ where: { userId, deleted: false }, order: { creationTime: "ASC" } }); + } + + public async addSSHPublicKey(userId: string, value: SSHPublicKeyValue): Promise { + const repo = await this.getSSHPublicKeyRepo(); + const fingerprint = SSHPublicKeyValue.getFingerprint(value); + const allKeys = await repo.find({ where: { userId, deleted: false } }); + const prevOne = allKeys.find((e) => e.fingerprint === fingerprint); + if (!!prevOne) { + throw new Error(`Key already in use`); + } + if (allKeys.length > SSHPublicKeyValue.MAXIMUM_KEY_LENGTH) { + throw new Error(`The maximum of public keys is ${SSHPublicKeyValue.MAXIMUM_KEY_LENGTH}`); + } + return repo.save({ + id: uuidv4(), + userId, + fingerprint, + name: value.name, + key: value.key, + creationTime: new Date().toISOString(), + deleted: false, + }); + } + + public async deleteSSHPublicKey(userId: string, id: string): Promise { + const repo = await this.getSSHPublicKeyRepo(); + await repo.update({ userId, id }, { deleted: true }); + } + public async findAllUsers( offset: number, limit: number, diff --git a/components/gitpod-db/src/user-db.ts b/components/gitpod-db/src/user-db.ts index c68fd18b363277..88e798d2e08325 100644 --- a/components/gitpod-db/src/user-db.ts +++ b/components/gitpod-db/src/user-db.ts @@ -10,10 +10,12 @@ import { GitpodTokenType, Identity, IdentityLookup, + SSHPublicKeyValue, Token, TokenEntry, User, UserEnvVar, + UserSSHPublicKey, } from "@gitpod/gitpod-protocol"; import { OAuthTokenRepository, OAuthUserRepository } from "@jmondi/oauth2-server"; import { Repository } from "typeorm"; @@ -117,6 +119,12 @@ export interface UserDB extends OAuthUserRepository, OAuthTokenRepository { deleteEnvVar(envVar: UserEnvVar): Promise; getEnvVars(userId: string): Promise; + // User SSH Keys + hasSSHPublicKey(userId: string): Promise; + getSSHPublicKeys(userId: string): Promise; + addSSHPublicKey(userId: string, value: SSHPublicKeyValue): Promise; + deleteSSHPublicKey(userId: string, id: string): Promise; + findAllUsers( offset: number, limit: number, diff --git a/components/gitpod-protocol/go/gitpod-service.go b/components/gitpod-protocol/go/gitpod-service.go index c69a410605c2da..b8e1a89acb16e5 100644 --- a/components/gitpod-protocol/go/gitpod-service.go +++ b/components/gitpod-protocol/go/gitpod-service.go @@ -64,6 +64,10 @@ type APIInterface interface { GetEnvVars(ctx context.Context) (res []*UserEnvVarValue, err error) SetEnvVar(ctx context.Context, variable *UserEnvVarValue) (err error) DeleteEnvVar(ctx context.Context, variable *UserEnvVarValue) (err error) + HasSSHPublicKey(ctx context.Context) (res bool, err error) + GetSSHPublicKeys(ctx context.Context) (res []*UserSSHPublicKeyValue, err error) + AddSSHPublicKey(ctx context.Context, value *SSHPublicKeyValue) (res *UserSSHPublicKeyValue, err error) + DeleteSSHPublicKey(ctx context.Context, id string) (err error) GetContentBlobUploadURL(ctx context.Context, name string) (url string, err error) GetContentBlobDownloadURL(ctx context.Context, name string) (url string, err error) GetGitpodTokens(ctx context.Context) (res []*APIToken, err error) @@ -168,6 +172,14 @@ const ( FunctionSetEnvVar FunctionName = "setEnvVar" // FunctionDeleteEnvVar is the name of the deleteEnvVar function FunctionDeleteEnvVar FunctionName = "deleteEnvVar" + // FunctionHasSSHPublicKey is the name of the hasSSHPublicKey function + FunctionHasSSHPublicKey FunctionName = "hasSSHPublicKey" + // FunctionGetSSHPublicKeys is the name of the getSSHPublicKeys function + FunctionGetSSHPublicKeys FunctionName = "getSSHPublicKeys" + // FunctionAddSSHPublicKey is the name of the addSSHPublicKey function + FunctionAddSSHPublicKey FunctionName = "addSSHPublicKey" + // FunctionDeleteSSHPublicKey is the name of the deleteSSHPublicKey function + FunctionDeleteSSHPublicKey FunctionName = "deleteSSHPublicKey" // FunctionGetContentBlobUploadURL is the name fo the getContentBlobUploadUrl function FunctionGetContentBlobUploadURL FunctionName = "getContentBlobUploadUrl" // FunctionGetContentBlobDownloadURL is the name fo the getContentBlobDownloadUrl function @@ -1117,6 +1129,50 @@ func (gp *APIoverJSONRPC) DeleteEnvVar(ctx context.Context, variable *UserEnvVar return } +// HasSSHPublicKey calls hasSSHPublicKey on the server +func (gp *APIoverJSONRPC) HasSSHPublicKey(ctx context.Context) (res bool, err error) { + if gp == nil { + err = errNotConnected + return + } + var _params []interface{} + err = gp.C.Call(ctx, "hasSSHPublicKey", _params, &res) + return +} + +// GetSSHPublicKeys calls getSSHPublicKeys on the server +func (gp *APIoverJSONRPC) GetSSHPublicKeys(ctx context.Context) (res []*UserSSHPublicKeyValue, err error) { + if gp == nil { + err = errNotConnected + return + } + var _params []interface{} + err = gp.C.Call(ctx, "getSSHPublicKeys", _params, &res) + return +} + +// AddSSHPublicKey calls addSSHPublicKey on the server +func (gp *APIoverJSONRPC) AddSSHPublicKey(ctx context.Context, value *SSHPublicKeyValue) (res *UserSSHPublicKeyValue, err error) { + if gp == nil { + err = errNotConnected + return + } + _params := []interface{}{value} + err = gp.C.Call(ctx, "addSSHPublicKey", _params, &res) + return +} + +// DeleteSSHPublicKey calls deleteSSHPublicKey on the server +func (gp *APIoverJSONRPC) DeleteSSHPublicKey(ctx context.Context, id string) (err error) { + if gp == nil { + err = errNotConnected + return + } + _params := []interface{}{id} + err = gp.C.Call(ctx, "deleteSSHPublicKey", _params, nil) + return +} + // GetContentBlobUploadURL calls getContentBlobUploadUrl on the server func (gp *APIoverJSONRPC) GetContentBlobUploadURL(ctx context.Context, name string) (url string, err error) { if gp == nil { @@ -1790,6 +1846,19 @@ type UserEnvVarValue struct { Value string `json:"value,omitempty"` } +type SSHPublicKeyValue struct { + Name string `json:"name,omitempty"` + Key string `json:"key,omitempty"` +} + +type UserSSHPublicKeyValue struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + CreationTime string `json:"creationTime,omitempty"` + LastUsedTime string `json:"lastUsedTime,omitempty"` +} + // GenerateNewGitpodTokenOptions is the GenerateNewGitpodTokenOptions message type type GenerateNewGitpodTokenOptions struct { Name string `json:"name,omitempty"` diff --git a/components/gitpod-protocol/go/mock.go b/components/gitpod-protocol/go/mock.go index 6f016632438878..e963837adfaaae 100644 --- a/components/gitpod-protocol/go/mock.go +++ b/components/gitpod-protocol/go/mock.go @@ -38,6 +38,21 @@ func (m *MockAPIInterface) EXPECT() *MockAPIInterfaceMockRecorder { return m.recorder } +// AddSSHPublicKey mocks base method. +func (m *MockAPIInterface) AddSSHPublicKey(ctx context.Context, value *SSHPublicKeyValue) (*UserSSHPublicKeyValue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddSSHPublicKey", ctx, value) + ret0, _ := ret[0].(*UserSSHPublicKeyValue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddSSHPublicKey indicates an expected call of AddSSHPublicKey. +func (mr *MockAPIInterfaceMockRecorder) AddSSHPublicKey(ctx, value interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSSHPublicKey", reflect.TypeOf((*MockAPIInterface)(nil).AddSSHPublicKey), ctx, value) +} + // AdminBlockUser mocks base method. func (m *MockAPIInterface) AdminBlockUser(ctx context.Context, req *AdminBlockUserRequest) error { m.ctrl.T.Helper() @@ -151,6 +166,20 @@ func (mr *MockAPIInterfaceMockRecorder) DeleteOwnAuthProvider(ctx, params interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOwnAuthProvider", reflect.TypeOf((*MockAPIInterface)(nil).DeleteOwnAuthProvider), ctx, params) } +// DeleteSSHPublicKey mocks base method. +func (m *MockAPIInterface) DeleteSSHPublicKey(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSSHPublicKey", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSSHPublicKey indicates an expected call of DeleteSSHPublicKey. +func (mr *MockAPIInterfaceMockRecorder) DeleteSSHPublicKey(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSSHPublicKey", reflect.TypeOf((*MockAPIInterface)(nil).DeleteSSHPublicKey), ctx, id) +} + // DeleteWorkspace mocks base method. func (m *MockAPIInterface) DeleteWorkspace(ctx context.Context, id string) error { m.ctrl.T.Helper() @@ -405,6 +434,21 @@ func (mr *MockAPIInterfaceMockRecorder) GetPortAuthenticationToken(ctx, workspac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPortAuthenticationToken", reflect.TypeOf((*MockAPIInterface)(nil).GetPortAuthenticationToken), ctx, workspaceID) } +// GetSSHPublicKeys mocks base method. +func (m *MockAPIInterface) GetSSHPublicKeys(ctx context.Context) ([]*UserSSHPublicKeyValue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSSHPublicKeys", ctx) + ret0, _ := ret[0].([]*UserSSHPublicKeyValue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSSHPublicKeys indicates an expected call of GetSSHPublicKeys. +func (mr *MockAPIInterfaceMockRecorder) GetSSHPublicKeys(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSSHPublicKeys", reflect.TypeOf((*MockAPIInterface)(nil).GetSSHPublicKeys), ctx) +} + // GetSnapshots mocks base method. func (m *MockAPIInterface) GetSnapshots(ctx context.Context, workspaceID string) ([]*string, error) { m.ctrl.T.Helper() @@ -555,6 +599,21 @@ func (mr *MockAPIInterfaceMockRecorder) HasPermission(ctx, permission interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermission", reflect.TypeOf((*MockAPIInterface)(nil).HasPermission), ctx, permission) } +// HasSSHPublicKey mocks base method. +func (m *MockAPIInterface) HasSSHPublicKey(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasSSHPublicKey", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasSSHPublicKey indicates an expected call of HasSSHPublicKey. +func (mr *MockAPIInterfaceMockRecorder) HasSSHPublicKey(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasSSHPublicKey", reflect.TypeOf((*MockAPIInterface)(nil).HasSSHPublicKey), ctx) +} + // InstanceUpdates mocks base method. func (m *MockAPIInterface) InstanceUpdates(ctx context.Context, instanceID string) (<-chan *WorkspaceInstance, error) { m.ctrl.T.Helper() diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index c8e0813d077f6f..9c068dfc90457f 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -25,6 +25,8 @@ import { GuessedGitTokenScopes, ProjectEnvVar, PrebuiltWorkspace, + UserSSHPublicKeyValue, + SSHPublicKeyValue, } from "./protocol"; import { Team, @@ -149,6 +151,12 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, setEnvVar(variable: UserEnvVarValue): Promise; deleteEnvVar(variable: UserEnvVarValue): Promise; + // User SSH Keys + hasSSHPublicKey(): Promise; + getSSHPublicKeys(): Promise; + addSSHPublicKey(value: SSHPublicKeyValue): Promise; + deleteSSHPublicKey(id: string): Promise; + // Teams getTeams(): Promise; getTeamMembers(teamId: string): Promise; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index df6ca44934c4d7..4521ddca89f4f5 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -357,6 +357,68 @@ export namespace UserEnvVar { } } +export interface SSHPublicKeyValue { + name: string; + key: string; +} +export interface UserSSHPublicKey extends SSHPublicKeyValue { + id: string; + key: string; + userId: string; + fingerprint: string; + creationTime: string; + lastUsedTime?: string; +} + +export type UserSSHPublicKeyValue = Omit; + +export namespace SSHPublicKeyValue { + export function validate(value: SSHPublicKeyValue): string | undefined { + if (value.name.length === 0) { + return "Title must not be empty."; + } + if (value.name.length > 255) { + return "Title too long. Maximum value length is 255 characters."; + } + if (value.key.length === 0) { + return "Key must not be empty."; + } + try { + getData(value); + } catch (e) { + return "Key is invalid. You must supply a key in OpenSSH public key format."; + } + return; + } + + export function getData(value: SSHPublicKeyValue) { + // Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'. + const regex = + /^(?ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|ssh-ed25519|sk-ecdsa-sha2-nistp256@openssh\.com|sk-ssh-ed25519@openssh\.com) (?.*?)( (?.*?))?$/; + const resultGroup = regex.exec(value.key.trim()); + if (!resultGroup) { + throw new Error("Key is invalid."); + } + return { + type: resultGroup.groups?.["type"] as string, + key: resultGroup.groups?.["key"] as string, + email: resultGroup.groups?.["email"] || undefined, + }; + } + + export function getFingerprint(value: SSHPublicKeyValue) { + const data = getData(value); + let buf = Buffer.from(data.key, "base64"); + // gitlab style + // const hash = createHash("md5").update(buf).digest("hex"); + // github style + const hash = createHash("sha256").update(buf).digest("base64"); + return hash; + } + + export const MAXIMUM_KEY_LENGTH = 5; +} + export interface GitpodToken { /** Hash value (SHA256) of the token (primary key). */ tokenHash: string; diff --git a/components/public-api-server/pkg/apiv1/workspace_test.go b/components/public-api-server/pkg/apiv1/workspace_test.go index e7b3ba7369ebef..d35c03f055f66f 100644 --- a/components/public-api-server/pkg/apiv1/workspace_test.go +++ b/components/public-api-server/pkg/apiv1/workspace_test.go @@ -370,6 +370,22 @@ func (f *FakeGitpodAPI) DeleteEnvVar(ctx context.Context, variable *gitpod.UserE panic("implement me") } +func (f *FakeGitpodAPI) HasSSHPublicKey(ctx context.Context) (res bool, err error) { + panic("implement me") +} + +func (f *FakeGitpodAPI) GetSSHPublicKeys(ctx context.Context) (res []*gitpod.UserSSHPublicKeyValue, err error) { + panic("implement me") +} + +func (f *FakeGitpodAPI) AddSSHPublicKey(ctx context.Context, value *gitpod.SSHPublicKeyValue) (res *gitpod.UserSSHPublicKeyValue, err error) { + panic("implement me") +} + +func (f *FakeGitpodAPI) DeleteSSHPublicKey(ctx context.Context, id string) (err error) { + panic("implement me") +} + func (f *FakeGitpodAPI) GetContentBlobUploadURL(ctx context.Context, name string) (url string, err error) { panic("implement me") } diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index ff83927de4c7b8..37df085b09e532 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -92,6 +92,10 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { getAllEnvVars: { group: "default", points: 1 }, setEnvVar: { group: "default", points: 1 }, deleteEnvVar: { group: "default", points: 1 }, + hasSSHPublicKey: { group: "default", points: 1 }, + getSSHPublicKeys: { group: "default", points: 1 }, + addSSHPublicKey: { group: "default", points: 1 }, + deleteSSHPublicKey: { group: "default", points: 1 }, setProjectEnvironmentVariable: { group: "default", points: 1 }, getProjectEnvironmentVariables: { group: "default", points: 1 }, deleteProjectEnvironmentVariable: { group: "default", points: 1 }, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index efa3be3d3327cb..90bdd6a1a901b9 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -75,6 +75,8 @@ import { ClientHeaderFields, Permission, SnapshotContext, + SSHPublicKeyValue, + UserSSHPublicKeyValue, } from "@gitpod/gitpod-protocol"; import { AccountStatement } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; import { @@ -119,6 +121,7 @@ import { PortSpec, PortVisibility as ProtoPortVisibility, StopWorkspacePolicy, + UpdateSSHKeyRequest, } from "@gitpod/ws-manager/lib/core_pb"; import * as crypto from "crypto"; import { inject, injectable } from "inversify"; @@ -1930,6 +1933,61 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { await this.userDB.deleteEnvVar(envvar); } + async hasSSHPublicKey(ctx: TraceContext): Promise { + const user = this.checkUser("hasSSHPublicKey"); + return this.userDB.hasSSHPublicKey(user.id); + } + + async getSSHPublicKeys(ctx: TraceContext): Promise { + const user = this.checkUser("getSSHPublicKeys"); + const list = await this.userDB.getSSHPublicKeys(user.id); + return list.map((e) => ({ + id: e.id, + name: e.name, + fingerprint: e.fingerprint, + creationTime: e.creationTime, + lastUsedTime: e.lastUsedTime, + })); + } + + async addSSHPublicKey(ctx: TraceContext, value: SSHPublicKeyValue): Promise { + const user = this.checkUser("addSSHPublicKey"); + const data = await this.userDB.addSSHPublicKey(user.id, value); + this.updateSSHKeysForRegularRunningInstances(ctx, user.id).catch(console.error); + return { + id: data.id, + name: data.name, + fingerprint: data.fingerprint, + creationTime: data.creationTime, + lastUsedTime: data.lastUsedTime, + }; + } + + async deleteSSHPublicKey(ctx: TraceContext, id: string): Promise { + const user = this.checkUser("deleteSSHPublicKey"); + await this.userDB.deleteSSHPublicKey(user.id, id); + this.updateSSHKeysForRegularRunningInstances(ctx, user.id).catch(console.error); + return; + } + + protected async updateSSHKeysForRegularRunningInstances(ctx: TraceContext, userId: string) { + const keys = (await this.userDB.getSSHPublicKeys(userId)).map((e) => e.key); + const instances = await this.workspaceDb.trace(ctx).findRegularRunningInstances(userId); + const updateKeyOfInstance = async (instance: WorkspaceInstance) => { + try { + const req = new UpdateSSHKeyRequest(); + req.setId(instance.id); + req.setKeysList(keys); + const cli = await this.workspaceManagerClientProvider.get(instance.region); + await cli.updateSSHPublicKey(ctx, req); + } catch (err) { + const logCtx = { userId, instanceId: instance.id }; + log.error(logCtx, "Could not update ssh public key for instance", err); + } + }; + return Promise.allSettled(instances.map((e) => updateKeyOfInstance(e))); + } + async setProjectEnvironmentVariable( ctx: TraceContext, projectId: string, diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 2c556a4555c4bc..44de93066ef2b3 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -1395,6 +1395,8 @@ export class WorkspaceStarter { } spec.setAdmission(admissionLevel); spec.setVolumeSnapshot(volumeSnapshotInfo); + const sshKeys = await this.userDB.trace(traceCtx).getSSHPublicKeys(user.id); + spec.setSshPublicKeysList(sshKeys.map((e) => e.key)); return spec; } @@ -1428,6 +1430,10 @@ export class WorkspaceStarter { "function:getEnvVars", "function:setEnvVar", "function:deleteEnvVar", + "function:hasSSHPublicKey", + "function:getSSHPublicKeys", + "function:addSSHPublicKey", + "function:deleteSSHPublicKey", "function:trackEvent", "resource:" + From e35e372a0712a397957e16cae894a694adb2641e Mon Sep 17 00:00:00 2001 From: mustard Date: Fri, 10 Jun 2022 07:44:33 +0000 Subject: [PATCH 2/4] Add test cases ssh public keys namespace --- .../gitpod-protocol/src/protocol.spec.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 components/gitpod-protocol/src/protocol.spec.ts diff --git a/components/gitpod-protocol/src/protocol.spec.ts b/components/gitpod-protocol/src/protocol.spec.ts new file mode 100644 index 00000000000000..65007f4f0e6765 --- /dev/null +++ b/components/gitpod-protocol/src/protocol.spec.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2020 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { suite, test } from "mocha-typescript"; +import * as chai from "chai"; +import { SSHPublicKeyValue } from "."; + +const expect = chai.expect; + +@suite +class TestSSHPublicKeyValue { + private key = + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDCnrN9UdK1bNGPmZfenTWXLuYYDjlYvZE8S+WOfP08WpR1GETzX5ZvgYOEZGwEE8KUPHC9cge4Hvo/ydIS9aqbZ5MiVGJ8cAIq1Ic89SjlDWU6fl8TwIqOPCi2imAASlEDP4q8vMLK1N6UOW1EVbxyL3uybGd10ysC1t1FxFPveIGNsYE/MOQeuEWS16AplpXYXIfVRSlgAskeBft2w8Ud3B4gNe8ECLA/FXu96UpvZkdtOarA3JZ9Z27GveNJg9Mtmmw0+US0KXiO9x9NyH7G8+mqVDwDY+nNvaFA5gtQxkkl/uY2oz9k/B4Rjlj3jOiUXe5uQs3XUm5m8g9a9fh62DabLpA2fEvtfg+a/VqNe52dNa5YjupwvBd6Inb5uMW/TYjNl6bNHPlXFKw/nwLOVzukpkjxMZUKS6+4BGkpoasj6y2rTU/wkpbdD8J7yjI1p6J9aKkC6KksIWgN7xGmHkv2PCGDqMHTNbnQyowtNKMgA/667vAYJ0qW7HAHBFXJRs6uRi/DI3+c1QV2s4wPCpEHDIYApovQ0fbON4WDPoGMyHd7kPh9xB/bX7Dj0uMXImu1pdTd62fQ/1XXX64+vjAAXS/P9RSCD0RCRt/K3LPKl2m7GPI3y1niaE52XhxZw+ms9ays6NasNVMw/ZC+f02Ti+L5FBEVf8230RVVRQ== notfound@gitpod.io"; + + @test public testValidate() { + const key = this.key; + const [t, k, e] = key.split(" "); + expect( + SSHPublicKeyValue.getData({ + key, + name: "NiceName", + }), + ).to.deep.equal({ type: t, key: k, email: e }); + } + + @test public testValidateWithDiffType() { + const key = this.key; + const [_, k, e] = key.split(" "); + expect( + SSHPublicKeyValue.getData({ + key: key.replace("ssh-rsa", "sk-ecdsa-sha2-nistp256@openssh.com"), + name: "NiceName", + }), + ).to.deep.equal({ type: "sk-ecdsa-sha2-nistp256@openssh.com", key: k, email: e }); + } + + @test public testValidateWithoutEmail() { + const key = this.key; + const [t, k, _] = key.split(" "); + expect( + SSHPublicKeyValue.getData({ + key: key.replace(" notfound@gitpod.io", ""), + name: "NiceName", + }), + ).to.deep.equal({ type: t, key: k, email: undefined }); + } + + @test public testValidateWithoutEmailButEndsWithSpaces() { + const key = this.key; + const [t, k, _] = key.split(" "); + expect( + SSHPublicKeyValue.getData({ + key: key.replace("notfound@gitpod.io", " "), + name: "NiceName", + }), + ).to.deep.equal({ type: t, key: k, email: undefined }); + } + + @test public testValidateWithError() { + expect(() => + SSHPublicKeyValue.getData({ + key: "Hello World", + name: "NiceName", + }), + ).throw("Key is invalid"); + + expect(() => + SSHPublicKeyValue.getData({ + key: "", + name: "NiceName", + }), + ).throw("Key is invalid"); + } + + @test public testGetFingerprint() { + const key = this.key; + expect( + SSHPublicKeyValue.getFingerprint({ + key, + name: "NiceName", + }), + ).to.equal("ykjP/b5aqoa3envmXzWpPMCGgEFMu3QvubfSTNrJCMA="); + } + + @test public testGetFingerprintWithIncorrectPublicKey() { + expect(() => + SSHPublicKeyValue.getFingerprint({ + key: "Hello World", + name: "NiceName", + }), + ).to.throw("Key is invalid"); + } +} +module.exports = new TestSSHPublicKeyValue(); // Only to circumvent no usage warning :-/ From c5178fa54cf7531627d9342833ddd4c752ac9f6d Mon Sep 17 00:00:00 2001 From: mustard Date: Tue, 14 Jun 2022 19:08:12 +0000 Subject: [PATCH 3/4] [dashboard] ssh keys setting support --- components/dashboard/src/App.tsx | 3 + .../dashboard/src/components/ItemsList.tsx | 15 +- components/dashboard/src/components/Modal.tsx | 6 +- components/dashboard/src/icons/Key.svg | 3 + components/dashboard/src/index.css | 3 + components/dashboard/src/settings/SSHKeys.tsx | 258 ++++++++++++++++++ .../dashboard/src/settings/settings-menu.ts | 5 + .../dashboard/src/settings/settings.routes.ts | 2 + components/dashboard/tailwind.config.js | 108 ++++---- 9 files changed, 339 insertions(+), 64 deletions(-) create mode 100644 components/dashboard/src/icons/Key.svg create mode 100644 components/dashboard/src/settings/SSHKeys.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 98ddf2c2f2f414..266ecb80473d27 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -34,6 +34,7 @@ import { settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables, + settingsPathSSHKeys, } from "./settings/settings.routes"; import { projectsPathInstallGitHubApp, @@ -56,6 +57,7 @@ const Billing = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/ const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Plans")); const Teams = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Teams")); const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/EnvironmentVariables")); +const SSHKeys = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/SSHKeys")); const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Integrations")); const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Preferences")); const Open = React.lazy(() => import(/* webpackPrefetch: true */ "./start/Open")); @@ -352,6 +354,7 @@ function App() { + diff --git a/components/dashboard/src/components/ItemsList.tsx b/components/dashboard/src/components/ItemsList.tsx index 2e7372e9dd5670..3a4db01581f872 100644 --- a/components/dashboard/src/components/ItemsList.tsx +++ b/components/dashboard/src/components/ItemsList.tsx @@ -10,9 +10,11 @@ export function ItemsList(props: { children?: React.ReactNode; className?: strin return
{props.children}
; } -export function Item(props: { children?: React.ReactNode; className?: string; header?: boolean }) { +export function Item(props: { children?: React.ReactNode; className?: string; header?: boolean; solid?: boolean }) { + // cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 + const solidClassName = props.solid ? "bg-gray-50 dark:bg-gray-800" : "hover:bg-gray-100 dark:hover:bg-gray-800"; const headerClassName = "text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800"; - const notHeaderClassName = "rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light"; + const notHeaderClassName = "rounded-xl focus:bg-gitpod-kumquat-light " + solidClassName; return (
{props.children}
; } -export function ItemFieldContextMenu(props: { menuEntries: ContextMenuEntry[]; className?: string }) { +export function ItemFieldContextMenu(props: { + menuEntries: ContextMenuEntry[]; + className?: string; + position?: "start" | "center" | "end"; +}) { + const cls = "self-" + (props.position ?? "center"); return (
diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index 0dc05e9e839dd6..c6e3f6b8cdbc76 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -19,7 +19,7 @@ export default function Modal(props: { closeable?: boolean; className?: string; onClose: () => void; - onEnter?: () => boolean; + onEnter?: () => boolean | Promise; }) { const closeModal = (manner: CloseModalManner) => { props.onClose(); @@ -36,7 +36,7 @@ export default function Modal(props: { .then() .catch(console.error); }; - const handler = (evt: KeyboardEvent) => { + const handler = async (evt: KeyboardEvent) => { if (!props.visible) { return; } @@ -48,7 +48,7 @@ export default function Modal(props: { } if (evt.key === "Enter") { if (props.onEnter) { - if (props.onEnter()) { + if (await props.onEnter()) { closeModal("enter"); } } else { diff --git a/components/dashboard/src/icons/Key.svg b/components/dashboard/src/icons/Key.svg new file mode 100644 index 00000000000000..09e7beaefeff1a --- /dev/null +++ b/components/dashboard/src/icons/Key.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/dashboard/src/index.css b/components/dashboard/src/index.css index a6f669a7539b43..ba3b102e316adb 100644 --- a/components/dashboard/src/index.css +++ b/components/dashboard/src/index.css @@ -77,12 +77,14 @@ @apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500; } + textarea, input[type="text"], input[type="search"], input[type="password"], select { @apply block w-56 text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-md border border-gray-300 dark:border-gray-500 focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0; } + textarea::placeholder, input[type="text"]::placeholder, input[type="search"]::placeholder, input[type="password"]::placeholder { @@ -93,6 +95,7 @@ select.error { @apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red; } + textarea[disabled], input[disabled] { @apply bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500; } diff --git a/components/dashboard/src/settings/SSHKeys.tsx b/components/dashboard/src/settings/SSHKeys.tsx new file mode 100644 index 00000000000000..4d8a13fe460c7b --- /dev/null +++ b/components/dashboard/src/settings/SSHKeys.tsx @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { useContext, useEffect, useState } from "react"; +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import getSettingsMenu from "./settings-menu"; +import { PaymentContext } from "../payment-context"; +import Modal from "../components/Modal"; +import Alert from "../components/Alert"; +import { Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; +import ConfirmationModal from "../components/ConfirmationModal"; +import { SSHPublicKeyValue, UserSSHPublicKeyValue } from "@gitpod/gitpod-protocol"; +import { getGitpodService } from "../service/service"; +import moment from "moment"; + +interface AddModalProps { + value: SSHPublicKeyValue; + onClose: () => void; + onSave: () => void; +} + +interface DeleteModalProps { + value: UserSSHPublicKeyValue; + onConfirm: () => void; + onClose: () => void; +} + +export function AddSSHKeyModal(props: AddModalProps) { + const [errorMsg, setErrorMsg] = useState(""); + + const [value, setValue] = useState({ ...props.value }); + const update = (pev: Partial) => { + setValue({ ...value, ...pev }); + setErrorMsg(""); + }; + + useEffect(() => { + setValue({ ...props.value }); + setErrorMsg(""); + }, [props.value]); + + const save = async () => { + const tmp = SSHPublicKeyValue.validate(value); + if (tmp) { + setErrorMsg(tmp); + return false; + } + try { + await getGitpodService().server.addSSHPublicKey(value); + } catch (e) { + setErrorMsg(e.message.replace("Request addSSHPublicKey failed with message: ", "")); + return false; + } + props.onClose(); + props.onSave(); + return true; + }; + + return ( + + Add SSH Key + + } + visible={true} + onClose={props.onClose} + onEnter={save} + > + <> + {errorMsg.length > 0 && ( + + {errorMsg} + + )} + +
+ Add an SSH key for secure access workspaces via SSH.{" "} + + Learn more + +
+ + SSH key are used to connect securely to workspaces.{" "} + + Learn how to create an SSH Key + + +
+

Key

+