diff --git a/.gitignore b/.gitignore index 747f84c76..194b0d2f3 100644 --- a/.gitignore +++ b/.gitignore @@ -246,6 +246,7 @@ dist # testing /coverage +.temp # production /build diff --git a/README.md b/README.md index 1d1da61db..faa970c61 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ customize for your environment, see the [project's documentation](https://git-pr - [Quickstart](https://git-proxy.finos.org/docs/category/quickstart/) - [Installation](https://git-proxy.finos.org/docs/installation) - [Configuration](https://git-proxy.finos.org/docs/category/configuration) +- [SSH Support](docs/SSH.md) - Documentation for SSH feature and configuration ## Contributing diff --git a/config.schema.json b/config.schema.json index 3661d7464..67c2f5af2 100644 --- a/config.schema.json +++ b/config.schema.json @@ -110,6 +110,36 @@ "$ref": "#/definitions/authentication" } }, + "ssh": { + "description": "SSH server configuration for secure Git operations", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable SSH server" + }, + "port": { + "type": "number", + "description": "Port number for the SSH server to listen on" + }, + "hostKey": { + "type": "object", + "description": "SSH host key configuration", + "properties": { + "privateKeyPath": { + "type": "string", + "description": "Path to the private key file" + }, + "publicKeyPath": { + "type": "string", + "description": "Path to the public key file" + } + }, + "required": ["privateKeyPath", "publicKeyPath"] + } + }, + "required": ["enabled", "port", "hostKey"] + }, "tls": { "description": "TLS configuration for secure connections", "type": "object", diff --git a/docs/SSH.md b/docs/SSH.md new file mode 100644 index 000000000..7615ee7ce --- /dev/null +++ b/docs/SSH.md @@ -0,0 +1,165 @@ +# SSH Feature Documentation + +## Overview + +The SSH feature enables secure Git operations over SSH protocol, providing an alternative to HTTPS for repository access. This implementation acts as a proxy between Git clients and the remote Git server (e.g., GitHub), with additional security and control capabilities. + +## Configuration + +The SSH feature can be configured in the main configuration file with the following options: + +```json +{ + "ssh": { + "enabled": true, + "port": 22, + "hostKey": { + "privateKeyPath": "./.ssh/host_key", + "publicKeyPath": "./.ssh/host_key.pub" + } + } +} +``` + +### Configuration Options + +- `enabled`: Boolean flag to enable/disable SSH support +- `port`: Port number for the SSH server to listen on (default is 22) +- `hostKey`: Configuration for the server's SSH host key + - `privateKeyPath`: Path to the private key file + - `publicKeyPath`: Path to the public key file + +## Authentication Methods + +The SSH server supports two authentication methods: + +1. **Public Key Authentication** + + - Users can authenticate using their SSH public keys + - Keys are stored in the database and associated with user accounts + - Supports various key types (RSA, ED25519, etc.) + +2. **Password Authentication** + - Users can authenticate using their username and password + - Passwords are stored securely using bcrypt hashing + - Only available if no public key is provided + +## Connection Handling + +The SSH server implements several features to ensure reliable connections: + +- **Keepalive Mechanism** + + - Regular keepalive packets (every 15 seconds) + - Configurable keepalive interval and maximum attempts + - Helps prevent connection timeouts + +- **Error Recovery** + + - Graceful handling of connection errors + - Automatic recovery from temporary disconnections + - Fallback mechanisms for authentication failures + +- **Connection Timeouts** + - 5-minute timeout for large repository operations + - Configurable ready timeout (30 seconds by default) + +## Git Protocol Support + +The SSH server fully supports Git protocol operations: + +- **Git Protocol Version 2** + + - Enabled by default for all connections + - Improved performance and security + +- **Command Execution** + - Supports all standard Git commands + - Proper handling of Git protocol streams + - Efficient data transfer between client and server + +## Security Features + +1. **Host Key Verification** + + - Server uses a dedicated host key pair for the initial handshake between git proxy and user + - Keys are stored securely in the filesystem + - This key pair is used to establish the secure SSH connection and verify the server's identity to the client + +2. **Authentication Chain** + + - Integrates with the existing authentication chain + - Supports custom authentication plugins + - Enforces access control policies + +3. **Connection Security** + - Secure key exchange + - Encrypted data transmission + - Protection against common SSH attacks + +## Implementation Details + +The SSH server is implemented using the `ssh2` library and includes: + +- Custom SSH server class (`SSHServer`) +- Comprehensive error handling +- Detailed logging for debugging +- Support for large file transfers +- Efficient stream handling + +## Usage + +To use the SSH feature: + +1. Ensure SSH is enabled in the configuration +2. Generate and configure the host key pair +3. Add user SSH keys to the database +4. Connect using standard Git SSH commands: + +```bash +git clone git@your-proxy:username/repo.git +``` + +If other than default (22) port is used, git command will look like this: + +```bash +git clone ssh://git@your-proxy:2222/username/repo.git +``` + +## Troubleshooting + +Common issues and solutions: + +1. **Connection Timeouts** + + - Check keepalive settings + - Verify network connectivity + - Ensure proper firewall configuration + +2. **Authentication Failures** + + - Verify SSH key format + - Check key association in database + - Ensure proper permissions + +3. **Performance Issues** + - Adjust window size and packet size + - Monitor connection timeouts + - Check server resources + +## Development + +The SSH implementation includes comprehensive tests in `test/ssh/sshServer.test.js`. To run the tests: + +```bash +npm test +``` + +## Future Improvements + +Planned enhancements: + +1. Move SSH configuration options (keep alive, timeouts, and other params) to config file +2. Enhance actions for SSH functionality +3. Improved error reporting +4. Additional security features diff --git a/package-lock.json b/package-lock.json index 57efef641..7852b7180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "react-html-parser": "^2.0.2", "react-router-dom": "6.28.2", "simple-git": "^3.25.0", + "ssh2": "^1.16.0", "uuid": "^11.0.0", "yargs": "^17.7.2" }, @@ -71,6 +72,7 @@ "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", + "@types/ssh2": "^1.15.5", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", @@ -3847,6 +3849,33 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.87", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.87.tgz", + "integrity": "sha512-OIAAu6ypnVZHmsHCeJ+7CCSub38QNBS9uceMQeg7K5Ur0Jr+wG9wEOEvvMbhp09pxD5czIUy/jND7s7Tb6Nw7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/superagent": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.13.tgz", @@ -4704,7 +4733,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "tweetnacl": "^0.14.3" @@ -4916,6 +4944,15 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5621,6 +5658,20 @@ "typescript": ">=5" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -10450,6 +10501,13 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/nan": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.9", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz", @@ -12599,6 +12657,23 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -13673,7 +13748,6 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true, "license": "Unlicense" }, "node_modules/type-check": { diff --git a/package.json b/package.json index 40885046d..b6c32669c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test-coverage": "nyc npm run test", "test-coverage-ci": "nyc --reporter=lcovonly --reporter=text npm run test", "prepare": "node ./scripts/prepare.js", - "lint": "eslint \"src/**/*.{js,jsx,ts,tsx,json}\" \"test/**/*.{js,jsx,ts,tsx,json}\"", + "lint": "eslint --quiet --ignore-pattern \"**/*.d.ts\" \"src/**/*.{js,jsx,ts,tsx,json}\" \"test/**/*.{js,jsx,ts,tsx,json}\"", "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx,json}\" \"test/**/*.{js,jsx,ts,tsx,json}\"", "format": "prettier --write src/**/*.{js,jsx,ts,tsx,css,md,json,scss} test/**/*.{js,jsx,ts,tsx,json} packages/git-proxy-cli/test/**/*.{js,jsx,ts,tsx,json} packages/git-proxy-cli/index.js --config ./.prettierrc", "gen-schema-doc": "node ./scripts/doc-schema.js", @@ -78,6 +78,7 @@ "react-html-parser": "^2.0.2", "react-router-dom": "6.28.2", "simple-git": "^3.25.0", + "ssh2": "^1.16.0", "uuid": "^11.0.0", "yargs": "^17.7.2" }, @@ -92,6 +93,7 @@ "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", + "@types/ssh2": "^1.15.5", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", diff --git a/packages/git-proxy-cli/index.js b/packages/git-proxy-cli/index.js index 142a58a33..6afdeb7c3 100755 --- a/packages/git-proxy-cli/index.js +++ b/packages/git-proxy-cli/index.js @@ -330,6 +330,60 @@ async function reloadConfig() { } } +/** + * Add SSH key for a user + * @param {string} username The username to add the key for + * @param {string} keyPath Path to the public key file + */ +async function addSSHKey(username, keyPath) { + console.log('Add SSH key', { username, keyPath }); + if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) { + console.error('Error: SSH key: Authentication required'); + process.exitCode = 1; + return; + } + + try { + const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8')); + const publicKey = fs.readFileSync(keyPath, 'utf8').trim(); + + console.log('Adding SSH key', { username, publicKey }); + await axios.post( + `${baseUrl}/api/v1/user/${username}/ssh-keys`, + { publicKey }, + { + headers: { + Cookie: cookies, + 'Content-Type': 'application/json', + }, + withCredentials: true, + }, + ); + + console.log(`SSH key added successfully for user ${username}`); + } catch (error) { + let errorMessage = `Error: SSH key: '${error.message}'`; + process.exitCode = 2; + + if (error.response) { + switch (error.response.status) { + case 401: + errorMessage = 'Error: SSH key: Authentication required'; + process.exitCode = 3; + break; + case 404: + errorMessage = `Error: SSH key: User '${username}' not found`; + process.exitCode = 4; + break; + } + } else if (error.code === 'ENOENT') { + errorMessage = `Error: SSH key: Could not find key file at ${keyPath}`; + process.exitCode = 5; + } + console.error(errorMessage); + } +} + // Parsing command line arguments yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused-expressions .command({ @@ -465,6 +519,37 @@ yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused description: 'Reload GitProxy configuration without restarting', action: reloadConfig, }) + .command({ + command: 'ssh-key', + describe: 'Manage SSH keys', + builder: { + action: { + describe: 'Action to perform (add/remove)', + demandOption: true, + type: 'string', + choices: ['add', 'remove'], + }, + username: { + describe: 'Username to manage keys for', + demandOption: true, + type: 'string', + }, + keyPath: { + describe: 'Path to the public key file', + demandOption: true, + type: 'string', + }, + }, + handler(argv) { + if (argv.action === 'add') { + addSSHKey(argv.username, argv.keyPath); + } else if (argv.action === 'remove') { + // TODO: Implement remove SSH key + console.error('Error: SSH key: Remove action not implemented yet'); + process.exitCode = 1; + } + }, + }) .demandCommand(1, 'You need at least one command before moving on') .strict() .help().argv; diff --git a/proxy.config.json b/proxy.config.json index 618603a6a..4b0bedcba 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -175,5 +175,13 @@ "loginRequired": true } ] + }, + "ssh": { + "enabled": false, + "port": 2222, + "hostKey": { + "privateKeyPath": "./.ssh/host_key", + "publicKeyPath": "./.ssh/host_key.pub" + } } } diff --git a/src/chain/index.d.ts b/src/chain/index.d.ts new file mode 100644 index 000000000..84f66bbce --- /dev/null +++ b/src/chain/index.d.ts @@ -0,0 +1,11 @@ +export function executeChain(req: { + method: string; + originalUrl: string; + isSSH: boolean; + headers: Record; +}): Promise<{ + error?: boolean; + blocked?: boolean; + errorMessage?: string; + blockedMessage?: string; +}>; diff --git a/src/cli/ssh-key.js b/src/cli/ssh-key.js new file mode 100644 index 000000000..fa2c5f5b8 --- /dev/null +++ b/src/cli/ssh-key.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); + +const API_BASE_URL = process.env.GIT_PROXY_API_URL || 'http://localhost:3000'; +const GIT_PROXY_COOKIE_FILE = path.join( + process.env.HOME || process.env.USERPROFILE, + '.git-proxy-cookies.json', +); + +async function addSSHKey(username, keyPath) { + try { + // Check for authentication + if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) { + console.error('Error: Authentication required. Please run "yarn cli login" first.'); + process.exit(1); + } + + // Read the cookies + const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8')); + + // Read the public key file + const publicKey = fs.readFileSync(keyPath, 'utf8').trim(); + console.log('Read public key:', publicKey); + + // Validate the key format + if (!publicKey.startsWith('ssh-')) { + console.error('Invalid SSH key format. The key should start with "ssh-"'); + process.exit(1); + } + + console.log('Making API request to:', `${API_BASE_URL}/api/v1/user/${username}/ssh-keys`); + // Make the API request + await axios.post( + `${API_BASE_URL}/api/v1/user/${username}/ssh-keys`, + { publicKey }, + { + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + Cookie: cookies, + }, + }, + ); + + console.log('SSH key added successfully!'); + } catch (error) { + console.error('Full error:', error); + if (error.response) { + console.error('Response error:', error.response.data); + console.error('Response status:', error.response.status); + } else if (error.code === 'ENOENT') { + console.error(`Error: Could not find SSH key file at ${keyPath}`); + } else { + console.error('Error:', error.message); + } + process.exit(1); + } +} + +async function removeSSHKey(username, keyPath) { + try { + // Check for authentication + if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) { + console.error('Error: Authentication required. Please run "yarn cli login" first.'); + process.exit(1); + } + + // Read the cookies + const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8')); + + // 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, + }, + }); + + console.log('SSH key removed successfully!'); + } catch (error) { + if (error.response) { + console.error('Error:', error.response.data.error); + } else if (error.code === 'ENOENT') { + console.error(`Error: Could not find SSH key file at ${keyPath}`); + } else { + console.error('Error:', error.message); + } + process.exit(1); + } +} + +// Parse command line arguments +const args = process.argv.slice(2); +const command = args[0]; +const username = args[1]; +const keyPath = args[2]; + +if (!command || !username || !keyPath) { + console.log(` +Usage: + Add SSH key: node ssh-key.js add + Remove SSH key: node ssh-key.js remove + `); + process.exit(1); +} + +if (command === 'add') { + addSSHKey(username, keyPath); +} else if (command === 'remove') { + removeSSHKey(username, keyPath); +} else { + console.error('Invalid command. Use "add" or "remove"'); + process.exit(1); +} diff --git a/src/config/index.d.ts b/src/config/index.d.ts new file mode 100644 index 000000000..0cd6bbfeb --- /dev/null +++ b/src/config/index.d.ts @@ -0,0 +1,4 @@ +import { SSHConfig } from './types'; + +export function getSSHConfig(): SSHConfig; +export function getProxyUrl(): string; diff --git a/src/config/index.ts b/src/config/index.ts index 63174a296..3acee8eaf 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -33,6 +33,7 @@ let _urlShortener: string = defaultSettings.urlShortener; let _contactEmail: string = defaultSettings.contactEmail; let _csrfProtection: boolean = defaultSettings.csrfProtection; let _domains: Record = defaultSettings.domains; +let _sshConfig = defaultSettings.ssh; let _rateLimit: RateLimitConfig = defaultSettings.rateLimit; // These are not always present in the default config file, so casting is required @@ -56,6 +57,17 @@ export const getProxyUrl = () => { return _proxyUrl; }; +export const getSSHProxyUrl = () => { + return getProxyUrl().replace('https://', 'git@'); +}; + +export const getSSHConfig = () => { + if (_userSettings !== null && _userSettings.ssh) { + _sshConfig = _userSettings.ssh; + } + return _sshConfig; +}; + // Gets a list of authorised repositories export const getAuthorisedList = () => { if (_userSettings !== null && _userSettings.authorisedList) { diff --git a/src/config/types.ts b/src/config/types.ts index 291de4081..97409e741 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -22,9 +22,18 @@ export interface UserSettings { contactEmail: string; csrfProtection: boolean; domains: Record; + ssh: SSHConfig; rateLimit: RateLimitConfig; } +export interface SSHConfig { + enabled: boolean; + port: number; + hostKey: { + privateKeyPath: string; + publicKeyPath: string; + }; +} export interface TLSConfig { enabled?: boolean; cert?: string; diff --git a/src/db/file/index.ts b/src/db/file/index.ts index 6ac1c2088..9e6a7bee2 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -27,4 +27,14 @@ export const { canUserApproveRejectPushRepo, } = repo; -export const { findUser, findUserByOIDC, getUsers, createUser, deleteUser, updateUser } = users; +export const { + findUser, + findUserByOIDC, + getUsers, + createUser, + deleteUser, + updateUser, + addPublicKey, + removePublicKey, + findUserBySSHKey, +} = users; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index 263c612f4..825926e3e 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -54,6 +54,10 @@ export const findUserByOIDC = function (oidcId: string) { }; export const createUser = function (user: User) { + if (!user.publicKeys) { + user.publicKeys = []; + } + user.username = user.username.toLowerCase(); user.email = user.email.toLowerCase(); return new Promise((resolve, reject) => { @@ -84,6 +88,10 @@ export const deleteUser = (username: string) => { }; export const updateUser = (user: User) => { + if (!user.publicKeys) { + user.publicKeys = []; + } + user.username = user.username.toLowerCase(); if (user.email) { user.email = user.email.toLowerCase(); @@ -140,3 +148,61 @@ export const getUsers = (query: any = {}) => { }); }); }; + +export const addPublicKey = function (username: string, publicKey: string) { + return new Promise((resolve, reject) => { + findUser(username) + .then((user) => { + if (!user) { + reject(new Error('User not found')); + return; + } + if (!user.publicKeys) { + user.publicKeys = []; + } + if (!user.publicKeys.includes(publicKey)) { + user.publicKeys.push(publicKey); + exports.updateUser(user).then(resolve).catch(reject); + } else { + resolve(user); + } + }) + .catch(reject); + }); +}; + +export const removePublicKey = function (username: string, publicKey: string) { + return new Promise((resolve, reject) => { + findUser(username) + .then((user) => { + if (!user) { + reject(new Error('User not found')); + return; + } + if (!user.publicKeys) { + user.publicKeys = []; + resolve(user); + return; + } + user.publicKeys = user.publicKeys.filter((key) => key !== publicKey); + exports.updateUser(user).then(resolve).catch(reject); + }) + .catch(reject); + }); +}; + +export const findUserBySSHKey = function (sshKey: string) { + return new Promise((resolve, reject) => { + db.findOne({ publicKeys: sshKey }, (err, doc) => { + if (err) { + reject(err); + } else { + if (!doc) { + resolve(null); + } else { + resolve(doc as User); + } + } + }); + }); +}; diff --git a/src/db/index.d.ts b/src/db/index.d.ts new file mode 100644 index 000000000..9a6a452c4 --- /dev/null +++ b/src/db/index.d.ts @@ -0,0 +1,4 @@ +import { User } from './types'; + +export function findUser(username: string): Promise; +export function findUserBySSHKey(sshKey: string): Promise; diff --git a/src/db/index.ts b/src/db/index.ts index ff1189f1b..fea3322c4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -82,4 +82,7 @@ export const { canUserApproveRejectPush, canUserCancelPush, getSessionStore, + addPublicKey, + removePublicKey, + findUserBySSHKey, } = sink; diff --git a/src/db/mongo/index.ts b/src/db/mongo/index.ts index a6d7ce6b2..df65c0152 100644 --- a/src/db/mongo/index.ts +++ b/src/db/mongo/index.ts @@ -30,4 +30,13 @@ export const { canUserApproveRejectPushRepo, } = repo; -export const { findUser, getUsers, createUser, deleteUser, updateUser } = users; +export const { + findUser, + getUsers, + createUser, + deleteUser, + updateUser, + addPublicKey, + removePublicKey, + findUserBySSHKey, +} = users; diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index 5bacb245d..262de06c9 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -26,6 +26,9 @@ export const deleteUser = async function (username: string) { }; export const createUser = async function (user: User) { + if (!user.publicKeys) { + user.publicKeys = []; + } user.username = user.username.toLowerCase(); user.email = user.email.toLowerCase(); const collection = await connect(collectionName); @@ -33,6 +36,9 @@ export const createUser = async function (user: User) { }; export const updateUser = async (user: User) => { + if (!user.publicKeys) { + user.publicKeys = []; + } user.username = user.username.toLowerCase(); if (user.email) { user.email = user.email.toLowerCase(); @@ -41,3 +47,24 @@ export const updateUser = async (user: User) => { const collection = await connect(collectionName); await collection.updateOne({ username: user.username }, { $set: user }, options); }; + +export const addPublicKey = async (username: string, publicKey: string) => { + const collection = await connect(collectionName); + return collection.updateOne( + { username: username.toLowerCase() }, + { $addToSet: { publicKeys: publicKey } }, + ); +}; + +export const removePublicKey = async (username: string, publicKey: string) => { + const collection = await connect(collectionName); + return collection.updateOne( + { username: username.toLowerCase() }, + { $pull: { publicKeys: publicKey } }, + ); +}; + +export const findUserBySSHKey = async function (sshKey: string) { + const collection = await connect(collectionName); + return collection.findOne({ publicKeys: { $eq: sshKey } }); +}; diff --git a/src/db/types.ts b/src/db/types.ts index 04951a699..31b5af949 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -23,6 +23,7 @@ export type User = { email: string; admin: boolean; oidcId: string | null; + publicKeys: string[]; }; export type Push = { diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index b15b7c24c..51c137854 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -48,6 +48,7 @@ class Action { attestation?: string; lastStep?: Step; proxyGitPath?: string; + protocol: 'https' | 'ssh' = 'https'; /** * Create an action. diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 4cfcda986..89b09758a 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -10,11 +10,13 @@ import { getTLSKeyPemPath, getTLSCertPemPath, getTLSEnabled, + getSSHConfig, } from '../config'; import { addUserCanAuthorise, addUserCanPush, createRepo, getRepos } from '../db'; import { PluginLoader } from '../plugin'; import chain from './chain'; import { Repo } from '../db/types'; +import SSHServer from './ssh/server'; const { GIT_PROXY_SERVER_PORT: proxyHttpPort, GIT_PROXY_HTTPS_SERVER_PORT: proxyHttpsPort } = require('../config/env').serverConfig; @@ -52,6 +54,12 @@ export const proxyPreparations = async () => { await addUserCanAuthorise(x.name, 'admin'); } }); + + // Initialize SSH server if enabled + if (getSSHConfig().enabled) { + const sshServer = new SSHServer(); + sshServer.start(); + } }; // just keep this async incase it needs async stuff in the future diff --git a/src/proxy/processors/pre-processor/parseAction.ts b/src/proxy/processors/pre-processor/parseAction.ts index a9c332fdc..6fed33675 100644 --- a/src/proxy/processors/pre-processor/parseAction.ts +++ b/src/proxy/processors/pre-processor/parseAction.ts @@ -4,6 +4,7 @@ const exec = async (req: { originalUrl: string; method: string; headers: Record; + isSSH: boolean; }) => { const id = Date.now(); const timestamp = id; diff --git a/src/proxy/processors/push-action/pullRemote.ts b/src/proxy/processors/push-action/pullRemote.ts index 2f7c808a2..9a06df827 100644 --- a/src/proxy/processors/push-action/pullRemote.ts +++ b/src/proxy/processors/push-action/pullRemote.ts @@ -2,6 +2,7 @@ import { Action, Step } from '../../actions'; import fs from 'fs'; import git from 'isomorphic-git'; import gitHttpClient from 'isomorphic-git/http/node'; +import execSync from 'child_process'; const dir = './.remote'; @@ -21,26 +22,42 @@ const exec = async (req: any, action: Action): Promise => { fs.mkdirSync(action.proxyGitPath, 0o755); } - const cmd = `git clone ${action.url}`; - step.log(`Exectuting ${cmd}`); - - const authHeader = req.headers?.authorization; - const [username, password] = Buffer.from(authHeader.split(' ')[1], 'base64') - .toString() - .split(':'); - - await git.clone({ - fs, - http: gitHttpClient, - url: action.url, - onAuth: () => ({ - username, - password, - }), - dir: `${action.proxyGitPath}/${action.repoName}`, - }); - - console.log('Clone Success: ', action.url); + let cloneUrl = action.url; + let cmd = 'git clone'; + + if (action.protocol === 'ssh') { + // Convert HTTPS URL to SSH URL + cloneUrl = action.url.replace('https://', 'git@'); + cmd += ` ${cloneUrl}`; + step.log(`Executing ${cmd}`); + + // Use native git command with SSH + execSync(cmd, { + cwd: action.proxyGitPath, + stdio: 'pipe', + }); + } else { + cmd += ` ${action.url}`; + step.log(`Exectuting ${cmd}`); + + const authHeader = req.headers?.authorization; + const [username, password] = Buffer.from(authHeader.split(' ')[1], 'base64') + .toString() + .split(':'); + + await git.clone({ + fs, + http: gitHttpClient, + url: action.url, + onAuth: () => ({ + username, + password, + }), + dir: `${action.proxyGitPath}/${action.repoName}`, + }); + } + + console.log('Clone Success: ', cloneUrl); step.log(`Completed ${cmd}`); step.setContent(`Completed ${cmd}`); diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index 973608169..fe82cce50 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -47,6 +47,20 @@ const validGitRequest = (url: string, headers: any): boolean => { return false; }; +// Add function to convert SSH URL to HTTPS +const convertSshToHttps = (url: string) => { + // Handle SSH URLs in the format git@host:path + const sshRegex = /^git@([^:]+):(.+)$/; + const match = url.match(sshRegex); + + if (match) { + const [, host, path] = match; + return `https://${host}/${path}`; + } + + return url; +}; + router.use( '/', proxy(getProxyUrl(), { @@ -105,6 +119,11 @@ router.use( console.log('Sending request to ' + url); return url; }, + proxySSHReqPathResolver: (req) => { + const url = convertSshToHttps(getProxyUrl()) + req.originalUrl; + console.log('Sending request to ' + url); + return url; + }, proxyReqOptDecorator: function (proxyReqOpts) { return proxyReqOpts; }, diff --git a/src/proxy/ssh/server.js b/src/proxy/ssh/server.js new file mode 100644 index 000000000..ac9498453 --- /dev/null +++ b/src/proxy/ssh/server.js @@ -0,0 +1,683 @@ +const ssh2 = require('ssh2'); +const config = require('../../config'); +const chain = require('../chain'); +const db = require('../../db'); + +class SSHServer { + constructor() { + // TODO: Server config could go to config file + this.server = new ssh2.Server( + { + hostKeys: [require('fs').readFileSync(config.getSSHConfig().hostKey.privateKeyPath)], + authMethods: ['publickey', 'password'], + keepaliveInterval: 20000, // 20 seconds is recommended for SSH connections + keepaliveCountMax: 5, // Allow more keepalive attempts + readyTimeout: 30000, // Longer ready timeout + debug: (msg) => { + console.debug('[SSH Debug]', msg); + }, + }, + this.handleClient.bind(this), + ); + } + + async handleClient(client) { + console.log('[SSH] Client connected'); + + // Set up client error handling + client.on('error', (err) => { + console.error('[SSH] Client error:', err); + // Don't end the connection on error, let it try to recover + }); + + // Handle client end + client.on('end', () => { + console.log('[SSH] Client disconnected'); + }); + + // Handle client close + client.on('close', () => { + console.log('[SSH] Client connection closed'); + }); + + // Handle keepalive requests + client.on('global request', (accept, reject, info) => { + 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(); + } + }); + + // Set up keepalive timer + let keepaliveTimer = null; + const startKeepalive = () => { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + } + keepaliveTimer = setInterval(() => { + if (client.connected) { + console.log('[SSH] Sending keepalive'); + try { + client.ping(); + } catch (error) { + console.error('[SSH] Error sending keepalive:', error); + // Don't clear the timer on error, let it try again + } + } else { + console.log('[SSH] Client disconnected, clearing keepalive'); + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + }, 15000); // 15 seconds between keepalives (recommended for SSH connections is 15-30 seconds) + }; + + // Start keepalive when client is ready + client.on('ready', () => { + console.log('[SSH] Client ready, starting keepalive'); + startKeepalive(); + }); + + // Clean up keepalive on client end + client.on('end', () => { + console.log('[SSH] Client disconnected'); + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + }); + + client.on('authentication', async (ctx) => { + console.log(`[SSH] Authentication attempt: ${ctx.method}`); + + if (ctx.method === 'publickey') { + try { + console.log(`[SSH] CTX KEY: ${JSON.stringify(ctx.key)}`); + // Get the key type and key data + const keyType = ctx.key.algo; + const keyData = ctx.key.data; + + // Format the key in the same way as stored in user's publicKeys (without comment) + const keyString = `${keyType} ${keyData.toString('base64')}`; + + console.log(`[SSH] Attempting public key authentication with key: ${keyString}`); + + // Find user by SSH key + const user = await db.findUserBySSHKey(keyString); + if (!user) { + console.log('[SSH] No user found with this SSH key'); + ctx.reject(); + return; + } + + console.log(`[SSH] Public key authentication successful for user ${user.username}`); + client.username = user.username; + // Store the user's private key for later use with GitHub + client.userPrivateKey = { + algo: ctx.key.algo, + data: ctx.key.data, + comment: ctx.key.comment || '', + }; + console.log( + `[SSH] Stored key info - Algorithm: ${ctx.key.algo}, Data length: ${ctx.key.data.length}, Data type: ${typeof ctx.key.data}`, + ); + if (Buffer.isBuffer(ctx.key.data)) { + console.log('[SSH] Key data is a Buffer'); + } + ctx.accept(); + } catch (error) { + console.error('[SSH] Error during public key authentication:', error); + // Let the client try the next key + ctx.reject(); + } + } else if (ctx.method === 'password') { + // Only try password authentication if no public key was provided + if (!ctx.key) { + try { + const user = await db.findUser(ctx.username); + if (user && user.password) { + const bcrypt = require('bcryptjs'); + const isValid = await bcrypt.compare(ctx.password, user.password); + if (isValid) { + console.log(`[SSH] Password authentication successful for user ${ctx.username}`); + ctx.accept(); + } else { + console.log(`[SSH] Password authentication failed for user ${ctx.username}`); + ctx.reject(); + } + } else { + console.log(`[SSH] User ${ctx.username} not found or no password set`); + ctx.reject(); + } + } catch (error) { + console.error('[SSH] Error during password authentication:', error); + ctx.reject(); + } + } else { + console.log('[SSH] Password authentication attempted but public key was provided'); + ctx.reject(); + } + } else { + console.log(`Unsupported authentication method: ${ctx.method}`); + ctx.reject(); + } + }); + + client.on('ready', () => { + console.log(`[SSH] Client ready: ${client.username}`); + client.on('session', this.handleSession.bind(this)); + }); + } + + async handleSession(accept, reject) { + const session = accept(); + session.on('exec', async (accept, reject, info) => { + const stream = accept(); + const command = info.command; + + // Parse Git command + console.log('[SSH] Command', command); + if (command.startsWith('git-')) { + // Extract the repository path from the command + // Remove quotes and 'git-' prefix, then trim any leading/trailing slashes + const repoPath = command + .replace('git-upload-pack', '') + .replace('git-receive-pack', '') + .replace(/^['"]|['"]$/g, '') + .replace(/^\/+|\/+$/g, ''); + + const req = { + method: command.startsWith('git-upload-pack') ? 'GET' : 'POST', + originalUrl: repoPath, + isSSH: true, + headers: { + 'user-agent': 'git/2.0.0', + 'content-type': command.startsWith('git-receive-pack') + ? 'application/x-git-receive-pack-request' + : undefined, + }, + }; + + try { + console.log('[SSH] Executing chain', req); + const action = await chain.executeChain(req); + + console.log('[SSH] Action', action); + + if (action.error || action.blocked) { + // If there's an error or the action is blocked, send the error message + console.log( + '[SSH] Action error or blocked', + action.errorMessage || action.blockedMessage, + ); + stream.write(action.errorMessage || action.blockedMessage); + stream.end(); + return; + } + + // Create SSH connection to GitHub using the Client approach + const { Client } = require('ssh2'); + const remoteGitSsh = new Client(); + + console.log('[SSH] Creating SSH connection to remote'); + + // Get remote host from config + const remoteUrl = new URL(config.getProxyUrl()); + + // TODO: Connection options could go to config + // Set up connection options + const connectionOptions = { + host: remoteUrl.hostname, + port: 22, + username: 'git', + readyTimeout: 30000, + tryKeyboard: false, + debug: (msg) => { + console.debug('[GitHub SSH Debug]', msg); + }, + 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: 1024 * 1024, // 1MB window size + packetSize: 32768, // 32KB packet size + }; + + // Get the client's SSH key that was used for authentication + const clientKey = session._channel._client.userPrivateKey; + console.log('[SSH] Client key:', clientKey ? 'Available' : 'Not available'); + + // Add the private key based on what's available + if (clientKey) { + console.log('[SSH] Using client key to connect to remote' + JSON.stringify(clientKey)); + // Check if the key is in the correct format + if (typeof clientKey === 'object' && clientKey.algo && clientKey.data) { + // 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'); + connectionOptions.privateKey = require('fs').readFileSync( + config.getSSHConfig().hostKey.privateKeyPath, + ); + } 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 { + // Try to convert the key to a buffer if it's a string + try { + connectionOptions.privateKey = Buffer.from(clientKey); + console.log('[SSH] Converted client key to buffer'); + } catch (error) { + console.error('[SSH] Failed to convert client key to buffer:', error); + // Fall back to the proxy key + connectionOptions.privateKey = require('fs').readFileSync( + config.getSSHConfig().hostKey.privateKeyPath, + ); + console.log('[SSH] Falling back to proxy key'); + } + } + } else { + console.log('[SSH] No client key available, using proxy key'); + connectionOptions.privateKey = require('fs').readFileSync( + config.getSSHConfig().hostKey.privateKeyPath, + ); + } + + // Log the key type for debugging + if (connectionOptions.privateKey) { + if ( + typeof connectionOptions.privateKey === 'object' && + connectionOptions.privateKey.algo + ) { + console.log(`[SSH] Key algo: ${connectionOptions.privateKey.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}`); + } + } + + // Set up event handlers + remoteGitSsh.on('ready', () => { + console.log('[SSH] Connected to remote'); + + // Execute the Git command on remote + remoteGitSsh.exec( + command, + { + env: { + GIT_PROTOCOL: 'version=2', + GIT_TERMINAL_PROMPT: '0', + }, + }, + (err, remoteStream) => { + if (err) { + console.error('[SSH] Failed to execute command on remote:', err); + stream.write(err.toString()); + stream.end(); + return; + } + + // Handle stream errors + remoteStream.on('error', (err) => { + console.error('[SSH] Remote stream error:', 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, attempting to recover', + ); + // Try to keep the connection alive + if (remoteGitSsh.connected) { + console.log('[SSH] Connection still active, continuing'); + // Don't end the stream, let it try to recover + return; + } + } + // If we can't recover, then end the stream + stream.write(err.toString()); + stream.end(); + }); + + // Pipe data between client and remote + stream.on('data', (data) => { + console.debug('[SSH] Client -> Remote:', data.toString().slice(0, 100)); + try { + remoteStream.write(data); + } catch (error) { + console.error('[SSH] Error writing to remote stream:', error); + // Don't end the stream on error, let it try to recover + } + }); + + remoteStream.on('data', (data) => { + console.debug('[SSH] Remote -> Client:', data.toString().slice(0, 100)); + try { + stream.write(data); + } catch (error) { + console.error('[SSH] Error writing to client stream:', error); + // Don't end the stream on error, let it try to recover + } + }); + + remoteStream.on('end', () => { + console.log('[SSH] Remote stream ended'); + stream.exit(0); + stream.end(); + }); + + // Handle stream close + remoteStream.on('close', () => { + console.log('[SSH] Remote stream closed'); + // Don't end the client stream immediately, let Git protocol complete + // Check if we're in the middle of a large transfer + if (stream.readable && !stream.destroyed) { + console.log('[SSH] Stream still readable, not ending client stream'); + // Let the client end the stream when it's done + } else { + console.log('[SSH] Stream not readable or destroyed, ending client stream'); + stream.end(); + } + }); + + remoteStream.on('exit', (code) => { + console.log(`[SSH] Remote command exited with code ${code}`); + if (code !== 0) { + console.error(`[SSH] Remote command failed with code ${code}`); + } + // Don't end the connection here, let the client end it + }); + + // Handle client stream end + stream.on('end', () => { + console.log('[SSH] Client stream ended'); + // End the SSH connection after a short delay to allow cleanup + setTimeout(() => { + console.log('[SSH] Ending SSH connection after client stream end'); + remoteGitSsh.end(); + }, 1000); // Increased delay to ensure all data is processed + }); + + // Handle client stream error + stream.on('error', (err) => { + console.error('[SSH] Client stream error:', err); + // Don't immediately end the connection 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 on client side, attempting to recover', + ); + // Try to keep the connection alive + if (remoteGitSsh.connected) { + console.log('[SSH] Connection still active, continuing'); + // Don't end the connection, let it try to recover + return; + } + } + // If we can't recover, then end the connection + remoteGitSsh.end(); + }); + + // Handle connection end + remoteGitSsh.on('end', () => { + console.log('[SSH] Remote connection ended'); + }); + + // Handle connection close + remoteGitSsh.on('close', () => { + console.log('[SSH] Remote connection closed'); + }); + + // Add a timeout to ensure the connection is closed if it hangs + const connectionTimeout = setTimeout(() => { + console.log('[SSH] Connection timeout, ending connection'); + remoteGitSsh.end(); + }, 300000); // 5 minutes timeout for large repositories + + // Clear the timeout when the connection is closed + remoteGitSsh.on('close', () => { + clearTimeout(connectionTimeout); + }); + }, + ); + }); + + remoteGitSsh.on('error', (err) => { + console.error('[SSH] Remote SSH error:', err); + + // If authentication failed and we're using the client key, try with the proxy key + if ( + err.message.includes('All configured authentication methods failed') && + clientKey && + connectionOptions.privateKey !== + require('fs').readFileSync(config.getSSHConfig().hostKey.privateKeyPath) + ) { + console.log('[SSH] Authentication failed with client key, trying with proxy key'); + + // Create a new connection with the proxy key + const proxyGitSsh = new Client(); + + // Set up connection options with proxy key + const proxyConnectionOptions = { + ...connectionOptions, + privateKey: require('fs').readFileSync( + config.getSSHConfig().hostKey.privateKeyPath, + ), + // Ensure these settings are explicitly set for the proxy connection + windowSize: 1024 * 1024, // 1MB window size + packetSize: 32768, // 32KB packet size + keepaliveInterval: 5000, + keepaliveCountMax: 10, + }; + + // Set up event handlers for the proxy connection + proxyGitSsh.on('ready', () => { + console.log('[SSH] Connected to remote with proxy key'); + + // Execute the Git command on remote + proxyGitSsh.exec( + command, + { env: { GIT_PROTOCOL: 'version=2' } }, + (err, remoteStream) => { + if (err) { + console.error( + '[SSH] Failed to execute command on remote with proxy key:', + err, + ); + stream.write(err.toString()); + stream.end(); + return; + } + + // Handle stream errors + remoteStream.on('error', (err) => { + console.error('[SSH] Remote stream error with proxy key:', 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 with proxy key, attempting to recover', + ); + // Try to keep the connection alive + if (proxyGitSsh.connected) { + console.log('[SSH] Connection still active with proxy key, continuing'); + // Don't end the stream, let it try to recover + return; + } + } + // If we can't recover, then end the stream + stream.write(err.toString()); + stream.end(); + }); + + // Pipe data between client and remote + stream.on('data', (data) => { + console.debug('[SSH] Client -> Remote:', data.toString().slice(0, 100)); + try { + remoteStream.write(data); + } catch (error) { + console.error( + '[SSH] Error writing to remote stream with proxy key:', + error, + ); + // Don't end the stream on error, let it try to recover + } + }); + + remoteStream.on('data', (data) => { + console.debug('[SSH] Remote -> Client:', data.toString().slice(0, 20)); + try { + stream.write(data); + } catch (error) { + console.error( + '[SSH] Error writing to client stream with proxy key:', + error, + ); + // Don't end the stream on error, let it try to recover + } + }); + + // Handle stream close + remoteStream.on('close', () => { + console.log('[SSH] Remote stream closed with proxy key'); + // Don't end the client stream immediately, let Git protocol complete + // Check if we're in the middle of a large transfer + if (stream.readable && !stream.destroyed) { + console.log( + '[SSH] Stream still readable with proxy key, not ending client stream', + ); + // Let the client end the stream when it's done + } else { + console.log( + '[SSH] Stream not readable or destroyed with proxy key, ending client stream', + ); + stream.end(); + } + }); + + remoteStream.on('exit', (code) => { + console.log(`[SSH] Remote command exited with code ${code} using proxy key`); + // Don't end the connection here, let the client end it + }); + + // Handle client stream end + stream.on('end', () => { + console.log('[SSH] Client stream ended with proxy key'); + // End the SSH connection after a short delay to allow cleanup + setTimeout(() => { + console.log( + '[SSH] Ending SSH connection after client stream end with proxy key', + ); + proxyGitSsh.end(); + }, 1000); // Increased delay to ensure all data is processed + }); + + // Handle client stream error + stream.on('error', (err) => { + console.error('[SSH] Client stream error with proxy key:', err); + // Don't immediately end the connection 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 on client side with proxy key, attempting to recover', + ); + // Try to keep the connection alive + if (proxyGitSsh.connected) { + console.log('[SSH] Connection still active with proxy key, continuing'); + // Don't end the connection, let it try to recover + return; + } + } + // If we can't recover, then end the connection + proxyGitSsh.end(); + }); + + // Handle remote stream error + remoteStream.on('error', (err) => { + console.error('[SSH] Remote stream error with proxy key:', err); + // Don't end the client stream immediately, let Git protocol complete + }); + + // Handle connection end + proxyGitSsh.on('end', () => { + console.log('[SSH] Remote connection ended with proxy key'); + }); + + // Handle connection close + proxyGitSsh.on('close', () => { + console.log('[SSH] Remote connection closed with proxy key'); + }); + + // Add a timeout to ensure the connection is closed if it hangs + const proxyConnectionTimeout = setTimeout(() => { + console.log('[SSH] Connection timeout with proxy key, ending connection'); + proxyGitSsh.end(); + }, 300000); // 5 minutes timeout for large repositories + + // Clear the timeout when the connection is closed + proxyGitSsh.on('close', () => { + clearTimeout(proxyConnectionTimeout); + }); + }, + ); + }); + + proxyGitSsh.on('error', (err) => { + console.error('[SSH] Remote SSH error with proxy key:', err); + stream.write(err.toString()); + stream.end(); + }); + + // Connect to remote with proxy key + proxyGitSsh.connect(proxyConnectionOptions); + } else { + // If we're already using the proxy key or it's a different error, just end the stream + stream.write(err.toString()); + stream.end(); + } + }); + + // Connect to remote + console.log('[SSH] Attempting connection with options:', { + host: connectionOptions.host, + port: connectionOptions.port, + username: connectionOptions.username, + algorithms: connectionOptions.algorithms, + privateKeyType: typeof connectionOptions.privateKey, + privateKeyIsBuffer: Buffer.isBuffer(connectionOptions.privateKey), + }); + remoteGitSsh.connect(connectionOptions); + } catch (error) { + console.error('[SSH] Error during SSH connection:', error); + stream.write(error.toString()); + stream.end(); + } + } else { + console.log('[SSH] Unsupported command', command); + stream.write('Unsupported command'); + stream.end(); + } + }); + } + + start() { + const port = config.getSSHConfig().port; + this.server.listen(port, '0.0.0.0', () => { + console.log(`[SSH] Server listening on port ${port}`); + }); + } +} + +module.exports = SSHServer; diff --git a/src/service/routes/users.js b/src/service/routes/users.js index 118243d70..0b64a0174 100644 --- a/src/service/routes/users.js +++ b/src/service/routes/users.js @@ -17,7 +17,14 @@ router.get('/', async (req, res) => { query[k] = v; } - res.send(await db.getUsers(query)); + const users = await db.getUsers(query); + for (const user of users) { + delete user.password; + if (user.publicKeys) { + user.publicKeys = user.publicKeys.map((key) => key.trim()); + } + } + res.send(users); }); router.get('/:id', async (req, res) => { @@ -29,4 +36,68 @@ router.get('/:id', async (req, res) => { res.send(user); }); +// 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 } = req.body; + if (!publicKey) { + res.status(400).json({ error: 'Public key is required' }); + return; + } + + // Strip the comment from the key (everything after the last space) + const keyWithoutComment = publicKey.split(' ').slice(0, 2).join(' '); + + console.log('Adding SSH key', { targetUsername, keyWithoutComment }); + try { + await db.addPublicKey(targetUsername, keyWithoutComment); + res.status(201).json({ message: 'SSH key added successfully' }); + } catch (error) { + console.error('Error adding SSH key:', error); + res.status(500).json({ error: 'Failed to add SSH key' }); + } +}); + +// Remove SSH public key +router.delete('/: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 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; + } + + const { publicKey } = req.body; + if (!publicKey) { + res.status(400).json({ error: 'Public key is required' }); + return; + } + + try { + await db.removePublicKey(targetUsername, publicKey); + res.status(200).json({ message: 'SSH key removed successfully' }); + } catch (error) { + console.error('Error removing SSH key:', error); + res.status(500).json({ error: 'Failed to remove SSH key' }); + } +}); + module.exports = router; diff --git a/test/ssh/sshServer.test.js b/test/ssh/sshServer.test.js new file mode 100644 index 000000000..84245d5ec --- /dev/null +++ b/test/ssh/sshServer.test.js @@ -0,0 +1,341 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const expect = chai.expect; +const fs = require('fs'); +const ssh2 = require('ssh2'); +const config = require('../../src/config'); +const db = require('../../src/db'); +const chain = require('../../src/proxy/chain'); +const SSHServer = require('../../src/proxy/ssh/server'); +const { execSync } = require('child_process'); + +describe('SSHServer', () => { + let server; + let mockConfig; + let mockDb; + let mockChain; + let mockSsh2Server; + let mockFs; + const testKeysDir = 'test/keys'; + let testKeyContent; + + before(() => { + // Create directory for test keys + if (!fs.existsSync(testKeysDir)) { + fs.mkdirSync(testKeysDir, { recursive: true }); + } + // Generate test SSH key pair + execSync(`ssh-keygen -t rsa -b 4096 -f ${testKeysDir}/test_key -N "" -C "test@git-proxy"`); + // Read the key once and store it + testKeyContent = fs.readFileSync(`${testKeysDir}/test_key`); + }); + + after(() => { + // Clean up test keys + if (fs.existsSync(testKeysDir)) { + fs.rmSync(testKeysDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + // Create stubs for all dependencies + mockConfig = { + getSSHConfig: sinon.stub().returns({ + hostKey: { + privateKeyPath: `${testKeysDir}/test_key`, + publicKeyPath: `${testKeysDir}/test_key.pub`, + }, + port: 22, + }), + getProxyUrl: sinon.stub().returns('https://github.com'), + }; + + mockDb = { + findUserBySSHKey: sinon.stub(), + findUser: sinon.stub(), + }; + + mockChain = { + executeChain: sinon.stub(), + }; + + mockFs = { + readFileSync: sinon.stub().callsFake((path) => { + if (path === `${testKeysDir}/test_key`) { + return testKeyContent; + } + return 'mock-key-data'; + }), + }; + + // Create a more complete mock for the SSH2 server + mockSsh2Server = { + Server: sinon.stub().returns({ + listen: sinon.stub(), + on: sinon.stub(), + }), + }; + + // Replace the real modules with our stubs + sinon.stub(config, 'getSSHConfig').callsFake(mockConfig.getSSHConfig); + sinon.stub(config, 'getProxyUrl').callsFake(mockConfig.getProxyUrl); + sinon.stub(db, 'findUserBySSHKey').callsFake(mockDb.findUserBySSHKey); + sinon.stub(db, 'findUser').callsFake(mockDb.findUser); + sinon.stub(chain, 'executeChain').callsFake(mockChain.executeChain); + sinon.stub(fs, 'readFileSync').callsFake(mockFs.readFileSync); + sinon.stub(ssh2, 'Server').callsFake(mockSsh2Server.Server); + + server = new SSHServer(); + }); + + afterEach(() => { + // Restore all stubs + sinon.restore(); + }); + + describe('constructor', () => { + it('should create a new SSH2 server with correct configuration', () => { + expect(ssh2.Server.calledOnce).to.be.true; + const serverConfig = ssh2.Server.firstCall.args[0]; + expect(serverConfig.hostKeys).to.be.an('array'); + expect(serverConfig.authMethods).to.deep.equal(['publickey', 'password']); + expect(serverConfig.keepaliveInterval).to.equal(20000); + expect(serverConfig.keepaliveCountMax).to.equal(5); + expect(serverConfig.readyTimeout).to.equal(30000); + }); + }); + + describe('start', () => { + it('should start listening on the configured port', () => { + server.start(); + expect(server.server.listen.calledWith(22, '0.0.0.0')).to.be.true; + }); + }); + + describe('handleClient', () => { + let mockClient; + + beforeEach(() => { + mockClient = { + on: sinon.stub(), + username: null, + userPrivateKey: null, + }; + }); + + it('should set up client event handlers', () => { + server.handleClient(mockClient); + expect(mockClient.on.calledWith('error')).to.be.true; + expect(mockClient.on.calledWith('end')).to.be.true; + expect(mockClient.on.calledWith('close')).to.be.true; + expect(mockClient.on.calledWith('global request')).to.be.true; + expect(mockClient.on.calledWith('ready')).to.be.true; + expect(mockClient.on.calledWith('authentication')).to.be.true; + }); + + describe('authentication', () => { + it('should handle public key authentication successfully', async () => { + const mockCtx = { + method: 'publickey', + key: { + algo: 'ssh-rsa', + data: Buffer.from('mock-key-data'), + comment: 'test-key', + }, + accept: sinon.stub(), + reject: sinon.stub(), + }; + + mockDb.findUserBySSHKey.resolves({ username: 'test-user' }); + + server.handleClient(mockClient); + const authHandler = mockClient.on.withArgs('authentication').firstCall.args[1]; + await authHandler(mockCtx); + + expect(mockDb.findUserBySSHKey.calledOnce).to.be.true; + expect(mockCtx.accept.calledOnce).to.be.true; + expect(mockClient.username).to.equal('test-user'); + expect(mockClient.userPrivateKey).to.deep.equal(mockCtx.key); + }); + + it('should handle password authentication successfully', async () => { + const mockCtx = { + method: 'password', + username: 'test-user', + password: 'test-password', + accept: sinon.stub(), + reject: sinon.stub(), + }; + + mockDb.findUser.resolves({ + username: 'test-user', + password: '$2a$10$mockHash', + }); + + const bcrypt = require('bcryptjs'); + sinon.stub(bcrypt, 'compare').resolves(true); + + server.handleClient(mockClient); + const authHandler = mockClient.on.withArgs('authentication').firstCall.args[1]; + await authHandler(mockCtx); + + expect(mockDb.findUser.calledWith('test-user')).to.be.true; + expect(bcrypt.compare.calledWith('test-password', '$2a$10$mockHash')).to.be.true; + expect(mockCtx.accept.calledOnce).to.be.true; + }); + }); + }); + + describe('handleSession', () => { + let mockSession; + let mockStream; + let mockAccept; + let mockReject; + + beforeEach(() => { + mockStream = { + write: sinon.stub(), + end: sinon.stub(), + exit: sinon.stub(), + on: sinon.stub(), + }; + + mockSession = { + on: sinon.stub(), + _channel: { + _client: { + userPrivateKey: null, + }, + }, + }; + + mockAccept = sinon.stub().returns(mockSession); + mockReject = sinon.stub(); + }); + + it('should handle git-upload-pack command', async () => { + const mockInfo = { + command: "git-upload-pack 'test/repo'", + }; + + mockChain.executeChain.resolves({ + error: false, + blocked: false, + }); + + const { Client } = require('ssh2'); + const mockSsh2Client = { + on: sinon.stub(), + connect: sinon.stub(), + exec: sinon.stub(), + }; + + // Mock the SSH client constructor + sinon.stub(Client.prototype, 'on').callsFake(mockSsh2Client.on); + sinon.stub(Client.prototype, 'connect').callsFake(mockSsh2Client.connect); + sinon.stub(Client.prototype, 'exec').callsFake(mockSsh2Client.exec); + + // Mock the ready event + mockSsh2Client.on.withArgs('ready').callsFake((event, callback) => { + callback(); + }); + + // Mock the exec response + mockSsh2Client.exec.callsFake((command, options, callback) => { + const mockStream = { + on: sinon.stub(), + write: sinon.stub(), + end: sinon.stub(), + }; + callback(null, mockStream); + }); + + server.handleSession(mockAccept, mockReject); + const execHandler = mockSession.on.withArgs('exec').firstCall.args[1]; + await execHandler(mockAccept, mockReject, mockInfo); + + expect( + mockChain.executeChain.calledWith({ + method: 'GET', + originalUrl: " 'test/repo", + isSSH: true, + headers: { + 'user-agent': 'git/2.0.0', + 'content-type': undefined, + }, + }), + ).to.be.true; + }); + + it('should handle git-receive-pack command', async () => { + const mockInfo = { + command: "git-receive-pack 'test/repo'", + }; + + mockChain.executeChain.resolves({ + error: false, + blocked: false, + }); + + const { Client } = require('ssh2'); + const mockSsh2Client = { + on: sinon.stub(), + connect: sinon.stub(), + exec: sinon.stub(), + }; + sinon.stub(Client.prototype, 'on').callsFake(mockSsh2Client.on); + sinon.stub(Client.prototype, 'connect').callsFake(mockSsh2Client.connect); + sinon.stub(Client.prototype, 'exec').callsFake(mockSsh2Client.exec); + + server.handleSession(mockAccept, mockReject); + const execHandler = mockSession.on.withArgs('exec').firstCall.args[1]; + await execHandler(mockAccept, mockReject, mockInfo); + + expect( + mockChain.executeChain.calledWith({ + method: 'POST', + originalUrl: " 'test/repo", + isSSH: true, + headers: { + 'user-agent': 'git/2.0.0', + 'content-type': 'application/x-git-receive-pack-request', + }, + }), + ).to.be.true; + }); + + it('should handle unsupported commands', async () => { + const mockInfo = { + command: 'unsupported-command', + }; + + // Mock the stream that accept() returns + mockStream = { + write: sinon.stub(), + end: sinon.stub(), + }; + + // Mock the session + const mockSession = { + on: sinon.stub(), + }; + + // Set up the exec handler + mockSession.on.withArgs('exec').callsFake((event, handler) => { + // First accept call returns the session + // const sessionAccept = () => mockSession; + // Second accept call returns the stream + const streamAccept = () => mockStream; + handler(streamAccept, mockReject, mockInfo); + }); + + // Update mockAccept to return our mock session + mockAccept = sinon.stub().returns(mockSession); + + server.handleSession(mockAccept, mockReject); + + expect(mockStream.write.calledWith('Unsupported command')).to.be.true; + expect(mockStream.end.calledOnce).to.be.true; + }); + }); +});