+
props.onClose()}>
Close
+ }
+ visible={true}
+ onClose={props.onClose}
+ >
+
+
);
diff --git a/components/dashboard/tailwind.config.js b/components/dashboard/tailwind.config.js
index 16c6d2d7628c83..8695dd4b58b082 100644
--- a/components/dashboard/tailwind.config.js
+++ b/components/dashboard/tailwind.config.js
@@ -5,16 +5,13 @@
*/
// tailwind.config.js
-const colors = require('tailwindcss/colors');
+const colors = require("tailwindcss/colors");
module.exports = {
jit: true,
- purge: [
- './public/**/*.html',
- './src/**/*.{js,ts,tsx}',
- ],
+ purge: ["./public/**/*.html", "./src/**/*.{js,ts,tsx}"],
important: true,
- darkMode: 'class',
+ darkMode: "class",
theme: {
extend: {
colors: {
@@ -22,79 +19,76 @@ module.exports = {
green: colors.lime,
orange: colors.amber,
blue: {
- light: '#75A9EC',
- DEFAULT: '#5C8DD6',
- dark: '#265583',
+ light: "#75A9EC",
+ DEFAULT: "#5C8DD6",
+ dark: "#265583",
},
- 'gitpod-black': '#161616',
- 'gitpod-gray': '#8E8787',
- 'gitpod-red': '#CE4A3E',
- 'gitpod-kumquat-light': '#FFE4BC',
- 'gitpod-kumquat': '#FFB45B',
- 'gitpod-kumquat-dark': '#FF8A00',
- 'gitpod-kumquat-darker': '#f28300',
- 'gitpod-kumquat-gradient': 'linear-gradient(137.41deg, #FFAD33 14.37%, #FF8A00 91.32%)',
+ "gitpod-black": "#161616",
+ "gitpod-gray": "#8E8787",
+ "gitpod-red": "#CE4A3E",
+ "gitpod-kumquat-light": "#FFE4BC",
+ "gitpod-kumquat": "#FFB45B",
+ "gitpod-kumquat-dark": "#FF8A00",
+ "gitpod-kumquat-darker": "#f28300",
+ "gitpod-kumquat-gradient": "linear-gradient(137.41deg, #FFAD33 14.37%, #FF8A00 91.32%)",
},
container: {
center: true,
},
outline: {
- blue: '1px solid #000033',
+ blue: "1px solid #000033",
+ },
+ width: {
+ 112: "28rem",
+ 128: "32rem",
},
},
fontFamily: {
sans: [
- 'Inter',
- 'system-ui',
- '-apple-system',
- 'BlinkMacSystemFont',
- 'Segoe UI',
- 'Roboto',
- 'Helvetica Neue',
- 'Arial',
- 'Noto Sans',
- 'sans-serif',
- 'Apple Color Emoji',
- 'Segoe UI Emoji',
- 'Segoe UI Symbol',
- 'Noto Color Emoji',
- ],
- mono: [
- 'SF Mono',
- 'Monaco',
- 'Inconsolata',
- 'Fira Mono',
- 'Droid Sans Mono',
- 'Source Code Pro',
- 'monospace'
+ "Inter",
+ "system-ui",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ "Segoe UI",
+ "Roboto",
+ "Helvetica Neue",
+ "Arial",
+ "Noto Sans",
+ "sans-serif",
+ "Apple Color Emoji",
+ "Segoe UI Emoji",
+ "Segoe UI Symbol",
+ "Noto Color Emoji",
],
+ mono: ["SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", "Source Code Pro", "monospace"],
},
underlineThickness: {
- 'thin': '2px',
- 'thick': '5px'
+ thin: "2px",
+ thick: "5px",
},
underlineOffset: {
- 'small': '2px',
- 'medium': '5px',
+ small: "2px",
+ medium: "5px",
},
- filter: { // defaults to {}
+ filter: {
+ // defaults to {}
// https://github.com/benface/tailwindcss-filters#usage
- 'none': 'none',
- 'grayscale': 'grayscale(1)',
- 'invert': 'invert(1)',
- 'brightness-10': 'brightness(10)',
+ none: "none",
+ grayscale: "grayscale(1)",
+ invert: "invert(1)",
+ "brightness-10": "brightness(10)",
},
},
variants: {
extend: {
- opacity: ['disabled'],
- display: ['dark'],
- }
+ opacity: ["disabled"],
+ display: ["dark"],
+ },
},
plugins: [
- require('@tailwindcss/forms'),
- require('tailwind-underline-utils'),
- require('tailwindcss-filters'),
+ require("@tailwindcss/forms"),
+ require("tailwind-underline-utils"),
+ require("tailwindcss-filters"),
// ...
],
-};
\ No newline at end of file
+};
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.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 :-/
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:" +