Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "4.11.3",
"@material-ui/lab": "^4.0.0-alpha.61",
"@primer/octicons-react": "^19.18.0",
"@seald-io/nedb": "^4.1.2",
"axios": "^1.12.2",
Expand All @@ -52,6 +53,7 @@
"concurrently": "^9.2.1",
"connect-mongo": "^5.1.0",
"cors": "^2.8.5",
"dayjs": "^1.11.13",
"diff2html": "^3.4.52",
"env-paths": "^3.0.0",
"express": "^4.21.2",
Expand Down Expand Up @@ -81,6 +83,7 @@
"react-router-dom": "6.30.1",
"simple-git": "^3.28.0",
"ssh2": "^1.16.0",
"sshpk": "^1.18.0",
"uuid": "^11.1.0",
"validator": "^13.15.15",
"yargs": "^17.7.2"
Expand Down
48 changes: 40 additions & 8 deletions src/cli/ssh-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { utils } from 'ssh2';
import * as crypto from 'crypto';

const API_BASE_URL = process.env.GIT_PROXY_API_URL || 'http://localhost:3000';
const GIT_PROXY_COOKIE_FILE = path.join(
Expand All @@ -23,6 +25,23 @@ interface ErrorWithResponse {
message: string;
}

// Calculate SHA-256 fingerprint from SSH public key
// Note: This function is duplicated in src/service/routes/users.js to keep CLI and server independent
function calculateFingerprint(publicKeyStr: string): string | null {
try {
const parsed = utils.parseKey(publicKeyStr);
if (!parsed || parsed instanceof Error) {
return null;
}
const pubKey = parsed.getPublicSSH();
const hash = crypto.createHash('sha256').update(pubKey).digest('base64');
return `SHA256:${hash}`;
} catch (err) {
console.error('Error calculating fingerprint:', err);
return null;
}
}

async function addSSHKey(username: string, keyPath: string): Promise<void> {
try {
// Check for authentication
Expand Down Expand Up @@ -90,15 +109,28 @@ async function removeSSHKey(username: string, keyPath: string): Promise<void> {
// Read the public key file
const publicKey = fs.readFileSync(keyPath, 'utf8').trim();

// Make the API request
await axios.delete(`${API_BASE_URL}/api/v1/user/${username}/ssh-keys`, {
data: { publicKey },
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Cookie: cookies,
// Strip the comment from the key (everything after the last space)
const keyWithoutComment = publicKey.split(' ').slice(0, 2).join(' ');

// Calculate fingerprint
const fingerprint = calculateFingerprint(keyWithoutComment);
if (!fingerprint) {
console.error('Invalid SSH key format. Unable to calculate fingerprint.');
process.exit(1);
}

console.log(`Removing SSH key with fingerprint: ${fingerprint}`);

// Make the API request using fingerprint in path
await axios.delete(
`${API_BASE_URL}/api/v1/user/${username}/ssh-keys/${encodeURIComponent(fingerprint)}`,
{
withCredentials: true,
headers: {
Cookie: cookies,
},
},
});
);

console.log('SSH key removed successfully!');
} catch (error) {
Expand Down
50 changes: 0 additions & 50 deletions src/config/generated/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ export interface GitProxyConfig {
* Provide domains to use alternative to the defaults
*/
domains?: { [key: string]: any };
/**
* Limits for git operations such as maximum pack size
*/
limits?: Limits;
/**
* List of plugins to integrate on GitProxy's push or pull actions. Each value is either a
* file path or a module name.
Expand Down Expand Up @@ -152,17 +148,6 @@ export interface Gitleaks {
[property: string]: any;
}

/**
* Limits for git operations
*/
export interface Limits {
/**
* Maximum allowed size of git packfiles in bytes
*/
maxPackSizeBytes?: number;
[property: string]: any;
}

/**
* Configuration used in conjunction with ActiveDirectory auth, which relates to a REST API
* used to check user group membership, as opposed to direct querying via LDAP.<br />If this
Expand Down Expand Up @@ -330,10 +315,6 @@ export interface SSH {
* Port for SSH proxy server to listen on
*/
port?: number;
/**
* Credentials used when cloning repositories for SSH-originated pushes
*/
clone?: SSHClone;
[property: string]: any;
}

Expand All @@ -352,23 +333,6 @@ export interface HostKey {
[property: string]: any;
}

/**
* Configuration for cloning repositories during SSH pushes
*/
export interface SSHClone {
serviceToken?: ServiceToken;
[property: string]: any;
}

/**
* Basic authentication credentials used for cloning operations
*/
export interface ServiceToken {
username?: string;
password?: string;
[property: string]: any;
}

/**
* Toggle the generation of temporary password for git-proxy admin user
*/
Expand Down Expand Up @@ -610,7 +574,6 @@ const typeMap: any = {
{ json: 'cookieSecret', js: 'cookieSecret', typ: u(undefined, '') },
{ json: 'csrfProtection', js: 'csrfProtection', typ: u(undefined, true) },
{ json: 'domains', js: 'domains', typ: u(undefined, m('any')) },
{ json: 'limits', js: 'limits', typ: u(undefined, r('Limits')) },
{ json: 'plugins', js: 'plugins', typ: u(undefined, a('')) },
{ json: 'privateOrganizations', js: 'privateOrganizations', typ: u(undefined, a('any')) },
{ json: 'proxyUrl', js: 'proxyUrl', typ: u(undefined, '') },
Expand Down Expand Up @@ -645,7 +608,6 @@ const typeMap: any = {
],
'any',
),
Limits: o([{ json: 'maxPackSizeBytes', js: 'maxPackSizeBytes', typ: u(undefined, 3.14) }], 'any'),
Ls: o([{ json: 'userInADGroup', js: 'userInADGroup', typ: u(undefined, '') }], false),
AuthenticationElement: o(
[
Expand Down Expand Up @@ -718,7 +680,6 @@ const typeMap: any = {
{ json: 'enabled', js: 'enabled', typ: true },
{ json: 'hostKey', js: 'hostKey', typ: u(undefined, r('HostKey')) },
{ json: 'port', js: 'port', typ: u(undefined, 3.14) },
{ json: 'clone', js: 'clone', typ: u(undefined, r('SSHClone')) },
],
'any',
),
Expand All @@ -729,17 +690,6 @@ const typeMap: any = {
],
'any',
),
SSHClone: o(
[{ json: 'serviceToken', js: 'serviceToken', typ: u(undefined, r('ServiceToken')) }],
'any',
),
ServiceToken: o(
[
{ json: 'username', js: 'username', typ: u(undefined, '') },
{ json: 'password', js: 'password', typ: u(undefined, '') },
],
'any',
),
TempPassword: o(
[
{ json: 'emailConfig', js: 'emailConfig', typ: u(undefined, m('any')) },
Expand Down
1 change: 1 addition & 0 deletions src/db/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export const {
updateUser,
addPublicKey,
removePublicKey,
getPublicKeys,
} = users;
41 changes: 29 additions & 12 deletions src/db/file/users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs';
import Datastore from '@seald-io/nedb';
import { User } from '../types';
import { User, PublicKeyRecord } from '../types';

const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day

Expand Down Expand Up @@ -176,7 +176,7 @@ export const getUsers = (query: any = {}): Promise<User[]> => {
});
};

export const addPublicKey = (username: string, publicKey: string): Promise<void> => {
export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise<void> => {
return new Promise((resolve, reject) => {
findUser(username)
.then((user) => {
Expand All @@ -187,20 +187,28 @@ export const addPublicKey = (username: string, publicKey: string): Promise<void>
if (!user.publicKeys) {
user.publicKeys = [];
}
if (!user.publicKeys.includes(publicKey)) {
user.publicKeys.push(publicKey);
updateUser(user)
.then(() => resolve())
.catch(reject);
} else {
resolve();

// Check if key already exists (by key content or fingerprint)
const keyExists = user.publicKeys.some(
(k) =>
k.key === publicKey.key || (k.fingerprint && k.fingerprint === publicKey.fingerprint),
);

if (keyExists) {
reject(new Error('SSH key already exists'));
return;
}

user.publicKeys.push(publicKey);
updateUser(user)
.then(() => resolve())
.catch(reject);
})
.catch(reject);
});
};

export const removePublicKey = (username: string, publicKey: string): Promise<void> => {
export const removePublicKey = (username: string, fingerprint: string): Promise<void> => {
return new Promise((resolve, reject) => {
findUser(username)
.then((user) => {
Expand All @@ -213,7 +221,7 @@ export const removePublicKey = (username: string, publicKey: string): Promise<vo
resolve();
return;
}
user.publicKeys = user.publicKeys.filter((key) => key !== publicKey);
user.publicKeys = user.publicKeys.filter((k) => k.fingerprint !== fingerprint);
updateUser(user)
.then(() => resolve())
.catch(reject);
Expand All @@ -224,7 +232,7 @@ export const removePublicKey = (username: string, publicKey: string): Promise<vo

export const findUserBySSHKey = (sshKey: string): Promise<User | null> => {
return new Promise<User | null>((resolve, reject) => {
db.findOne({ publicKeys: sshKey }, (err: Error | null, doc: User) => {
db.findOne({ 'publicKeys.key': sshKey }, (err: Error | null, doc: User) => {
// ignore for code coverage as neDB rarely returns errors even for an invalid query
/* istanbul ignore if */
if (err) {
Expand All @@ -239,3 +247,12 @@ export const findUserBySSHKey = (sshKey: string): Promise<User | null> => {
});
});
};

export const getPublicKeys = (username: string): Promise<PublicKeyRecord[]> => {
return findUser(username).then((user) => {
if (!user) {
throw new Error('User not found');
}
return user.publicKeys || [];
});
};
8 changes: 7 additions & 1 deletion src/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AuthorisedRepo } from '../config/types';
import { PushQuery, Repo, Sink, User } from './types';
import { PushQuery, Repo, Sink, User, PublicKeyRecord } from './types';
import * as bcrypt from 'bcryptjs';
import * as config from '../config';
import * as mongo from './mongo';
Expand Down Expand Up @@ -172,3 +172,9 @@ export const findUserBySSHKey = (sshKey: string): Promise<User | null> =>
export const getUsers = (query?: object): Promise<User[]> => sink.getUsers(query);
export const deleteUser = (username: string): Promise<void> => sink.deleteUser(username);
export const updateUser = (user: User): Promise<void> => sink.updateUser(user);
export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise<void> =>
sink.addPublicKey(username, publicKey);
export const removePublicKey = (username: string, fingerprint: string): Promise<void> =>
sink.removePublicKey(username, fingerprint);
export const getPublicKeys = (username: string): Promise<PublicKeyRecord[]> =>
sink.getPublicKeys(username);
1 change: 1 addition & 0 deletions src/db/mongo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export const {
updateUser,
addPublicKey,
removePublicKey,
getPublicKeys,
} = users;
35 changes: 29 additions & 6 deletions src/db/mongo/users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OptionalId, Document, ObjectId } from 'mongodb';
import { toClass } from '../helper';
import { User } from '../types';
import { User, PublicKeyRecord } from '../types';
import { connect } from './helper';
import _ from 'lodash';
const collectionName = 'users';
Expand Down Expand Up @@ -68,24 +68,47 @@ export const updateUser = async (user: User): Promise<void> => {
await collection.updateOne(filter, { $set: userWithoutId }, options);
};

export const addPublicKey = async (username: string, publicKey: string): Promise<void> => {
export const addPublicKey = async (username: string, publicKey: PublicKeyRecord): Promise<void> => {
const collection = await connect(collectionName);

const user = await collection.findOne({ username: username.toLowerCase() });
if (!user) {
throw new Error('User not found');
}

const keyExists = user.publicKeys?.some(
(k: PublicKeyRecord) =>
k.key === publicKey.key || (k.fingerprint && k.fingerprint === publicKey.fingerprint),
);

if (keyExists) {
throw new Error('SSH key already exists');
}

await collection.updateOne(
{ username: username.toLowerCase() },
{ $addToSet: { publicKeys: publicKey } },
{ $push: { publicKeys: publicKey } },
);
};

export const removePublicKey = async (username: string, publicKey: string): Promise<void> => {
export const removePublicKey = async (username: string, fingerprint: string): Promise<void> => {
const collection = await connect(collectionName);
await collection.updateOne(
{ username: username.toLowerCase() },
{ $pull: { publicKeys: publicKey } },
{ $pull: { publicKeys: { fingerprint: fingerprint } } },
);
};

export const findUserBySSHKey = async function (sshKey: string): Promise<User | null> {
const collection = await connect(collectionName);
const doc = await collection.findOne({ publicKeys: { $eq: sshKey } });
const doc = await collection.findOne({ 'publicKeys.key': { $eq: sshKey } });
return doc ? toClass(doc, User.prototype) : null;
};

export const getPublicKeys = async (username: string): Promise<PublicKeyRecord[]> => {
const user = await findUser(username);
if (!user) {
throw new Error('User not found');
}
return user.publicKeys || [];
};
Loading
Loading