diff --git a/docs/SSH_ARCHITECTURE.md b/docs/SSH_ARCHITECTURE.md new file mode 100644 index 000000000..92fbaa688 --- /dev/null +++ b/docs/SSH_ARCHITECTURE.md @@ -0,0 +1,351 @@ +# SSH Proxy Architecture + +Complete documentation of the SSH proxy architecture and operation for Git. + +### Main Components + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────┐ +│ Client │ SSH │ Git Proxy │ SSH │ GitHub │ +│ (Developer) ├────────→│ (Middleware) ├────────→│ (Remote) │ +└─────────────┘ └──────────────────┘ └──────────┘ + ↓ + ┌─────────────┐ + │ Security │ + │ Chain │ + └─────────────┘ +``` + +--- + +## Client → Proxy Communication + +### Client Setup + +The Git client uses SSH to communicate with the proxy. Minimum required configuration: + +**1. Configure Git remote**: + +```bash +git remote add origin ssh://user@git-proxy.example.com:2222/org/repo.git +``` + +**2. Configure SSH agent forwarding** (`~/.ssh/config`): + +``` +Host git-proxy.example.com + ForwardAgent yes # REQUIRED + IdentityFile ~/.ssh/id_ed25519 + Port 2222 +``` + +**3. Start ssh-agent and load key**: + +```bash +eval $(ssh-agent -s) +ssh-add ~/.ssh/id_ed25519 +ssh-add -l # Verify key loaded +``` + +**4. Register public key with proxy**: + +```bash +# Copy the public key +cat ~/.ssh/id_ed25519.pub + +# Register it via UI (http://localhost:8000) or database +# The key must be in the proxy database for Client → Proxy authentication +``` + +### How It Works + +When you run `git push`, Git translates the command into SSH: + +```bash +# User: +git push origin main + +# Git internally: +ssh -A git-proxy.example.com "git-receive-pack '/org/repo.git'" +``` + +The `-A` flag (agent forwarding) is activated automatically if configured in `~/.ssh/config` + +--- + +### SSH Channels: Session vs Agent + +**IMPORTANT**: Client → Proxy communication uses **different channels** than agent forwarding: + +#### Session Channel (Git Protocol) + +``` +┌─────────────┐ ┌─────────────┐ +│ Client │ │ Proxy │ +│ │ Session Channel 0 │ │ +│ │◄──────────────────────►│ │ +│ Git Data │ Git Protocol │ Git Data │ +│ │ (upload/receive) │ │ +└─────────────┘ └─────────────┘ +``` + +This channel carries: + +- Git commands (git-upload-pack, git-receive-pack) +- Git data (capabilities, refs, pack data) +- stdin/stdout/stderr of the command + +#### Agent Channel (Agent Forwarding) + +``` +┌─────────────┐ ┌─────────────┐ +│ Client │ │ Proxy │ +│ │ │ │ +│ ssh-agent │ Agent Channel 1 │ LazyAgent │ +│ [Key] │◄──────────────────────►│ │ +│ │ (opened on-demand) │ │ +└─────────────┘ └─────────────┘ +``` + +This channel carries: + +- Identity requests (list of public keys) +- Signature requests +- Agent responses + +**The two channels are completely independent!** + +### Complete Example: git push with Agent Forwarding + +**What happens**: + +``` +CLIENT PROXY GITHUB + + │ ssh -A git-proxy.example.com │ │ + ├────────────────────────────────►│ │ + │ Session Channel │ │ + │ │ │ + │ "git-receive-pack /org/repo" │ │ + ├────────────────────────────────►│ │ + │ │ │ + │ │ ssh github.com │ + │ ├──────────────────────────────►│ + │ │ (needs authentication) │ + │ │ │ + │ Agent Channel opened │ │ + │◄────────────────────────────────┤ │ + │ │ │ + │ "Sign this challenge" │ │ + │◄────────────────────────────────┤ │ + │ │ │ + │ [Signature] │ │ + │────────────────────────────────►│ │ + │ │ [Signature] │ + │ ├──────────────────────────────►│ + │ Agent Channel closed │ (authenticated!) │ + │◄────────────────────────────────┤ │ + │ │ │ + │ Git capabilities │ Git capabilities │ + │◄────────────────────────────────┼───────────────────────────────┤ + │ (via Session Channel) │ (forwarded) │ + │ │ │ +``` + +--- + +## Core Concepts + +### 1. SSH Agent Forwarding + +SSH agent forwarding allows the proxy to use the client's SSH keys **without ever receiving them**. The private key remains on the client's computer. + +#### How does it work? + +``` +┌──────────┐ ┌───────────┐ ┌──────────┐ +│ Client │ │ Proxy │ │ GitHub │ +│ │ │ │ │ │ +│ ssh-agent│ │ │ │ │ +│ ↑ │ │ │ │ │ +│ │ │ Agent Forwarding │ │ │ │ +│ [Key] │◄──────────────────►│ Lazy │ │ │ +│ │ SSH Channel │ Agent │ │ │ +└──────────┘ └───────────┘ └──────────┘ + │ │ │ + │ │ 1. GitHub needs signature │ + │ │◄─────────────────────────────┤ + │ │ │ + │ 2. Open temp agent channel │ │ + │◄───────────────────────────────┤ │ + │ │ │ + │ 3. Request signature │ │ + │◄───────────────────────────────┤ │ + │ │ │ + │ 4. Return signature │ │ + │───────────────────────────────►│ │ + │ │ │ + │ 5. Close channel │ │ + │◄───────────────────────────────┤ │ + │ │ 6. Forward signature │ + │ ├─────────────────────────────►│ +``` + +#### Lazy Agent Pattern + +The proxy does **not** keep an agent channel open permanently. Instead: + +1. When GitHub requires a signature, we open a **temporary channel** +2. We request the signature through the channel +3. We **immediately close** the channel after the response + +#### Implementation Details and Limitations + +**Important**: The SSH agent forwarding implementation is more complex than typical due to limitations in the `ssh2` library. + +**The Problem:** +The `ssh2` library does not expose public APIs for **server-side** SSH agent forwarding. While ssh2 has excellent support for client-side agent forwarding (connecting TO an agent), it doesn't provide APIs for the server side (accepting agent channels FROM clients and forwarding requests). + +**Our Solution:** +We implemented agent forwarding by directly manipulating ssh2's internal structures: + +- `_protocol`: Internal protocol handler +- `_chanMgr`: Internal channel manager +- `_handlers`: Event handler registry + +**Code reference** (`AgentForwarding.ts`): + +```typescript +// Uses ssh2 internals - no public API available +const proto = (client as any)._protocol; +const chanMgr = (client as any)._chanMgr; +(proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = handlerWrapper; +``` + +**Risks:** + +- **Fragile**: If ssh2 changes internals, this could break +- **Maintenance**: Requires monitoring ssh2 updates +- **No type safety**: Uses `any` casts to bypass TypeScript + +**Upstream Work:** +There are open PRs in the ssh2 repository to add proper server-side agent forwarding APIs: + +- [#781](https://github.com/mscdex/ssh2/pull/781) - Add support for server-side agent forwarding +- [#1468](https://github.com/mscdex/ssh2/pull/1468) - Related improvements + +**Future Improvements:** +Once ssh2 adds public APIs for server-side agent forwarding, we should: + +1. Remove internal API usage in `openTemporaryAgentChannel()` +2. Use the new public APIs +3. Improve type safety + +### 2. Git Capabilities + +"Capabilities" are the features supported by the Git server (e.g., `report-status`, `delete-refs`, `side-band-64k`). They are sent at the beginning of each Git session along with available refs. + +#### How does it work normally (without proxy)? + +**Standard Git push flow**: + +``` +Client ──────────────→ GitHub (single connection) + 1. "git-receive-pack /repo.git" + 2. GitHub: capabilities + refs + 3. Client: pack data + 4. GitHub: "ok refs/heads/main" +``` + +Capabilities are exchanged **only once** at the beginning of the connection. + +#### How did we modify the flow in the proxy? + +**Our modified flow**: + +``` +Client → Proxy Proxy → GitHub + │ │ + │ 1. "git-receive-pack" │ + │─────────────────────────────→│ + │ │ CONNECTION 1 + │ ├──────────────→ GitHub + │ │ "get capabilities" + │ │←─────────────┤ + │ │ capabilities (500 bytes) + │ 2. capabilities │ DISCONNECT + │←─────────────────────────────┤ + │ │ + │ 3. pack data │ + │─────────────────────────────→│ (BUFFERED!) + │ │ + │ │ 4. Security validation + │ │ + │ │ CONNECTION 2 + │ ├──────────────→ GitHub + │ │ pack data + │ │←─────────────┤ + │ │ capabilities (500 bytes AGAIN!) + │ │ + actual response + │ 5. response │ + │←─────────────────────────────┤ (skip capabilities, forward response) +``` + +#### Why this change? + +**Core requirement**: Validate pack data BEFORE sending it to GitHub (security chain). + +**Difference with HTTPS**: + +In **HTTPS**, capabilities are exchanged in a **separate** HTTP request: + +``` +1. GET /info/refs?service=git-receive-pack → capabilities + refs +2. POST /git-receive-pack → pack data (no capabilities) +``` + +The HTTPS proxy simply forwards the GET, then buffers/validates the POST. + +In **SSH**, everything happens in **a single conversational session**: + +``` +Client → Proxy: "git-receive-pack" → expects capabilities IMMEDIATELY in the same session +``` + +We can't say "make a separate request". The client blocks if we don't respond immediately. + +**SSH Problem**: + +1. The client expects capabilities **IMMEDIATELY** when requesting git-receive-pack +2. But we need to **buffer** all pack data to validate it +3. If we waited to receive all pack data BEFORE fetching capabilities → the client blocks + +**Solution**: + +- **Connection 1**: Fetch capabilities immediately, send to client +- The client can start sending pack data +- We **buffer** the pack data (we don't send it yet!) +- **Validation**: Security chain verifies the pack data +- **Connection 2**: Only AFTER approval, we send to GitHub + +**Consequence**: + +- GitHub sees the second connection as a **new session** +- It resends capabilities (500 bytes) as it would normally +- We must **skip** these 500 duplicate bytes +- We forward only the real response: `"ok refs/heads/main\n"` + +### 3. Security Chain Validation Uses HTTPS + +**Important**: Even though the client uses SSH to connect to the proxy, the **security chain validation** (pullRemote action) clones the repository using **HTTPS**. + +The security chain needs to independently clone and analyze the repository **before** accepting the push. This validation is separate from the SSH git protocol flow and uses HTTPS because: + +1. Validation must work regardless of SSH agent forwarding state +2. Uses proxy's own credentials (service token), not client's keys +3. HTTPS is simpler for automated cloning/validation tasks + +The two protocols serve different purposes: + +- **SSH**: End-to-end git operations (preserves user identity) +- **HTTPS**: Internal security validation (uses proxy credentials) diff --git a/package.json b/package.json index 52d6211be..b57a437a2 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,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.19.0", "@seald-io/nedb": "^4.1.2", "axios": "^1.12.2", @@ -90,6 +91,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", "escape-string-regexp": "^5.0.0", @@ -119,6 +121,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 37cc19f55..62dceaeda 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 @@ -83,15 +102,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/db/file/index.ts b/src/db/file/index.ts index 1f4dcf993..2b1448b8e 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -31,4 +31,5 @@ export const { updateUser, addPublicKey, removePublicKey, + getPublicKeys, } = users; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index 01846c29a..db395c91d 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import Datastore from '@seald-io/nedb'; -import { User, UserQuery } from '../types'; +import { User, UserQuery, PublicKeyRecord } from '../types'; import { DuplicateSSHKeyError, UserNotFoundError } from '../../errors/DatabaseErrors'; const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day @@ -181,7 +181,7 @@ export const getUsers = (query: Partial = {}): Promise => { }); }; -export const addPublicKey = (username: string, publicKey: string): Promise => { +export const addPublicKey = (username: string, publicKey: PublicKeyRecord): Promise => { return new Promise((resolve, reject) => { // Check if this key already exists for any user findUserBySSHKey(publicKey) @@ -202,20 +202,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) => { @@ -228,7 +236,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); @@ -239,7 +247,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) { @@ -254,3 +262,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 af109ddf6..09f8b5f2a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,5 +1,5 @@ import { AuthorisedRepo } from '../config/generated/config'; -import { PushQuery, Repo, RepoQuery, Sink, User, UserQuery } from './types'; +import { PushQuery, Repo, RepoQuery, Sink, User, UserQuery, PublicKeyRecord } from './types'; import * as bcrypt from 'bcryptjs'; import * as config from '../config'; import * as mongo from './mongo'; @@ -171,9 +171,11 @@ export const findUserBySSHKey = (sshKey: string): Promise => sink.findUserBySSHKey(sshKey); export const getUsers = (query?: Partial): Promise => sink.getUsers(query); export const deleteUser = (username: string): Promise => sink.deleteUser(username); -export const updateUser = (user: Partial): Promise => sink.updateUser(user); -export const addPublicKey = (username: string, publicKey: string): Promise => +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, publicKey: string): Promise => - sink.removePublicKey(username, publicKey); -export type { PushQuery, Repo, Sink, User } from './types'; +export const removePublicKey = (username: string, fingerprint: string): Promise => + sink.removePublicKey(username, fingerprint); +export const getPublicKeys = (username: string): Promise => + sink.getPublicKeys(username); +export type { PushQuery, Repo, Sink, User, PublicKeyRecord } from './types'; 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 2f7063105..912e94887 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'; import { DuplicateSSHKeyError } from '../../errors/DatabaseErrors'; @@ -71,9 +71,9 @@ export const updateUser = async (user: Partial): 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 => { // Check if this key already exists for any user - const existingUser = await findUserBySSHKey(publicKey); + const existingUser = await findUserBySSHKey(publicKey.key); if (existingUser && existingUser.username.toLowerCase() !== username.toLowerCase()) { throw new DuplicateSSHKeyError(existingUser.username); @@ -81,22 +81,45 @@ export const addPublicKey = async (username: string, publicKey: string): Promise // Key doesn't exist for other users 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 7ee6c9709..f2f21eeab 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -29,6 +29,13 @@ export type QueryValue = string | boolean | number | undefined; export type UserRole = 'canPush' | 'canAuthorise'; +export type PublicKeyRecord = { + key: string; + name: string; + addedAt: string; + fingerprint: string; +}; + export class Repo { project: string; name: string; @@ -58,7 +65,7 @@ export class User { email: string; admin: boolean; oidcId?: string | null; - publicKeys?: string[]; + publicKeys?: PublicKeyRecord[]; displayName?: string | null; title?: string | null; _id?: string; @@ -70,7 +77,7 @@ export class User { email: string, admin: boolean, oidcId: string | null = null, - publicKeys: string[] = [], + publicKeys: PublicKeyRecord[] = [], _id?: string, ) { this.username = username; @@ -110,7 +117,8 @@ export interface Sink { getUsers: (query?: Partial) => Promise; createUser: (user: User) => Promise; deleteUser: (username: string) => Promise; - updateUser: (user: Partial) => Promise; - addPublicKey: (username: string, publicKey: string) => Promise; - removePublicKey: (username: string, publicKey: string) => Promise; + updateUser: (user: User) => Promise; + addPublicKey: (username: string, publicKey: PublicKeyRecord) => Promise; + removePublicKey: (username: string, fingerprint: string) => Promise; + getPublicKeys: (username: string) => Promise; } diff --git a/src/proxy/processors/push-action/pullRemote.ts b/src/proxy/processors/push-action/pullRemote.ts index bcfc5b375..a6a6fc8c2 100644 --- a/src/proxy/processors/push-action/pullRemote.ts +++ b/src/proxy/processors/push-action/pullRemote.ts @@ -2,9 +2,6 @@ import { Action, Step } from '../../actions'; import fs from 'fs'; import git from 'isomorphic-git'; import gitHttpClient from 'isomorphic-git/http/node'; -import path from 'path'; -import os from 'os'; -import { simpleGit } from 'simple-git'; const dir = './.remote'; @@ -44,16 +41,6 @@ const decodeBasicAuth = (authHeader?: string): BasicCredentials | null => { }; }; -const buildSSHCloneUrl = (remoteUrl: string): string => { - const parsed = new URL(remoteUrl); - const repoPath = parsed.pathname.replace(/^\//, ''); - return `git@${parsed.hostname}:${repoPath}`; -}; - -const cleanupTempDir = async (tempDir: string) => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); -}; - const cloneWithHTTPS = async ( action: Action, credentials: BasicCredentials | null, @@ -71,51 +58,10 @@ const cloneWithHTTPS = async ( await git.clone(cloneOptions); }; -const cloneWithSSHKey = async (action: Action, privateKey: Buffer): Promise => { - if (!privateKey || privateKey.length === 0) { - throw new Error('SSH private key is empty'); - } - - const keyBuffer = Buffer.isBuffer(privateKey) ? privateKey : Buffer.from(privateKey); - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'git-proxy-ssh-clone-')); - const keyPath = path.join(tempDir, 'id_rsa'); - - await fs.promises.writeFile(keyPath, keyBuffer, { mode: 0o600 }); - - const originalGitSSH = process.env.GIT_SSH_COMMAND; - process.env.GIT_SSH_COMMAND = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; - - try { - const gitClient = simpleGit(action.proxyGitPath); - await gitClient.clone(buildSSHCloneUrl(action.url), action.repoName, [ - '--depth', - '1', - '--single-branch', - ]); - } finally { - if (originalGitSSH) { - process.env.GIT_SSH_COMMAND = originalGitSSH; - } else { - delete process.env.GIT_SSH_COMMAND; - } - await cleanupTempDir(tempDir); - } -}; - const handleSSHClone = async (req: any, action: Action, step: Step): Promise => { const authContext = req?.authContext ?? {}; - const sshKey = authContext?.sshKey; - - if (sshKey?.keyData || sshKey?.privateKey) { - const keyData = sshKey.keyData ?? sshKey.privateKey; - step.log('Cloning repository over SSH using caller credentials'); - await cloneWithSSHKey(action, keyData); - return { - command: `git clone ${buildSSHCloneUrl(action.url)}`, - strategy: 'ssh-user-key', - }; - } + // Try service token first (if configured) const serviceToken = authContext?.cloneServiceToken; if (serviceToken?.username && serviceToken?.password) { step.log('Cloning repository over HTTPS using configured service token'); @@ -129,17 +75,20 @@ const handleSSHClone = async (req: any, action: Action, step: Step): Promise => { diff --git a/src/proxy/ssh/AgentForwarding.ts b/src/proxy/ssh/AgentForwarding.ts new file mode 100644 index 000000000..14cfe67a5 --- /dev/null +++ b/src/proxy/ssh/AgentForwarding.ts @@ -0,0 +1,280 @@ +/** + * SSH Agent Forwarding Implementation + * + * This module handles SSH agent forwarding, allowing the Git Proxy to use + * the client's SSH agent to authenticate to remote Git servers without + * ever receiving the private key. + */ + +import { SSHAgentProxy } from './AgentProxy'; +import { ClientWithUser } from './types'; + +// Import BaseAgent from ssh2 for custom agent implementation +const { BaseAgent } = require('ssh2/lib/agent.js'); + +/** + * Lazy SSH Agent implementation that extends ssh2's BaseAgent. + * Opens temporary agent channels on-demand when GitHub requests signatures. + * + * IMPORTANT: Agent operations are serialized to prevent channel ID conflicts. + * Only one agent operation (getIdentities or sign) can be active at a time. + */ +export class LazySSHAgent extends BaseAgent { + private openChannelFn: (client: ClientWithUser) => Promise; + private client: ClientWithUser; + private operationChain: Promise = Promise.resolve(); + + constructor( + openChannelFn: (client: ClientWithUser) => Promise, + client: ClientWithUser, + ) { + super(); + this.openChannelFn = openChannelFn; + this.client = client; + } + + /** + * Execute an operation with exclusive lock using Promise chain. + */ + private async executeWithLock(operation: () => Promise): Promise { + const result = this.operationChain.then( + () => operation(), + () => operation(), + ); + + // Update chain to wait for this operation (but ignore result) + this.operationChain = result.then( + () => {}, + () => {}, + ); + + return result; + } + + /** + * Get list of identities from the client's forwarded agent + */ + getIdentities(callback: (err: Error | null, keys?: any[]) => void): void { + console.log('[LazyAgent] getIdentities called'); + + // Wrap the operation in a lock to prevent concurrent channel usage + this.executeWithLock(async () => { + console.log('[LazyAgent] Lock acquired, opening temporary channel'); + let agentProxy: SSHAgentProxy | null = null; + + try { + agentProxy = await this.openChannelFn(this.client); + if (!agentProxy) { + throw new Error('Could not open agent channel'); + } + + const identities = await agentProxy.getIdentities(); + + // ssh2's AgentContext.init() calls parseKey() on every key we return. + // We need to return the raw pubKeyBlob Buffer, which parseKey() can parse + // into a proper ParsedKey object. + const keys = identities.map((identity) => identity.publicKeyBlob); + + console.log(`[LazyAgent] Returning ${keys.length} identities`); + + // Close the temporary agent channel + if (agentProxy) { + agentProxy.close(); + console.log('[LazyAgent] Closed temporary agent channel after getIdentities'); + } + + callback(null, keys); + } catch (err: any) { + console.error('[LazyAgent] Error getting identities:', err); + if (agentProxy) { + agentProxy.close(); + } + callback(err); + } + }).catch((err) => { + console.error('[LazyAgent] Unexpected error in executeWithLock:', err); + callback(err); + }); + } + + /** + * Sign data with a specific key using the client's forwarded agent + */ + sign( + pubKey: any, + data: Buffer, + options: any, + callback?: (err: Error | null, signature?: Buffer) => void, + ): void { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + if (!callback) { + callback = () => {}; + } + + console.log('[LazyAgent] sign called'); + + // Wrap the operation in a lock to prevent concurrent channel usage + this.executeWithLock(async () => { + console.log('[LazyAgent] Lock acquired, opening temporary channel for signing'); + let agentProxy: SSHAgentProxy | null = null; + + try { + agentProxy = await this.openChannelFn(this.client); + if (!agentProxy) { + throw new Error('Could not open agent channel'); + } + let pubKeyBlob: Buffer; + + if (typeof pubKey.getPublicSSH === 'function') { + pubKeyBlob = pubKey.getPublicSSH(); + } else if (Buffer.isBuffer(pubKey)) { + pubKeyBlob = pubKey; + } else { + console.error('[LazyAgent] Unknown pubKey format:', Object.keys(pubKey || {})); + throw new Error('Invalid pubKey format - cannot extract SSH wire format'); + } + + const signature = await agentProxy.sign(pubKeyBlob, data); + console.log(`[LazyAgent] Signature received (${signature.length} bytes)`); + + if (agentProxy) { + agentProxy.close(); + console.log('[LazyAgent] Closed temporary agent channel after sign'); + } + + callback!(null, signature); + } catch (err: any) { + console.error('[LazyAgent] Error signing data:', err); + if (agentProxy) { + agentProxy.close(); + } + callback!(err); + } + }).catch((err) => { + console.error('[LazyAgent] Unexpected error in executeWithLock:', err); + callback!(err); + }); + } +} + +/** + * Open a temporary agent channel to communicate with the client's forwarded agent + * This channel is used for a single request and then closed + * + * IMPORTANT: This function manipulates ssh2 internals (_protocol, _chanMgr, _handlers) + * because ssh2 does not expose a public API for opening agent channels from server side. + * + * @param client - The SSH client connection with agent forwarding enabled + * @returns Promise resolving to an SSHAgentProxy or null if failed + */ +export async function openTemporaryAgentChannel( + client: ClientWithUser, +): Promise { + // Access internal protocol handler (not exposed in public API) + const proto = (client as any)._protocol; + if (!proto) { + console.error('[SSH] No protocol found on client connection'); + return null; + } + + // Find next available channel ID by checking internal ChannelManager + // This prevents conflicts with channels that ssh2 might be managing + const chanMgr = (client as any)._chanMgr; + let localChan = 1; // Start from 1 (0 is typically main session) + + if (chanMgr && chanMgr._channels) { + // Find first available channel ID + while (chanMgr._channels[localChan] !== undefined) { + localChan++; + } + } + + console.log(`[SSH] Opening agent channel with ID ${localChan}`); + + return new Promise((resolve) => { + const originalHandler = (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION; + const handlerWrapper = (self: any, info: any) => { + if (originalHandler) { + originalHandler(self, info); + } + + if (info.recipient === localChan) { + clearTimeout(timeout); + + // Restore original handler + if (originalHandler) { + (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = originalHandler; + } else { + delete (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION; + } + + // Create a Channel object manually + try { + const channelInfo = { + type: 'auth-agent@openssh.com', + incoming: { + id: info.sender, + window: info.window, + packetSize: info.packetSize, + state: 'open', + }, + outgoing: { + id: localChan, + window: 2 * 1024 * 1024, // 2MB default + packetSize: 32 * 1024, // 32KB default + state: 'open', + }, + }; + + const { Channel } = require('ssh2/lib/Channel'); + const channel = new Channel(client, channelInfo, { server: true }); + + // Register channel with ChannelManager + const chanMgr = (client as any)._chanMgr; + if (chanMgr) { + chanMgr._channels[localChan] = channel; + chanMgr._count++; + } + + // Create the agent proxy + const agentProxy = new SSHAgentProxy(channel); + resolve(agentProxy); + } catch (err) { + console.error('[SSH] Failed to create Channel/AgentProxy:', err); + resolve(null); + } + } + }; + + // Install our handler + (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = handlerWrapper; + + const timeout = setTimeout(() => { + console.error('[SSH] Timeout waiting for channel confirmation'); + if (originalHandler) { + (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = originalHandler; + } else { + delete (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION; + } + resolve(null); + }, 5000); + + // Send the channel open request + const { MAX_WINDOW, PACKET_SIZE } = require('ssh2/lib/Channel'); + proto.openssh_authAgent(localChan, MAX_WINDOW, PACKET_SIZE); + }); +} + +/** + * Create a "lazy" agent that opens channels on-demand when GitHub requests signatures + * + * @param client - The SSH client connection with agent forwarding enabled + * @returns A LazySSHAgent instance + */ +export function createLazyAgent(client: ClientWithUser): LazySSHAgent { + return new LazySSHAgent(openTemporaryAgentChannel, client); +} diff --git a/src/proxy/ssh/AgentProxy.ts b/src/proxy/ssh/AgentProxy.ts new file mode 100644 index 000000000..ac1944655 --- /dev/null +++ b/src/proxy/ssh/AgentProxy.ts @@ -0,0 +1,306 @@ +import { Channel } from 'ssh2'; +import { EventEmitter } from 'events'; + +/** + * SSH Agent Protocol Message Types + * Based on RFC 4252 and draft-miller-ssh-agent + */ +enum AgentMessageType { + SSH_AGENTC_REQUEST_IDENTITIES = 11, + SSH_AGENT_IDENTITIES_ANSWER = 12, + SSH_AGENTC_SIGN_REQUEST = 13, + SSH_AGENT_SIGN_RESPONSE = 14, + SSH_AGENT_FAILURE = 5, +} + +/** + * Represents a public key identity from the SSH agent + */ +export interface SSHIdentity { + /** The public key blob in SSH wire format */ + publicKeyBlob: Buffer; + /** Comment/description of the key */ + comment: string; + /** Parsed key algorithm (e.g., 'ssh-ed25519', 'ssh-rsa') */ + algorithm?: string; +} + +/** + * SSH Agent Proxy + * + * Implements the SSH agent protocol over a forwarded SSH channel. + * This allows the Git Proxy to request signatures from the user's + * local ssh-agent without ever receiving the private key. + * + * The agent runs on the client's machine, and this proxy communicates + * with it through the SSH connection's agent forwarding channel. + */ +export class SSHAgentProxy extends EventEmitter { + private channel: Channel; + private pendingResponse: ((data: Buffer) => void) | null = null; + private buffer: Buffer = Buffer.alloc(0); + + constructor(channel: Channel) { + super(); + this.channel = channel; + this.setupChannelHandlers(); + } + + /** + * Set up handlers for data coming from the agent channel + */ + private setupChannelHandlers(): void { + this.channel.on('data', (data: Buffer) => { + this.buffer = Buffer.concat([this.buffer, data]); + this.processBuffer(); + }); + + this.channel.on('close', () => { + this.emit('close'); + }); + + this.channel.on('error', (err: Error) => { + console.error('[AgentProxy] Channel error:', err); + this.emit('error', err); + }); + } + + /** + * Process accumulated buffer for complete messages + * Agent protocol format: [4 bytes length][message] + */ + private processBuffer(): void { + while (this.buffer.length >= 4) { + const messageLength = this.buffer.readUInt32BE(0); + + // Check if we have the complete message + if (this.buffer.length < 4 + messageLength) { + // Not enough data yet, wait for more + break; + } + + // Extract the complete message + const message = this.buffer.slice(4, 4 + messageLength); + + // Remove processed message from buffer + this.buffer = this.buffer.slice(4 + messageLength); + + // Handle the message + this.handleMessage(message); + } + } + + /** + * Handle a complete message from the agent + */ + private handleMessage(message: Buffer): void { + if (message.length === 0) { + console.warn('[AgentProxy] Empty message from agent'); + return; + } + + if (this.pendingResponse) { + const resolver = this.pendingResponse; + this.pendingResponse = null; + resolver(message); + } + } + + /** + * Send a message to the agent and wait for response + */ + private async sendMessage(message: Buffer): Promise { + return new Promise((resolve, reject) => { + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(message.length, 0); + const fullMessage = Buffer.concat([length, message]); + + const timeout = setTimeout(() => { + this.pendingResponse = null; + reject(new Error('Agent request timeout')); + }, 10000); + + this.pendingResponse = (data: Buffer) => { + clearTimeout(timeout); + resolve(data); + }; + + // Send to agent + this.channel.write(fullMessage); + }); + } + + /** + * Get list of identities (public keys) from the agent + */ + async getIdentities(): Promise { + const message = Buffer.from([AgentMessageType.SSH_AGENTC_REQUEST_IDENTITIES]); + const response = await this.sendMessage(message); + const responseType = response[0]; + + if (responseType === AgentMessageType.SSH_AGENT_FAILURE) { + throw new Error('Agent returned failure for identities request'); + } + + if (responseType !== AgentMessageType.SSH_AGENT_IDENTITIES_ANSWER) { + throw new Error(`Unexpected response type: ${responseType}`); + } + + return this.parseIdentities(response); + } + + /** + * Parse IDENTITIES_ANSWER message + * Format: [type:1][num_keys:4][key_blob_len:4][key_blob][comment_len:4][comment]... + */ + private parseIdentities(response: Buffer): SSHIdentity[] { + const identities: SSHIdentity[] = []; + let offset = 1; // Skip message type byte + + // Read number of keys + if (response.length < offset + 4) { + throw new Error('Invalid identities response: too short for key count'); + } + const numKeys = response.readUInt32BE(offset); + offset += 4; + + for (let i = 0; i < numKeys; i++) { + // Read key blob length + if (response.length < offset + 4) { + throw new Error(`Invalid identities response: missing key blob length for key ${i}`); + } + const blobLength = response.readUInt32BE(offset); + offset += 4; + + // Read key blob + if (response.length < offset + blobLength) { + throw new Error(`Invalid identities response: incomplete key blob for key ${i}`); + } + const publicKeyBlob = response.slice(offset, offset + blobLength); + offset += blobLength; + + // Read comment length + if (response.length < offset + 4) { + throw new Error(`Invalid identities response: missing comment length for key ${i}`); + } + const commentLength = response.readUInt32BE(offset); + offset += 4; + + // Read comment + if (response.length < offset + commentLength) { + throw new Error(`Invalid identities response: incomplete comment for key ${i}`); + } + const comment = response.slice(offset, offset + commentLength).toString('utf8'); + offset += commentLength; + + // Extract algorithm from key blob (SSH wire format: [length:4][algorithm string]) + let algorithm = 'unknown'; + if (publicKeyBlob.length >= 4) { + const algoLen = publicKeyBlob.readUInt32BE(0); + if (publicKeyBlob.length >= 4 + algoLen) { + algorithm = publicKeyBlob.slice(4, 4 + algoLen).toString('utf8'); + } + } + + identities.push({ publicKeyBlob, comment, algorithm }); + } + + return identities; + } + + /** + * Request the agent to sign data with a specific key + * + * @param publicKeyBlob - The public key blob identifying which key to use + * @param data - The data to sign + * @param flags - Signing flags (usually 0) + * @returns The signature blob + */ + async sign(publicKeyBlob: Buffer, data: Buffer, flags: number = 0): Promise { + // Build SIGN_REQUEST message + // Format: [type:1][key_blob_len:4][key_blob][data_len:4][data][flags:4] + const message = Buffer.concat([ + Buffer.from([AgentMessageType.SSH_AGENTC_SIGN_REQUEST]), + this.encodeBuffer(publicKeyBlob), + this.encodeBuffer(data), + this.encodeUInt32(flags), + ]); + + const response = await this.sendMessage(message); + + // Parse response + const responseType = response[0]; + + if (responseType === AgentMessageType.SSH_AGENT_FAILURE) { + throw new Error('Agent returned failure for sign request'); + } + + if (responseType !== AgentMessageType.SSH_AGENT_SIGN_RESPONSE) { + throw new Error(`Unexpected response type: ${responseType}`); + } + + // Parse signature + // Format: [type:1][sig_blob_len:4][sig_blob] + if (response.length < 5) { + throw new Error('Invalid sign response: too short'); + } + + const sigLength = response.readUInt32BE(1); + if (response.length < 5 + sigLength) { + throw new Error('Invalid sign response: incomplete signature'); + } + + const signatureBlob = response.slice(5, 5 + sigLength); + + // The signature blob format from the agent is: [algo_len:4][algo:string][sig_len:4][sig:bytes] + // But ssh2 expects only the raw signature bytes (without the algorithm wrapper) + // because Protocol.authPK will add the algorithm wrapper itself + + // Parse the blob to extract just the signature bytes + if (signatureBlob.length < 4) { + throw new Error('Invalid signature blob: too short for algo length'); + } + + const algoLen = signatureBlob.readUInt32BE(0); + if (signatureBlob.length < 4 + algoLen + 4) { + throw new Error('Invalid signature blob: too short for algo and sig length'); + } + + const sigLen = signatureBlob.readUInt32BE(4 + algoLen); + if (signatureBlob.length < 4 + algoLen + 4 + sigLen) { + throw new Error('Invalid signature blob: incomplete signature bytes'); + } + + // Extract ONLY the raw signature bytes (without algo wrapper) + return signatureBlob.slice(4 + algoLen + 4, 4 + algoLen + 4 + sigLen); + } + + /** + * Encode a buffer with length prefix (SSH wire format) + */ + private encodeBuffer(data: Buffer): Buffer { + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(data.length, 0); + return Buffer.concat([length, data]); + } + + /** + * Encode a uint32 in big-endian format + */ + private encodeUInt32(value: number): Buffer { + const buf = Buffer.allocUnsafe(4); + buf.writeUInt32BE(value, 0); + return buf; + } + + /** + * Close the agent proxy + */ + close(): void { + if (this.channel && !this.channel.destroyed) { + this.channel.close(); + } + this.pendingResponse = null; + this.removeAllListeners(); + } +} diff --git a/src/proxy/ssh/GitProtocol.ts b/src/proxy/ssh/GitProtocol.ts new file mode 100644 index 000000000..abee4e1ee --- /dev/null +++ b/src/proxy/ssh/GitProtocol.ts @@ -0,0 +1,305 @@ +/** + * Git Protocol Handling for SSH + * + * This module handles the git pack protocol communication with remote Git servers (such as GitHub). + * It manages: + * - Fetching capabilities and refs from remote + * - Forwarding pack data for push operations + * - Setting up bidirectional streams for pull operations + */ + +import * as ssh2 from 'ssh2'; +import { ClientWithUser } from './types'; +import { validateSSHPrerequisites, createSSHConnectionOptions } from './sshHelpers'; + +/** + * Parser for Git pkt-line protocol + * Git uses pkt-line format: [4 byte hex length][payload] + * Special packet "0000" (flush packet) indicates end of section + */ +class PktLineParser { + private buffer: Buffer = Buffer.alloc(0); + + /** + * Append data to internal buffer + */ + append(data: Buffer): void { + this.buffer = Buffer.concat([this.buffer, data]); + } + + /** + * Check if we've received a flush packet (0000) indicating end of capabilities + * The flush packet appears after the capabilities/refs section + */ + hasFlushPacket(): boolean { + const bufStr = this.buffer.toString('utf8'); + return bufStr.includes('0000'); + } + + /** + * Get the complete buffer + */ + getBuffer(): Buffer { + return this.buffer; + } +} + +/** + * Fetch capabilities and refs from GitHub without sending any data + * This allows us to validate data BEFORE sending to GitHub + */ +export async function fetchGitHubCapabilities( + command: string, + client: ClientWithUser, +): Promise { + validateSSHPrerequisites(client); + const connectionOptions = createSSHConnectionOptions(client); + + return new Promise((resolve, reject) => { + const remoteGitSsh = new ssh2.Client(); + const parser = new PktLineParser(); + + // Safety timeout (should never be reached) + const timeout = setTimeout(() => { + console.error(`[fetchCapabilities] Timeout waiting for capabilities`); + remoteGitSsh.end(); + reject(new Error('Timeout waiting for capabilities from remote')); + }, 30000); // 30 seconds + + remoteGitSsh.on('ready', () => { + console.log(`[fetchCapabilities] Connected to GitHub`); + + remoteGitSsh.exec(command, (err: Error | undefined, remoteStream: ssh2.ClientChannel) => { + if (err) { + console.error(`[fetchCapabilities] Error executing command:`, err); + clearTimeout(timeout); + remoteGitSsh.end(); + reject(err); + return; + } + + console.log(`[fetchCapabilities] Command executed, waiting for capabilities`); + + // Single data handler that checks for flush packet + remoteStream.on('data', (data: Buffer) => { + parser.append(data); + console.log(`[fetchCapabilities] Received ${data.length} bytes`); + + if (parser.hasFlushPacket()) { + console.log(`[fetchCapabilities] Flush packet detected, capabilities complete`); + clearTimeout(timeout); + remoteStream.end(); + remoteGitSsh.end(); + resolve(parser.getBuffer()); + } + }); + + remoteStream.on('error', (err: Error) => { + console.error(`[fetchCapabilities] Stream error:`, err); + clearTimeout(timeout); + remoteGitSsh.end(); + reject(err); + }); + }); + }); + + remoteGitSsh.on('error', (err: Error) => { + console.error(`[fetchCapabilities] Connection error:`, err); + clearTimeout(timeout); + reject(err); + }); + + remoteGitSsh.connect(connectionOptions); + }); +} + +/** + * Base function for executing Git commands on remote server + * Handles all common SSH connection logic, error handling, and cleanup + * Delegates stream-specific behavior to the provided callback + * + * @param command - The Git command to execute + * @param clientStream - The SSH stream to the client + * @param client - The authenticated client connection + * @param onRemoteStreamReady - Callback invoked when remote stream is ready + */ +async function executeGitCommandOnRemote( + command: string, + clientStream: ssh2.ServerChannel, + client: ClientWithUser, + onRemoteStreamReady: (remoteStream: ssh2.ClientChannel) => void, +): Promise { + validateSSHPrerequisites(client); + + const userName = client.authenticatedUser?.username || 'unknown'; + const connectionOptions = createSSHConnectionOptions(client, { debug: true, keepalive: true }); + + return new Promise((resolve, reject) => { + const remoteGitSsh = new ssh2.Client(); + + const connectTimeout = setTimeout(() => { + console.error(`[SSH] Connection timeout to remote for user ${userName}`); + remoteGitSsh.end(); + clientStream.stderr.write('Connection timeout to remote server\n'); + clientStream.exit(1); + clientStream.end(); + reject(new Error('Connection timeout')); + }, 30000); + + remoteGitSsh.on('ready', () => { + clearTimeout(connectTimeout); + console.log(`[SSH] Connected to remote Git server for user: ${userName}`); + + remoteGitSsh.exec(command, (err: Error | undefined, remoteStream: ssh2.ClientChannel) => { + if (err) { + console.error(`[SSH] Error executing command on remote for user ${userName}:`, err); + clientStream.stderr.write(`Remote execution error: ${err.message}\n`); + clientStream.exit(1); + clientStream.end(); + remoteGitSsh.end(); + reject(err); + return; + } + + console.log(`[SSH] Command executed on remote for user ${userName}`); + + remoteStream.on('close', () => { + console.log(`[SSH] Remote stream closed for user: ${userName}`); + clientStream.end(); + remoteGitSsh.end(); + console.log(`[SSH] Remote connection closed for user: ${userName}`); + resolve(); + }); + + remoteStream.on('exit', (code: number, signal?: string) => { + console.log( + `[SSH] Remote command exited for user ${userName} with code: ${code}, signal: ${signal || 'none'}`, + ); + clientStream.exit(code || 0); + resolve(); + }); + + remoteStream.on('error', (err: Error) => { + console.error(`[SSH] Remote stream error for user ${userName}:`, err); + clientStream.stderr.write(`Stream error: ${err.message}\n`); + clientStream.exit(1); + clientStream.end(); + remoteGitSsh.end(); + reject(err); + }); + + try { + onRemoteStreamReady(remoteStream); + } catch (callbackError) { + console.error(`[SSH] Error in stream callback for user ${userName}:`, callbackError); + clientStream.stderr.write(`Internal error: ${callbackError}\n`); + clientStream.exit(1); + clientStream.end(); + remoteGitSsh.end(); + reject(callbackError); + } + }); + }); + + remoteGitSsh.on('error', (err: Error) => { + console.error(`[SSH] Remote connection error for user ${userName}:`, err); + clearTimeout(connectTimeout); + clientStream.stderr.write(`Connection error: ${err.message}\n`); + clientStream.exit(1); + clientStream.end(); + reject(err); + }); + + remoteGitSsh.connect(connectionOptions); + }); +} + +/** + * Forward pack data to remote Git server (used for push operations) + * This connects to GitHub, sends the validated pack data, and forwards responses + */ +export async function forwardPackDataToRemote( + command: string, + stream: ssh2.ServerChannel, + client: ClientWithUser, + packData: Buffer | null, + capabilitiesSize?: number, +): Promise { + const userName = client.authenticatedUser?.username || 'unknown'; + + await executeGitCommandOnRemote(command, stream, client, (remoteStream) => { + console.log(`[SSH] Forwarding pack data for user ${userName}`); + + // Send pack data to GitHub + if (packData && packData.length > 0) { + console.log(`[SSH] Writing ${packData.length} bytes of pack data to remote`); + remoteStream.write(packData); + } + remoteStream.end(); + + // Skip duplicate capabilities that we already sent to client + let bytesSkipped = 0; + const CAPABILITY_BYTES_TO_SKIP = capabilitiesSize || 0; + + remoteStream.on('data', (data: Buffer) => { + if (CAPABILITY_BYTES_TO_SKIP > 0 && bytesSkipped < CAPABILITY_BYTES_TO_SKIP) { + const remainingToSkip = CAPABILITY_BYTES_TO_SKIP - bytesSkipped; + + if (data.length <= remainingToSkip) { + bytesSkipped += data.length; + console.log( + `[SSH] Skipping ${data.length} bytes of capabilities (${bytesSkipped}/${CAPABILITY_BYTES_TO_SKIP})`, + ); + return; + } else { + const actualResponse = data.slice(remainingToSkip); + bytesSkipped = CAPABILITY_BYTES_TO_SKIP; + console.log( + `[SSH] Capabilities skipped (${CAPABILITY_BYTES_TO_SKIP} bytes), forwarding response (${actualResponse.length} bytes)`, + ); + stream.write(actualResponse); + return; + } + } + // Forward all data after capabilities + stream.write(data); + }); + }); +} + +/** + * Connect to remote Git server and set up bidirectional stream (used for pull operations) + * This creates a simple pipe between client and remote for pull/clone operations + */ +export async function connectToRemoteGitServer( + command: string, + stream: ssh2.ServerChannel, + client: ClientWithUser, +): Promise { + const userName = client.authenticatedUser?.username || 'unknown'; + + await executeGitCommandOnRemote(command, stream, client, (remoteStream) => { + console.log(`[SSH] Setting up bidirectional piping for user ${userName}`); + + // Pipe client data to remote + stream.on('data', (data: Buffer) => { + remoteStream.write(data); + }); + + // Pipe remote data to client + remoteStream.on('data', (data: Buffer) => { + stream.write(data); + }); + + remoteStream.on('error', (err: Error) => { + if (err.message.includes('early EOF') || err.message.includes('unexpected disconnect')) { + console.log( + `[SSH] Detected early EOF for user ${userName}, this is usually harmless during Git operations`, + ); + return; + } + // Re-throw other errors + throw err; + }); + }); +} diff --git a/src/proxy/ssh/server.ts b/src/proxy/ssh/server.ts index 1f0f69878..4959609d9 100644 --- a/src/proxy/ssh/server.ts +++ b/src/proxy/ssh/server.ts @@ -1,41 +1,22 @@ import * as ssh2 from 'ssh2'; import * as fs from 'fs'; import * as bcrypt from 'bcryptjs'; -import { getSSHConfig, getProxyUrl, getMaxPackSizeBytes, getDomains } from '../../config'; +import { getSSHConfig, getMaxPackSizeBytes, getDomains } from '../../config'; import { serverConfig } from '../../config/env'; import chain from '../chain'; import * as db from '../../db'; import { Action } from '../actions'; -import { SSHAgent } from '../../security/SSHAgent'; -import { SSHKeyManager } from '../../security/SSHKeyManager'; -import { KILOBYTE, MEGABYTE } from '../../constants'; - -interface SSHUser { - username: string; - password?: string | null; - publicKeys?: string[]; - email?: string; - gitAccount?: string; -} - -interface AuthenticatedUser { - username: string; - email?: string; - gitAccount?: string; -} -interface ClientWithUser extends ssh2.Connection { - userPrivateKey?: { - keyType: string; - keyData: Buffer; - }; - authenticatedUser?: AuthenticatedUser; - clientIp?: string; -} +import { + fetchGitHubCapabilities, + forwardPackDataToRemote, + connectToRemoteGitServer, +} from './GitProtocol'; +import { ClientWithUser } from './types'; +import { createMockResponse } from './sshHelpers'; export class SSHServer { private server: ssh2.Server; - private keepaliveTimers: Map = new Map(); constructor() { const sshConfig = getSSHConfig(); @@ -70,89 +51,70 @@ export class SSHServer { } private resolveHostHeader(): string { - const proxyPort = Number(serverConfig.GIT_PROXY_SERVER_PORT) || 8000; + const port = Number(serverConfig.GIT_PROXY_SERVER_PORT) || 8000; const domains = getDomains(); - const candidateHosts = [ - typeof domains?.service === 'string' ? domains.service : undefined, - typeof serverConfig.GIT_PROXY_UI_HOST === 'string' - ? serverConfig.GIT_PROXY_UI_HOST - : undefined, - ]; - - for (const candidate of candidateHosts) { - const host = this.extractHostname(candidate); - if (host) { - return `${host}:${proxyPort}`; - } - } - - return `localhost:${proxyPort}`; - } - private extractHostname(candidate?: string): string | null { - if (!candidate) { - return null; - } - - const trimmed = candidate.trim(); - if (!trimmed) { - return null; - } + // Try service domain first, then UI host + const rawHost = domains?.service || serverConfig.GIT_PROXY_UI_HOST || 'localhost'; - const attemptParse = (value: string): string | null => { - try { - const parsed = new URL(value); - if (parsed.hostname) { - return parsed.hostname; - } - if (parsed.host) { - return parsed.host; - } - } catch { - return null; - } - return null; - }; + const cleanHost = rawHost + .replace(/^https?:\/\//, '') // Remove protocol + .split('/')[0] // Remove path + .split(':')[0]; // Remove port - // Try parsing the raw string - let host = attemptParse(trimmed); - if (host) { - return host; - } - - // Try assuming https scheme if missing - host = attemptParse(`https://${trimmed}`); - if (host) { - return host; - } - - // Fallback: remove protocol-like prefixes and trailing paths - const withoutScheme = trimmed.replace(/^[a-zA-Z]+:\/\//, ''); - const withoutPath = withoutScheme.split('/')[0]; - const hostnameOnly = withoutPath.split(':')[0]; - return hostnameOnly || null; + return `${cleanHost}:${port}`; } private buildAuthContext(client: ClientWithUser) { - const sshConfig = getSSHConfig(); - const serviceToken = - sshConfig?.clone?.serviceToken && - sshConfig.clone.serviceToken.username && - sshConfig.clone.serviceToken.password - ? { - username: sshConfig.clone.serviceToken.username, - password: sshConfig.clone.serviceToken.password, - } - : undefined; - return { protocol: 'ssh' as const, username: client.authenticatedUser?.username, email: client.authenticatedUser?.email, gitAccount: client.authenticatedUser?.gitAccount, - sshKey: client.userPrivateKey, clientIp: client.clientIp, - cloneServiceToken: serviceToken, + agentForwardingEnabled: client.agentForwardingEnabled || false, + }; + } + + /** + * Create a mock request object for security chain validation + */ + private createChainRequest( + repoPath: string, + gitPath: string, + client: ClientWithUser, + method: 'GET' | 'POST', + packData?: Buffer | null, + ): any { + const hostHeader = this.resolveHostHeader(); + const contentType = + method === 'POST' + ? 'application/x-git-receive-pack-request' + : 'application/x-git-upload-pack-request'; + + return { + originalUrl: `/${repoPath}/${gitPath}`, + url: `/${repoPath}/${gitPath}`, + method, + headers: { + 'user-agent': 'git/ssh-proxy', + 'content-type': contentType, + host: hostHeader, + ...(packData && { 'content-length': packData.length.toString() }), + 'x-forwarded-proto': 'https', + 'x-forwarded-host': hostHeader, + }, + body: packData || null, + bodyRaw: packData || null, + user: client.authenticatedUser || null, + isSSH: true, + protocol: 'ssh' as const, + sshUser: { + username: client.authenticatedUser?.username || 'unknown', + email: client.authenticatedUser?.email, + gitAccount: client.authenticatedUser?.gitAccount, + }, + authContext: this.buildAuthContext(client), }; } @@ -183,57 +145,34 @@ export class SSHServer { const clientWithUser = client as ClientWithUser; clientWithUser.clientIp = clientIp; - // Set up connection timeout (10 minutes) const connectionTimeout = setTimeout(() => { console.log(`[SSH] Connection timeout for ${clientIp} - closing`); client.end(); }, 600000); // 10 minute timeout - // Set up client error handling client.on('error', (err: Error) => { console.error(`[SSH] Client error from ${clientIp}:`, err); clearTimeout(connectionTimeout); - // Don't end the connection on error, let it try to recover }); - // Handle client end client.on('end', () => { console.log(`[SSH] Client disconnected from ${clientIp}`); clearTimeout(connectionTimeout); - // Clean up keepalive timer - const keepaliveTimer = this.keepaliveTimers.get(client); - if (keepaliveTimer) { - clearInterval(keepaliveTimer); - this.keepaliveTimers.delete(client); - } }); - // Handle client close client.on('close', () => { console.log(`[SSH] Client connection closed from ${clientIp}`); clearTimeout(connectionTimeout); - // Clean up keepalive timer - const keepaliveTimer = this.keepaliveTimers.get(client); - if (keepaliveTimer) { - clearInterval(keepaliveTimer); - this.keepaliveTimers.delete(client); - } }); - // Handle keepalive requests (client as any).on('global request', (accept: () => void, reject: () => void, info: any) => { - console.log('[SSH] Global request:', info); if (info.type === 'keepalive@openssh.com') { - console.log('[SSH] Accepting keepalive request'); - // Always accept keepalive requests to prevent connection drops accept(); } else { - console.log('[SSH] Rejecting unknown global request:', info.type); reject(); } }); - // Handle authentication client.on('authentication', (ctx: ssh2.AuthContext) => { console.log( `[SSH] Authentication attempt from ${clientIp}:`, @@ -243,7 +182,6 @@ export class SSHServer { ); if (ctx.method === 'publickey') { - // Handle public key authentication const keyString = `${ctx.key.algo} ${ctx.key.data.toString('base64')}`; (db as any) @@ -253,11 +191,6 @@ export class SSHServer { console.log( `[SSH] Public key authentication successful for user: ${user.username} from ${clientIp}`, ); - // Store the public key info and user context for later use - clientWithUser.userPrivateKey = { - keyType: ctx.key.algo, - keyData: ctx.key.data, - }; clientWithUser.authenticatedUser = { username: user.username, email: user.email, @@ -274,9 +207,8 @@ export class SSHServer { ctx.reject(); }); } else if (ctx.method === 'password') { - // Handle password authentication db.findUser(ctx.username) - .then((user: SSHUser | null) => { + .then((user) => { if (user && user.password) { bcrypt.compare( ctx.password, @@ -289,7 +221,6 @@ export class SSHServer { console.log( `[SSH] Password authentication successful for user: ${user.username} from ${clientIp}`, ); - // Store user context for later use clientWithUser.authenticatedUser = { username: user.username, email: user.email, @@ -317,57 +248,49 @@ export class SSHServer { } }); - // Set up keepalive timer - const startKeepalive = (): void => { - // Clean up any existing timer - const existingTimer = this.keepaliveTimers.get(client); - if (existingTimer) { - clearInterval(existingTimer); - } - - const keepaliveTimer = setInterval(() => { - if ((client as any).connected !== false) { - console.log(`[SSH] Sending keepalive to ${clientIp}`); - try { - (client as any).ping(); - } catch (error) { - console.error(`[SSH] Error sending keepalive to ${clientIp}:`, error); - // Don't clear the timer on error, let it try again - } - } else { - console.log(`[SSH] Client ${clientIp} disconnected, clearing keepalive`); - clearInterval(keepaliveTimer); - this.keepaliveTimers.delete(client); - } - }, 15000); // 15 seconds between keepalives (recommended for SSH connections is 15-30 seconds) - - this.keepaliveTimers.set(client, keepaliveTimer); - }; - - // Handle ready state client.on('ready', () => { console.log( - `[SSH] Client ready from ${clientIp}, user: ${clientWithUser.authenticatedUser?.username || 'unknown'}, starting keepalive`, + `[SSH] Client ready from ${clientIp}, user: ${clientWithUser.authenticatedUser?.username || 'unknown'}`, ); clearTimeout(connectionTimeout); - startKeepalive(); }); - // Handle session requests client.on('session', (accept: () => ssh2.ServerChannel, reject: () => void) => { - console.log('[SSH] Session requested'); const session = accept(); - // Handle command execution session.on( 'exec', (accept: () => ssh2.ServerChannel, reject: () => void, info: { command: string }) => { - console.log('[SSH] Command execution requested:', info.command); const stream = accept(); - this.handleCommand(info.command, stream, clientWithUser); }, ); + + // Handle SSH agent forwarding requests + // ssh2 emits 'auth-agent' event + session.on('auth-agent', (...args: any[]) => { + const accept = args[0]; + + if (typeof accept === 'function') { + accept(); + } else { + // Client sent wantReply=false, manually send CHANNEL_SUCCESS + try { + const channelInfo = (session as any)._chanInfo; + if (channelInfo && channelInfo.outgoing && channelInfo.outgoing.id !== undefined) { + const proto = (client as any)._protocol || (client as any)._sock; + if (proto && typeof proto.channelSuccess === 'function') { + proto.channelSuccess(channelInfo.outgoing.id); + } + } + } catch (err) { + console.error('[SSH] Failed to send CHANNEL_SUCCESS:', err); + } + } + + clientWithUser.agentForwardingEnabled = true; + console.log('[SSH] Agent forwarding enabled'); + }); }); } @@ -380,7 +303,6 @@ export class SSHServer { const clientIp = client.clientIp || 'unknown'; console.log(`[SSH] Handling command from ${userName}@${clientIp}: ${command}`); - // Validate user is authenticated if (!client.authenticatedUser) { console.error(`[SSH] Unauthenticated command attempt from ${clientIp}`); stream.stderr.write('Authentication required\n'); @@ -390,7 +312,6 @@ export class SSHServer { } try { - // Check if it's a Git command if (command.startsWith('git-upload-pack') || command.startsWith('git-receive-pack')) { await this.handleGitCommand(command, stream, client); } else { @@ -419,7 +340,11 @@ export class SSHServer { throw new Error('Invalid Git command format'); } - const repoPath = repoMatch[1]; + let repoPath = repoMatch[1]; + // Remove leading slash if present to avoid double slashes in URL construction + if (repoPath.startsWith('/')) { + repoPath = repoPath.substring(1); + } const isReceivePack = command.includes('git-receive-pack'); const gitPath = isReceivePack ? 'git-receive-pack' : 'git-upload-pack'; @@ -428,10 +353,8 @@ export class SSHServer { ); if (isReceivePack) { - // For push operations (git-receive-pack), we need to capture pack data first await this.handlePushOperation(command, stream, client, repoPath, gitPath); } else { - // For pull operations (git-upload-pack), execute chain first then stream await this.handlePullOperation(command, stream, client, repoPath, gitPath); } } catch (error) { @@ -449,14 +372,19 @@ export class SSHServer { repoPath: string, gitPath: string, ): Promise { - console.log(`[SSH] Handling push operation for ${repoPath}`); + console.log( + `[SSH] Handling push operation for ${repoPath} (secure mode: validate BEFORE sending to GitHub)`, + ); - // Create pack data capture buffers - const packDataChunks: Buffer[] = []; - let totalBytes = 0; const maxPackSize = getMaxPackSizeBytes(); const maxPackSizeDisplay = this.formatBytes(maxPackSize); - const hostHeader = this.resolveHostHeader(); + const userName = client.authenticatedUser?.username || 'unknown'; + + const capabilities = await fetchGitHubCapabilities(command, client); + stream.write(capabilities); + + const packDataChunks: Buffer[] = []; + let totalBytes = 0; // Set up data capture from client stream const dataHandler = (data: Buffer) => { @@ -484,7 +412,7 @@ export class SSHServer { packDataChunks.push(data); totalBytes += data.length; - console.log(`[SSH] Captured ${data.length} bytes, total: ${totalBytes} bytes`); + // NOTE: Data is buffered, NOT sent to GitHub yet } catch (error) { console.error(`[SSH] Error processing data chunk:`, error); stream.stderr.write(`Error: Failed to process data chunk: ${error}\n`); @@ -494,16 +422,17 @@ export class SSHServer { }; const endHandler = async () => { - console.log(`[SSH] Pack data capture complete: ${totalBytes} bytes`); + console.log(`[SSH] Received ${totalBytes} bytes, validating with security chain`); try { - // Validate pack data before processing if (packDataChunks.length === 0 && totalBytes === 0) { console.warn(`[SSH] No pack data received for push operation`); // Allow empty pushes (e.g., tag creation without commits) + stream.exit(0); + stream.end(); + return; } - // Concatenate all pack data chunks with error handling let packData: Buffer | null = null; try { packData = packDataChunks.length > 0 ? Buffer.concat(packDataChunks) : null; @@ -522,52 +451,11 @@ export class SSHServer { return; } - // Create request object with captured pack data - const req = { - originalUrl: `/${repoPath}/${gitPath}`, - url: `/${repoPath}/${gitPath}`, - method: 'POST' as const, - headers: { - 'user-agent': 'git/ssh-proxy', - 'content-type': 'application/x-git-receive-pack-request', - host: hostHeader, - 'content-length': totalBytes.toString(), - 'x-forwarded-proto': 'https', - 'x-forwarded-host': hostHeader, - }, - body: packData, - bodyRaw: packData, - user: client.authenticatedUser || null, - isSSH: true, - protocol: 'ssh' as const, - sshUser: { - username: client.authenticatedUser?.username || 'unknown', - email: client.authenticatedUser?.email, - gitAccount: client.authenticatedUser?.gitAccount, - sshKeyInfo: client.userPrivateKey, - }, - authContext: this.buildAuthContext(client), - }; - - // Create mock response object - const res = { - headers: {}, - statusCode: 200, - set: function (headers: any) { - Object.assign(this.headers, headers); - return this; - }, - status: function (code: number) { - this.statusCode = code; - return this; - }, - send: function (data: any) { - return this; - }, - }; + // Validate with security chain BEFORE sending to GitHub + const req = this.createChainRequest(repoPath, gitPath, client, 'POST', packData); + const res = createMockResponse(); // Execute the proxy chain with captured pack data - console.log(`[SSH] Executing security chain for push operation`); let chainResult: Action; try { chainResult = await chain.executeChain(req, res); @@ -584,17 +472,8 @@ export class SSHServer { throw new Error(message); } - console.log(`[SSH] Security chain passed, forwarding to remote`); - // Chain passed, now forward the captured data to remote - try { - await this.forwardPackDataToRemote(command, stream, client, packData, chainResult); - } catch (forwardError) { - console.error(`[SSH] Error forwarding pack data to remote:`, forwardError); - stream.stderr.write(`Error forwarding to remote: ${forwardError}\n`); - stream.exit(1); - stream.end(); - return; - } + console.log(`[SSH] Security chain passed, forwarding to GitHub`); + await forwardPackDataToRemote(command, stream, client, packData, capabilities.length); } catch (chainError: unknown) { console.error( `[SSH] Chain execution failed for user ${client.authenticatedUser?.username}:`, @@ -609,35 +488,31 @@ export class SSHServer { }; const errorHandler = (error: Error) => { - console.error(`[SSH] Stream error during pack capture:`, error); + console.error(`[SSH] Stream error during push:`, error); stream.stderr.write(`Stream error: ${error.message}\n`); stream.exit(1); stream.end(); }; - // Set up timeout for pack data capture (5 minutes max) - const captureTimeout = setTimeout(() => { - console.error( - `[SSH] Pack data capture timeout for user ${client.authenticatedUser?.username}`, - ); - stream.stderr.write('Error: Pack data capture timeout\n'); + const pushTimeout = setTimeout(() => { + console.error(`[SSH] Push operation timeout for user ${userName}`); + stream.stderr.write('Error: Push operation timeout\n'); stream.exit(1); stream.end(); }, 300000); // 5 minutes // Clean up timeout when stream ends - const originalEndHandler = endHandler; const timeoutAwareEndHandler = async () => { - clearTimeout(captureTimeout); - await originalEndHandler(); + clearTimeout(pushTimeout); + await endHandler(); }; const timeoutAwareErrorHandler = (error: Error) => { - clearTimeout(captureTimeout); + clearTimeout(pushTimeout); errorHandler(error); }; - // Attach event handlers + // Attach event handlers to receive pack data from client stream.on('data', dataHandler); stream.once('end', timeoutAwareEndHandler); stream.on('error', timeoutAwareErrorHandler); @@ -651,52 +526,13 @@ export class SSHServer { gitPath: string, ): Promise { console.log(`[SSH] Handling pull operation for ${repoPath}`); - const hostHeader = this.resolveHostHeader(); // For pull operations, execute chain first (no pack data to capture) - const req = { - originalUrl: `/${repoPath}/${gitPath}`, - url: `/${repoPath}/${gitPath}`, - method: 'GET' as const, - headers: { - 'user-agent': 'git/ssh-proxy', - 'content-type': 'application/x-git-upload-pack-request', - host: hostHeader, - 'x-forwarded-proto': 'https', - 'x-forwarded-host': hostHeader, - }, - body: null, - user: client.authenticatedUser || null, - isSSH: true, - protocol: 'ssh' as const, - sshUser: { - username: client.authenticatedUser?.username || 'unknown', - email: client.authenticatedUser?.email, - gitAccount: client.authenticatedUser?.gitAccount, - sshKeyInfo: client.userPrivateKey, - }, - authContext: this.buildAuthContext(client), - }; - - const res = { - headers: {}, - statusCode: 200, - set: function (headers: any) { - Object.assign(this.headers, headers); - return this; - }, - status: function (code: number) { - this.statusCode = code; - return this; - }, - send: function (data: any) { - return this; - }, - }; + const req = this.createChainRequest(repoPath, gitPath, client, 'GET'); + const res = createMockResponse(); // Execute the proxy chain try { - console.log(`[SSH] Executing security chain for pull operation`); const result = await chain.executeChain(req, res); if (result.error || result.blocked) { const message = @@ -704,9 +540,8 @@ export class SSHServer { throw new Error(message); } - console.log(`[SSH] Security chain passed, connecting to remote`); // Chain passed, connect to remote Git server - await this.connectToRemoteGitServer(command, stream, client); + await connectToRemoteGitServer(command, stream, client); } catch (chainError: unknown) { console.error( `[SSH] Chain execution failed for user ${client.authenticatedUser?.username}:`, @@ -720,447 +555,6 @@ export class SSHServer { } } - private async forwardPackDataToRemote( - command: string, - stream: ssh2.ServerChannel, - client: ClientWithUser, - packData: Buffer | null, - action?: Action, - ): Promise { - return new Promise((resolve, reject) => { - const userName = client.authenticatedUser?.username || 'unknown'; - console.log(`[SSH] Forwarding pack data to remote for user: ${userName}`); - - // Get remote host from config - const proxyUrl = getProxyUrl(); - if (!proxyUrl) { - const error = new Error('No proxy URL configured'); - console.error(`[SSH] ${error.message}`); - stream.stderr.write(`Configuration error: ${error.message}\n`); - stream.exit(1); - stream.end(); - reject(error); - return; - } - - const remoteUrl = new URL(proxyUrl); - const sshConfig = getSSHConfig(); - - const sshAgentInstance = SSHAgent.getInstance(); - let agentKeyCopy: Buffer | null = null; - let decryptedKey: Buffer | null = null; - - if (action?.id) { - const agentKey = sshAgentInstance.getPrivateKey(action.id); - if (agentKey) { - agentKeyCopy = Buffer.from(agentKey); - } - } - - if (!agentKeyCopy && action?.encryptedSSHKey && action?.sshKeyExpiry) { - const expiry = new Date(action.sshKeyExpiry); - if (!Number.isNaN(expiry.getTime())) { - const decrypted = SSHKeyManager.decryptSSHKey(action.encryptedSSHKey, expiry); - if (decrypted) { - decryptedKey = decrypted; - } - } - } - - const userPrivateKey = agentKeyCopy ?? decryptedKey; - const usingUserKey = Boolean(userPrivateKey); - const proxyPrivateKey = fs.readFileSync(sshConfig.hostKey.privateKeyPath); - - if (usingUserKey) { - console.log( - `[SSH] Using caller SSH key for push ${action?.id ?? 'unknown'} when forwarding to remote`, - ); - } else { - console.log( - '[SSH] Falling back to proxy SSH key when forwarding to remote (no caller key available)', - ); - } - - let cleanupRan = false; - const cleanupForwardingKey = () => { - if (cleanupRan) { - return; - } - cleanupRan = true; - if (usingUserKey && action?.id) { - sshAgentInstance.removeKey(action.id); - } - if (agentKeyCopy) { - agentKeyCopy.fill(0); - } - if (decryptedKey) { - decryptedKey.fill(0); - } - }; - - // Set up connection options (same as original connectToRemoteGitServer) - const connectionOptions: any = { - host: remoteUrl.hostname, - port: 22, - username: 'git', - tryKeyboard: false, - readyTimeout: 30000, - keepaliveInterval: 15000, - keepaliveCountMax: 5, - windowSize: 1 * MEGABYTE, - packetSize: 32 * KILOBYTE, - privateKey: usingUserKey ? (userPrivateKey as Buffer) : proxyPrivateKey, - debug: (msg: string) => { - console.debug('[GitHub SSH Debug]', msg); - }, - algorithms: { - kex: [ - 'ecdh-sha2-nistp256' as any, - 'ecdh-sha2-nistp384' as any, - 'ecdh-sha2-nistp521' as any, - 'diffie-hellman-group14-sha256' as any, - 'diffie-hellman-group16-sha512' as any, - 'diffie-hellman-group18-sha512' as any, - ], - serverHostKey: ['rsa-sha2-512' as any, 'rsa-sha2-256' as any, 'ssh-rsa' as any], - cipher: [ - 'aes128-gcm' as any, - 'aes256-gcm' as any, - 'aes128-ctr' as any, - 'aes256-ctr' as any, - ], - hmac: ['hmac-sha2-256' as any, 'hmac-sha2-512' as any], - }, - }; - - const remoteGitSsh = new ssh2.Client(); - - // Handle connection success - remoteGitSsh.on('ready', () => { - console.log(`[SSH] Connected to remote Git server for user: ${userName}`); - - // Execute the Git command on the remote server - remoteGitSsh.exec(command, (err: Error | undefined, remoteStream: ssh2.ClientChannel) => { - if (err) { - console.error(`[SSH] Error executing command on remote for user ${userName}:`, err); - stream.stderr.write(`Remote execution error: ${err.message}\n`); - stream.exit(1); - stream.end(); - remoteGitSsh.end(); - cleanupForwardingKey(); - reject(err); - return; - } - - console.log( - `[SSH] Command executed on remote for user ${userName}, forwarding pack data`, - ); - - // Forward the captured pack data to remote - if (packData && packData.length > 0) { - console.log(`[SSH] Writing ${packData.length} bytes of pack data to remote`); - remoteStream.write(packData); - } - - // End the write stream to signal completion - remoteStream.end(); - - // Handle remote response - remoteStream.on('data', (data: any) => { - stream.write(data); - }); - - remoteStream.on('close', () => { - console.log(`[SSH] Remote stream closed for user: ${userName}`); - cleanupForwardingKey(); - stream.end(); - resolve(); - }); - - remoteStream.on('exit', (code: number, signal?: string) => { - console.log( - `[SSH] Remote command exited for user ${userName} with code: ${code}, signal: ${signal || 'none'}`, - ); - stream.exit(code || 0); - cleanupForwardingKey(); - resolve(); - }); - - remoteStream.on('error', (err: Error) => { - console.error(`[SSH] Remote stream error for user ${userName}:`, err); - stream.stderr.write(`Stream error: ${err.message}\n`); - stream.exit(1); - stream.end(); - cleanupForwardingKey(); - reject(err); - }); - }); - }); - - // Handle connection errors - remoteGitSsh.on('error', (err: Error) => { - console.error(`[SSH] Remote connection error for user ${userName}:`, err); - stream.stderr.write(`Connection error: ${err.message}\n`); - stream.exit(1); - stream.end(); - cleanupForwardingKey(); - reject(err); - }); - - // Set connection timeout - const connectTimeout = setTimeout(() => { - console.error(`[SSH] Connection timeout to remote for user ${userName}`); - remoteGitSsh.end(); - stream.stderr.write('Connection timeout to remote server\n'); - stream.exit(1); - stream.end(); - cleanupForwardingKey(); - reject(new Error('Connection timeout')); - }, 30000); - - remoteGitSsh.on('ready', () => { - clearTimeout(connectTimeout); - }); - - // Connect to remote - console.log(`[SSH] Connecting to ${remoteUrl.hostname} for user ${userName}`); - remoteGitSsh.connect(connectionOptions); - }); - } - - private async connectToRemoteGitServer( - command: string, - stream: ssh2.ServerChannel, - client: ClientWithUser, - ): Promise { - return new Promise((resolve, reject) => { - const userName = client.authenticatedUser?.username || 'unknown'; - console.log(`[SSH] Creating SSH connection to remote for user: ${userName}`); - - // Get remote host from config - const proxyUrl = getProxyUrl(); - if (!proxyUrl) { - const error = new Error('No proxy URL configured'); - console.error(`[SSH] ${error.message}`); - stream.stderr.write(`Configuration error: ${error.message}\n`); - stream.exit(1); - stream.end(); - reject(error); - return; - } - - const remoteUrl = new URL(proxyUrl); - const sshConfig = getSSHConfig(); - - // TODO: Connection options could go to config - // Set up connection options - const connectionOptions: any = { - host: remoteUrl.hostname, - port: 22, - username: 'git', - tryKeyboard: false, - readyTimeout: 30000, - keepaliveInterval: 15000, // 15 seconds between keepalives (recommended for SSH connections is 15-30 seconds) - keepaliveCountMax: 5, // Recommended for SSH connections is 3-5 attempts - windowSize: 1 * MEGABYTE, // 1MB window size - packetSize: 32 * KILOBYTE, // 32KB packet size - privateKey: fs.readFileSync(sshConfig.hostKey.privateKeyPath), - debug: (msg: string) => { - console.debug('[GitHub SSH Debug]', msg); - }, - algorithms: { - kex: [ - 'ecdh-sha2-nistp256' as any, - 'ecdh-sha2-nistp384' as any, - 'ecdh-sha2-nistp521' as any, - 'diffie-hellman-group14-sha256' as any, - 'diffie-hellman-group16-sha512' as any, - 'diffie-hellman-group18-sha512' as any, - ], - serverHostKey: ['rsa-sha2-512' as any, 'rsa-sha2-256' as any, 'ssh-rsa' as any], - cipher: [ - 'aes128-gcm' as any, - 'aes256-gcm' as any, - 'aes128-ctr' as any, - 'aes256-ctr' as any, - ], - hmac: ['hmac-sha2-256' as any, 'hmac-sha2-512' as any], - }, - }; - - // Get the client's SSH key that was used for authentication - const clientKey = client.userPrivateKey; - console.log('[SSH] Client key:', clientKey ? 'Available' : 'Not available'); - - // Handle client key if available (though we only have public key data) - if (clientKey) { - console.log('[SSH] Using client key info:', JSON.stringify(clientKey)); - // Check if the key is in the correct format - if (typeof clientKey === 'object' && clientKey.keyType && clientKey.keyData) { - // We need to use the private key, not the public key data - // Since we only have the public key from authentication, we'll use the proxy key - console.log('[SSH] Only have public key data, using proxy key instead'); - } else if (Buffer.isBuffer(clientKey)) { - // The key is a buffer, use it directly - connectionOptions.privateKey = clientKey; - console.log('[SSH] Using client key buffer directly'); - } else { - // For other key types, we can't use the client key directly since we only have public key info - console.log('[SSH] Client key is not a buffer, falling back to proxy key'); - } - } else { - console.log('[SSH] No client key available, using proxy key'); - } - - // Log the key type for debugging - if (connectionOptions.privateKey) { - if ( - typeof connectionOptions.privateKey === 'object' && - (connectionOptions.privateKey as any).algo - ) { - console.log(`[SSH] Key algo: ${(connectionOptions.privateKey as any).algo}`); - } else if (Buffer.isBuffer(connectionOptions.privateKey)) { - console.log(`[SSH] Key is a buffer of length: ${connectionOptions.privateKey.length}`); - } else { - console.log(`[SSH] Key is of type: ${typeof connectionOptions.privateKey}`); - } - } - - const remoteGitSsh = new ssh2.Client(); - - // Handle connection success - remoteGitSsh.on('ready', () => { - console.log(`[SSH] Connected to remote Git server for user: ${userName}`); - - // Execute the Git command on the remote server - remoteGitSsh.exec(command, (err: Error | undefined, remoteStream: ssh2.ClientChannel) => { - if (err) { - console.error(`[SSH] Error executing command on remote for user ${userName}:`, err); - stream.stderr.write(`Remote execution error: ${err.message}\n`); - stream.exit(1); - stream.end(); - remoteGitSsh.end(); - reject(err); - return; - } - - console.log( - `[SSH] Command executed on remote for user ${userName}, setting up data piping`, - ); - - // Handle stream errors - remoteStream.on('error', (err: Error) => { - console.error(`[SSH] Remote stream error for user ${userName}:`, err); - // Don't immediately end the stream on error, try to recover - if ( - err.message.includes('early EOF') || - err.message.includes('unexpected disconnect') - ) { - console.log( - `[SSH] Detected early EOF or unexpected disconnect for user ${userName}, attempting to recover`, - ); - // Try to keep the connection alive - if ((remoteGitSsh as any).connected) { - console.log(`[SSH] Connection still active for user ${userName}, continuing`); - // Don't end the stream, let it try to recover - return; - } - } - // If we can't recover, then end the stream - stream.stderr.write(`Stream error: ${err.message}\n`); - stream.end(); - }); - - // Pipe data between client and remote - stream.on('data', (data: any) => { - remoteStream.write(data); - }); - - remoteStream.on('data', (data: any) => { - stream.write(data); - }); - - // Handle stream events - remoteStream.on('close', () => { - console.log(`[SSH] Remote stream closed for user: ${userName}`); - stream.end(); - resolve(); - }); - - remoteStream.on('exit', (code: number, signal?: string) => { - console.log( - `[SSH] Remote command exited for user ${userName} with code: ${code}, signal: ${signal || 'none'}`, - ); - stream.exit(code || 0); - resolve(); - }); - - stream.on('close', () => { - console.log(`[SSH] Client stream closed for user: ${userName}`); - remoteStream.end(); - }); - - stream.on('end', () => { - console.log(`[SSH] Client stream ended for user: ${userName}`); - setTimeout(() => { - remoteGitSsh.end(); - }, 1000); - }); - - // Handle errors on streams - remoteStream.on('error', (err: Error) => { - console.error(`[SSH] Remote stream error for user ${userName}:`, err); - stream.stderr.write(`Stream error: ${err.message}\n`); - }); - - stream.on('error', (err: Error) => { - console.error(`[SSH] Client stream error for user ${userName}:`, err); - remoteStream.destroy(); - }); - }); - }); - - // Handle connection errors - remoteGitSsh.on('error', (err: Error) => { - console.error(`[SSH] Remote connection error for user ${userName}:`, err); - - if (err.message.includes('All configured authentication methods failed')) { - console.log( - `[SSH] Authentication failed with default key for user ${userName}, this may be expected for some servers`, - ); - } - - stream.stderr.write(`Connection error: ${err.message}\n`); - stream.exit(1); - stream.end(); - reject(err); - }); - - // Handle connection close - remoteGitSsh.on('close', () => { - console.log(`[SSH] Remote connection closed for user: ${userName}`); - }); - - // Set a timeout for the connection attempt - const connectTimeout = setTimeout(() => { - console.error(`[SSH] Connection timeout to remote for user ${userName}`); - remoteGitSsh.end(); - stream.stderr.write('Connection timeout to remote server\n'); - stream.exit(1); - stream.end(); - reject(new Error('Connection timeout')); - }, 30000); - - remoteGitSsh.on('ready', () => { - clearTimeout(connectTimeout); - }); - - // Connect to remote - console.log(`[SSH] Connecting to ${remoteUrl.hostname} for user ${userName}`); - remoteGitSsh.connect(connectionOptions); - }); - } - public start(): void { const sshConfig = getSSHConfig(); const port = sshConfig.port || 2222; diff --git a/src/proxy/ssh/sshHelpers.ts b/src/proxy/ssh/sshHelpers.ts new file mode 100644 index 000000000..2610ca7cb --- /dev/null +++ b/src/proxy/ssh/sshHelpers.ts @@ -0,0 +1,103 @@ +import { getProxyUrl } from '../../config'; +import { KILOBYTE, MEGABYTE } from '../../constants'; +import { ClientWithUser } from './types'; +import { createLazyAgent } from './AgentForwarding'; + +/** + * Validate prerequisites for SSH connection to remote + * Throws descriptive errors if requirements are not met + */ +export function validateSSHPrerequisites(client: ClientWithUser): void { + // Check proxy URL + const proxyUrl = getProxyUrl(); + if (!proxyUrl) { + throw new Error('No proxy URL configured'); + } + + // Check agent forwarding + if (!client.agentForwardingEnabled) { + throw new Error( + 'SSH agent forwarding is required. Please connect with: ssh -A\n' + + 'Or configure ~/.ssh/config with: ForwardAgent yes', + ); + } +} + +/** + * Create SSH connection options for connecting to remote Git server + * Includes agent forwarding, algorithms, timeouts, etc. + */ +export function createSSHConnectionOptions( + client: ClientWithUser, + options?: { + debug?: boolean; + keepalive?: boolean; + }, +): any { + const proxyUrl = getProxyUrl(); + if (!proxyUrl) { + throw new Error('No proxy URL configured'); + } + + const remoteUrl = new URL(proxyUrl); + const customAgent = createLazyAgent(client); + + const connectionOptions: any = { + host: remoteUrl.hostname, + port: 22, + username: 'git', + tryKeyboard: false, + readyTimeout: 30000, + agent: customAgent, + algorithms: { + kex: [ + 'ecdh-sha2-nistp256' as any, + 'ecdh-sha2-nistp384' as any, + 'ecdh-sha2-nistp521' as any, + 'diffie-hellman-group14-sha256' as any, + 'diffie-hellman-group16-sha512' as any, + 'diffie-hellman-group18-sha512' as any, + ], + serverHostKey: ['rsa-sha2-512' as any, 'rsa-sha2-256' as any, 'ssh-rsa' as any], + cipher: ['aes128-gcm' as any, 'aes256-gcm' as any, 'aes128-ctr' as any, 'aes256-ctr' as any], + hmac: ['hmac-sha2-256' as any, 'hmac-sha2-512' as any], + }, + }; + + if (options?.keepalive) { + connectionOptions.keepaliveInterval = 15000; + connectionOptions.keepaliveCountMax = 5; + connectionOptions.windowSize = 1 * MEGABYTE; + connectionOptions.packetSize = 32 * KILOBYTE; + } + + if (options?.debug) { + connectionOptions.debug = (msg: string) => { + console.debug('[GitHub SSH Debug]', msg); + }; + } + + return connectionOptions; +} + +/** + * Create a mock response object for security chain validation + * This is used when SSH operations need to go through the proxy chain + */ +export function createMockResponse(): any { + return { + headers: {}, + statusCode: 200, + set: function (headers: any) { + Object.assign(this.headers, headers); + return this; + }, + status: function (code: number) { + this.statusCode = code; + return this; + }, + send: function () { + return this; + }, + }; +} diff --git a/src/proxy/ssh/types.ts b/src/proxy/ssh/types.ts new file mode 100644 index 000000000..82bbe4b1d --- /dev/null +++ b/src/proxy/ssh/types.ts @@ -0,0 +1,21 @@ +import * as ssh2 from 'ssh2'; + +/** + * Authenticated user information + */ +export interface AuthenticatedUser { + username: string; + email?: string; + gitAccount?: string; +} + +/** + * Extended SSH connection (server-side) with user context and agent forwarding + */ +export interface ClientWithUser extends ssh2.Connection { + authenticatedUser?: AuthenticatedUser; + clientIp?: string; + agentForwardingEnabled?: boolean; + agentChannel?: ssh2.Channel; + agentProxy?: any; +} diff --git a/src/service/routes/config.js b/src/service/routes/config.js new file mode 100644 index 000000000..054ffb0c9 --- /dev/null +++ b/src/service/routes/config.js @@ -0,0 +1,26 @@ +const express = require('express'); +const router = new express.Router(); + +const config = require('../../config'); + +router.get('/attestation', function ({ res }) { + res.send(config.getAttestationConfig()); +}); + +router.get('/urlShortener', function ({ res }) { + res.send(config.getURLShortener()); +}); + +router.get('/contactEmail', function ({ res }) { + res.send(config.getContactEmail()); +}); + +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 new file mode 100644 index 000000000..7690b14b2 --- /dev/null +++ b/src/service/routes/users.js @@ -0,0 +1,160 @@ +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`); + const users = await db.getUsers({}); + res.send(users.map(toPublicUser)); +}); + +router.get('/:id', async (req, res) => { + const username = req.params.id.toLowerCase(); + console.log(`Retrieving details for user: ${username}`); + const user = await db.findUser(username); + 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) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const targetUsername = req.params.username.toLowerCase(); + + // Only allow users to add keys to their own account, or admins to add to any account + if (req.user.username !== targetUsername && !req.user.admin) { + res.status(403).json({ error: 'Not authorized to add keys for this user' }); + return; + } + + 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.trim().split(' ').slice(0, 2).join(' '); + + // 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, publicKeyRecord); + res.status(201).json({ + message: 'SSH key added successfully', + fingerprint: fingerprint, + }); + } catch (error) { + console.error('Error adding SSH key:', error); + + // 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 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) { + res.status(403).json({ error: 'Not authorized to remove keys for this user' }); + return; + } + + if (!fingerprint) { + res.status(400).json({ error: 'Fingerprint is required' }); + return; + } + + try { + await db.removePublicKey(targetUsername, fingerprint); + res.status(200).json({ message: 'SSH key removed successfully' }); + } catch (error) { + console.error('Error removing SSH key:', error); + 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' }); + } + } +}); + +module.exports = router; diff --git a/src/service/routes/users.ts b/src/service/routes/users.ts index 82ff1bfdd..dccc323bc 100644 --- a/src/service/routes/users.ts +++ b/src/service/routes/users.ts @@ -1,12 +1,28 @@ import express, { Request, Response } from 'express'; import { utils } from 'ssh2'; +import crypto from 'crypto'; import * as db from '../../db'; import { toPublicUser } from './publicApi'; -import { DuplicateSSHKeyError, UserNotFoundError } from '../../errors/DatabaseErrors'; const router = express.Router(); -const parseKey = utils.parseKey; + +// 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: 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; + } +} router.get('/', async (req: Request, res: Response) => { console.log('fetching users'); @@ -25,72 +41,106 @@ router.get('/:id', async (req: Request, res: Response) => { res.send(toPublicUser(user)); }); +// Get SSH key fingerprints for a user +router.get('/:username/ssh-key-fingerprints', async (req: Request, res: Response) => { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const { username, admin } = req.user as { username: string; admin: boolean }; + const targetUsername = req.params.username.toLowerCase(); + + // Only allow users to view their own keys, or admins to view any keys + if (username !== targetUsername && !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: Request, res: Response) => { if (!req.user) { - res.status(401).json({ error: 'Login required' }); + res.status(401).json({ error: 'Authentication required' }); return; } const { username, admin } = req.user as { username: string; admin: boolean }; const targetUsername = req.params.username.toLowerCase(); - // Admins can add to any account, users can only add to their own + // Only allow users to add keys to their own account, or admins to add to any account if (username !== targetUsername && !admin) { res.status(403).json({ error: 'Not authorized to add keys for this user' }); return; } - const { publicKey } = req.body; - if (!publicKey || typeof publicKey !== 'string') { + const { publicKey, name } = req.body; + if (!publicKey) { res.status(400).json({ error: 'Public key is required' }); return; } - try { - const parsedKey = parseKey(publicKey.trim()); + // Strip the comment from the key (everything after the last space) + const keyWithoutComment = publicKey.trim().split(' ').slice(0, 2).join(' '); - if (parsedKey instanceof Error) { - res.status(400).json({ error: `Invalid SSH key: ${parsedKey.message}` }); - return; - } + // Calculate fingerprint + const fingerprint = calculateFingerprint(keyWithoutComment); + if (!fingerprint) { + res.status(400).json({ error: 'Invalid SSH public key format' }); + return; + } - if (parsedKey.isPrivateKey()) { - res.status(400).json({ error: 'Invalid SSH key: Must be a public key' }); - return; - } + const publicKeyRecord = { + key: keyWithoutComment, + name: name || 'Unnamed Key', + addedAt: new Date().toISOString(), + fingerprint: fingerprint, + }; - const keyWithoutComment = parsedKey.getPublicSSH().toString('utf8'); - console.log('Adding SSH key', { targetUsername, keyWithoutComment }); - await db.addPublicKey(targetUsername, keyWithoutComment); - res.status(201).json({ message: 'SSH key added successfully' }); - } catch (error) { + console.log('Adding SSH key', { targetUsername, fingerprint }); + try { + await db.addPublicKey(targetUsername, publicKeyRecord); + res.status(201).json({ + message: 'SSH key added successfully', + fingerprint: fingerprint, + }); + } catch (error: any) { console.error('Error adding SSH key:', error); - if (error instanceof DuplicateSSHKeyError) { - res.status(409).json({ error: error.message }); - return; - } - - if (error instanceof UserNotFoundError) { - res.status(404).json({ error: error.message }); - return; + // 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' }); } - - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: `Failed to add SSH key: ${errorMessage}` }); } }); -// Remove SSH public key -router.delete('/:username/ssh-keys', async (req: Request, res: Response) => { +// Remove SSH public key by fingerprint +router.delete('/:username/ssh-keys/:fingerprint', async (req: Request, res: Response) => { if (!req.user) { - res.status(401).json({ error: 'Login required' }); + res.status(401).json({ error: 'Authentication required' }); return; } const { username, admin } = req.user as { username: string; admin: boolean }; 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 (username !== targetUsername && !admin) { @@ -98,18 +148,19 @@ router.delete('/:username/ssh-keys', async (req: Request, res: Response) => { return; } - const { publicKey } = req.body; - if (!publicKey) { - res.status(400).json({ error: 'Public key is required' }); - return; - } - + console.log('Removing SSH key', { targetUsername, fingerprint }); try { - await db.removePublicKey(targetUsername, publicKey); + await db.removePublicKey(targetUsername, fingerprint); res.status(200).json({ message: 'SSH key removed successfully' }); - } catch (error) { + } catch (error: any) { console.error('Error removing SSH key:', error); - res.status(500).json({ error: 'Failed to remove SSH key' }); + + // Return specific error message + if (error.message === 'User not found') { + res.status(404).json({ error: 'User not found' }); + } else { + res.status(500).json({ error: error.message || '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 + + + + + + + + + + + ); }