diff --git a/package.json b/package.json index 26c39f64d..d09a9f7ef 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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" diff --git a/src/cli/ssh-key.ts b/src/cli/ssh-key.ts index de1182a77..602300c67 100644 --- a/src/cli/ssh-key.ts +++ b/src/cli/ssh-key.ts @@ -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( @@ -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 { try { // Check for authentication @@ -90,15 +109,28 @@ async function removeSSHKey(username: string, keyPath: string): Promise { // 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) { diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index 147d29e4c..ba3c2fbb0 100644 --- a/src/config/generated/config.ts +++ b/src/config/generated/config.ts @@ -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. @@ -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.
If this @@ -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; } @@ -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 */ @@ -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, '') }, @@ -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( [ @@ -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', ), @@ -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')) }, diff --git a/src/db/file/index.ts b/src/db/file/index.ts index 68d8adc1a..e48bbfe03 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -28,4 +28,5 @@ export const { updateUser, addPublicKey, removePublicKey, + getPublicKeys, } = users; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index 76742fb8f..15e4c7792 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -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 @@ -176,7 +176,7 @@ export const getUsers = (query: any = {}): Promise => { }); }; -export const addPublicKey = (username: string, publicKey: string): Promise => { +export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise => { return new Promise((resolve, reject) => { findUser(username) .then((user) => { @@ -187,20 +187,28 @@ export const addPublicKey = (username: string, publicKey: string): Promise 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 => { +export const removePublicKey = (username: string, fingerprint: string): Promise => { return new Promise((resolve, reject) => { findUser(username) .then((user) => { @@ -213,7 +221,7 @@ export const removePublicKey = (username: string, publicKey: string): Promise key !== publicKey); + user.publicKeys = user.publicKeys.filter((k) => k.fingerprint !== fingerprint); updateUser(user) .then(() => resolve()) .catch(reject); @@ -224,7 +232,7 @@ export const removePublicKey = (username: string, publicKey: string): Promise => { return new Promise((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) { @@ -239,3 +247,12 @@ export const findUserBySSHKey = (sshKey: string): Promise => { }); }); }; + +export const getPublicKeys = (username: string): Promise => { + return findUser(username).then((user) => { + if (!user) { + throw new Error('User not found'); + } + return user.publicKeys || []; + }); +}; diff --git a/src/db/index.ts b/src/db/index.ts index 71cc50f92..09c38b0c8 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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'; @@ -172,3 +172,9 @@ export const findUserBySSHKey = (sshKey: string): Promise => export const getUsers = (query?: object): Promise => sink.getUsers(query); export const deleteUser = (username: string): Promise => sink.deleteUser(username); export const updateUser = (user: User): Promise => sink.updateUser(user); +export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise => + sink.addPublicKey(username, publicKey); +export const removePublicKey = (username: string, fingerprint: string): Promise => + sink.removePublicKey(username, fingerprint); +export const getPublicKeys = (username: string): Promise => + sink.getPublicKeys(username); diff --git a/src/db/mongo/index.ts b/src/db/mongo/index.ts index 78c7dfce0..a793effa1 100644 --- a/src/db/mongo/index.ts +++ b/src/db/mongo/index.ts @@ -31,4 +31,5 @@ export const { updateUser, addPublicKey, removePublicKey, + getPublicKeys, } = users; diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index 74a102cc2..21b4cf6a2 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -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'; @@ -68,24 +68,47 @@ export const updateUser = async (user: User): Promise => { await collection.updateOne(filter, { $set: userWithoutId }, options); }; -export const addPublicKey = async (username: string, publicKey: string): Promise => { +export const addPublicKey = async (username: string, publicKey: PublicKeyRecord): Promise => { 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 => { +export const removePublicKey = async (username: string, fingerprint: string): Promise => { 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 { 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 => { + const user = await findUser(username); + if (!user) { + throw new Error('User not found'); + } + return user.publicKeys || []; +}; diff --git a/src/db/types.ts b/src/db/types.ts index 6402f937c..d7f9de400 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -10,6 +10,13 @@ export type PushQuery = { export type UserRole = 'canPush' | 'canAuthorise'; +export type PublicKeyRecord = { + key: string; + name: string; + addedAt: string; + fingerprint: string; +}; + export class Repo { project: string; name: string; @@ -39,7 +46,7 @@ export class User { email: string; admin: boolean; oidcId?: string | null; - publicKeys?: string[]; + publicKeys?: PublicKeyRecord[]; _id?: string; constructor( @@ -49,7 +56,7 @@ export class User { email: string, admin: boolean, oidcId: string | null = null, - publicKeys: string[] = [], + publicKeys: PublicKeyRecord[] = [], _id?: string, ) { this.username = username; @@ -90,6 +97,7 @@ export interface Sink { createUser: (user: User) => Promise; deleteUser: (username: string) => Promise; updateUser: (user: User) => Promise; - addPublicKey: (username: string, publicKey: string) => Promise; - removePublicKey: (username: string, publicKey: string) => Promise; + addPublicKey: (username: string, publicKey: PublicKeyRecord) => Promise; + removePublicKey: (username: string, fingerprint: string) => Promise; + getPublicKeys: (username: string) => Promise; } diff --git a/src/service/routes/config.js b/src/service/routes/config.js index e80d70b5b..054ffb0c9 100644 --- a/src/service/routes/config.js +++ b/src/service/routes/config.js @@ -19,4 +19,8 @@ router.get('/uiRouteAuth', function ({ res }) { res.send(config.getUIRouteAuth()); }); +router.get('/ssh', function ({ res }) { + res.send(config.getSSHConfig()); +}); + module.exports = router; diff --git a/src/service/routes/users.js b/src/service/routes/users.js index d378760e5..7690b14b2 100644 --- a/src/service/routes/users.js +++ b/src/service/routes/users.js @@ -2,6 +2,25 @@ const express = require('express'); const router = new express.Router(); const db = require('../../db'); const { toPublicUser } = require('./publicApi'); +const { utils } = require('ssh2'); +const crypto = require('crypto'); + +// Calculate SHA-256 fingerprint from SSH public key +// Note: This function is duplicated in src/cli/ssh-key.ts to keep CLI and server independent +function calculateFingerprint(publicKeyStr) { + 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; + } +} router.get('/', async (req, res) => { console.log(`fetching users`); @@ -16,6 +35,35 @@ router.get('/:id', async (req, res) => { res.send(toPublicUser(user)); }); +// Get SSH key fingerprints for a user +router.get('/:username/ssh-key-fingerprints', async (req, res) => { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const targetUsername = req.params.username.toLowerCase(); + + // Only allow users to view their own keys, or admins to view any keys + if (req.user.username !== targetUsername && !req.user.admin) { + res.status(403).json({ error: 'Not authorized to view keys for this user' }); + return; + } + + try { + const publicKeys = await db.getPublicKeys(targetUsername); + const keyFingerprints = publicKeys.map((keyRecord) => ({ + fingerprint: keyRecord.fingerprint, + name: keyRecord.name, + addedAt: keyRecord.addedAt, + })); + res.json(keyFingerprints); + } catch (error) { + console.error('Error retrieving SSH keys:', error); + res.status(500).json({ error: 'Failed to retrieve SSH keys' }); + } +}); + // Add SSH public key router.post('/:username/ssh-keys', async (req, res) => { if (!req.user) { @@ -31,33 +79,59 @@ router.post('/:username/ssh-keys', async (req, res) => { return; } - const { publicKey } = req.body; + const { publicKey, name } = req.body; if (!publicKey) { res.status(400).json({ error: 'Public key is required' }); return; } // Strip the comment from the key (everything after the last space) - const keyWithoutComment = publicKey.split(' ').slice(0, 2).join(' '); + const keyWithoutComment = publicKey.trim().split(' ').slice(0, 2).join(' '); - console.log('Adding SSH key', { targetUsername, keyWithoutComment }); + // Calculate fingerprint + const fingerprint = calculateFingerprint(keyWithoutComment); + if (!fingerprint) { + res.status(400).json({ error: 'Invalid SSH public key format' }); + return; + } + + const publicKeyRecord = { + key: keyWithoutComment, + name: name || 'Unnamed Key', + addedAt: new Date().toISOString(), + fingerprint: fingerprint, + }; + + console.log('Adding SSH key', { targetUsername, fingerprint }); try { - await db.addPublicKey(targetUsername, keyWithoutComment); - res.status(201).json({ message: 'SSH key added successfully' }); + await db.addPublicKey(targetUsername, publicKeyRecord); + res.status(201).json({ + message: 'SSH key added successfully', + fingerprint: fingerprint, + }); } catch (error) { console.error('Error adding SSH key:', error); - res.status(500).json({ error: 'Failed to add SSH key' }); + + // Return specific error message + if (error.message === 'SSH key already exists') { + res.status(409).json({ error: 'This SSH key already exists' }); + } else if (error.message === 'User not found') { + res.status(404).json({ error: 'User not found' }); + } else { + res.status(500).json({ error: error.message || 'Failed to add SSH key' }); + } } }); -// Remove SSH public key -router.delete('/:username/ssh-keys', async (req, res) => { +// Remove SSH public key by fingerprint +router.delete('/:username/ssh-keys/:fingerprint', async (req, res) => { if (!req.user) { res.status(401).json({ error: 'Authentication required' }); return; } const targetUsername = req.params.username.toLowerCase(); + const fingerprint = req.params.fingerprint; // Only allow users to remove keys from their own account, or admins to remove from any account if (req.user.username !== targetUsername && !req.user.admin) { @@ -65,18 +139,21 @@ router.delete('/:username/ssh-keys', async (req, res) => { return; } - const { publicKey } = req.body; - if (!publicKey) { - res.status(400).json({ error: 'Public key is required' }); + if (!fingerprint) { + res.status(400).json({ error: 'Fingerprint is required' }); return; } try { - await db.removePublicKey(targetUsername, publicKey); + await db.removePublicKey(targetUsername, fingerprint); res.status(200).json({ message: 'SSH key removed successfully' }); } catch (error) { console.error('Error removing SSH key:', error); - res.status(500).json({ error: 'Failed to remove SSH key' }); + if (error.message === 'User not found') { + res.status(404).json({ error: 'User not found' }); + } else { + res.status(500).json({ error: 'Failed to remove SSH key' }); + } } }); diff --git a/src/ui/components/CustomButtons/CodeActionButton.tsx b/src/ui/components/CustomButtons/CodeActionButton.tsx index 5fb9d6588..ffc556c5b 100644 --- a/src/ui/components/CustomButtons/CodeActionButton.tsx +++ b/src/ui/components/CustomButtons/CodeActionButton.tsx @@ -8,9 +8,11 @@ import { CopyIcon, TerminalIcon, } from '@primer/octicons-react'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { PopperPlacementType } from '@material-ui/core/Popper'; import Button from './Button'; +import { Tabs, Tab } from '@material-ui/core'; +import { getSSHConfig, SSHConfig } from '../../services/ssh'; interface CodeActionButtonProps { cloneURL: string; @@ -21,6 +23,32 @@ const CodeActionButton: React.FC = ({ cloneURL }) => { const [open, setOpen] = useState(false); const [placement, setPlacement] = useState(); const [isCopied, setIsCopied] = useState(false); + const [selectedTab, setSelectedTab] = useState(0); + const [sshConfig, setSshConfig] = useState(null); + const [sshURL, setSSHURL] = useState(''); + + // Load SSH config on mount + useEffect(() => { + const loadSSHConfig = async () => { + try { + const config = await getSSHConfig(); + setSshConfig(config); + + // Calculate SSH URL from HTTPS URL + if (config.enabled && cloneURL) { + // Convert https://proxy-host/github.com/user/repo.git to git@proxy-host:github.com/user/repo.git + const url = new URL(cloneURL); + const host = url.host; + const path = url.pathname.substring(1); // remove leading / + const port = config.port !== 22 ? `:${config.port}` : ''; + setSSHURL(`git@${host}${port}:${path}`); + } + } catch (error) { + console.error('Error loading SSH config:', error); + } + }; + loadSSHConfig(); + }, [cloneURL]); const handleClick = (newPlacement: PopperPlacementType) => (event: React.MouseEvent) => { @@ -34,6 +62,14 @@ const CodeActionButton: React.FC = ({ cloneURL }) => { setOpen(false); }; + const handleTabChange = (_event: React.ChangeEvent, newValue: number) => { + setSelectedTab(newValue); + setIsCopied(false); + }; + + const currentURL = selectedTab === 0 ? cloneURL : sshURL; + const currentCloneCommand = selectedTab === 0 ? `git clone ${cloneURL}` : `git clone ${sshURL}`; + return ( <> + + + - - ) : null} - - - - - + ) : null} + + + + + setSnackbarOpen(false)} + close + /> + + + {/* SSH Key Modal */} + + + Add New SSH Key + + + + + + + + + + + ); }