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 0d79baf96..164370aec 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 4539cb5b2..4b2d3557d 100644 --- a/config.schema.json +++ b/config.schema.json @@ -135,6 +135,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 77b0a2ff0..369812191 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dependencies": { "@material-ui/core": "^4.11.0", "@material-ui/icons": "4.11.3", + "@material-ui/lab": "^4.0.0-alpha.61", "@primer/octicons-react": "^19.8.0", "@seald-io/nedb": "^4.0.2", "axios": "^1.6.0", @@ -52,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" }, @@ -70,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", @@ -2597,6 +2600,34 @@ } } }, + "node_modules/@material-ui/lab": { + "version": "4.0.0-alpha.61", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz", + "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.3", + "clsx": "^1.0.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "@material-ui/core": "^4.12.1", + "@types/react": "^16.8.6 || ^17.0.0", + "react": "^16.8.0 || ^17.0.0", + "react-dom": "^16.8.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@material-ui/styles": { "version": "4.11.5", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", @@ -3839,6 +3870,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", @@ -4696,7 +4754,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" @@ -4869,6 +4926,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", @@ -5574,6 +5640,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", @@ -10414,6 +10494,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "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", @@ -12533,6 +12620,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", @@ -13607,7 +13711,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 443981997..b0bf1a54e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,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", @@ -41,6 +41,7 @@ "dependencies": { "@material-ui/core": "^4.11.0", "@material-ui/icons": "4.11.3", + "@material-ui/lab": "^4.0.0-alpha.61", "@primer/octicons-react": "^19.8.0", "@seald-io/nedb": "^4.0.2", "axios": "^1.6.0", @@ -79,6 +80,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" }, @@ -93,6 +95,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 6b2970c30..b4bb4644a 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -183,5 +183,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 a13cfef23..89cad550a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -34,6 +34,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 @@ -57,6 +58,24 @@ 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; +}; +export const getPublicSSHConfig = () => { + if (_userSettings !== null && _userSettings.ssh) { + _sshConfig = _userSettings.ssh; + } + const { enabled = false, port = 22 } = _sshConfig; + return { enabled, port }; +}; + // 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..39bc27521 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -27,4 +27,15 @@ export const { canUserApproveRejectPushRepo, } = repo; -export const { findUser, findUserByOIDC, getUsers, createUser, deleteUser, updateUser } = users; +export const { + findUser, + findUserByOIDC, + getUsers, + createUser, + deleteUser, + updateUser, + addPublicKey, + removePublicKey, + getPublicKeys, + findUserBySSHKey, +} = users; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index 263c612f4..9f687f5f5 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import Datastore from '@seald-io/nedb'; -import { User } from '../types'; +import { PublicKeyRecord, User } from '../types'; const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day @@ -25,11 +25,7 @@ export const findUser = (username: string) => { if (err) { reject(err); } else { - if (!doc) { - resolve(null); - } else { - resolve(doc); - } + resolve(doc || null); } }); }); @@ -43,17 +39,17 @@ export const findUserByOIDC = function (oidcId: string) { if (err) { reject(err); } else { - if (!doc) { - resolve(null); - } else { - resolve(doc); - } + resolve(doc || null); } }); }); }; 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 +80,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(); @@ -132,11 +132,86 @@ export const getUsers = (query: any = {}) => { db.find(query, (err: Error, docs: User[]) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ + if (err) reject(err); + else resolve(docs); + }); + }); +}; + +export const getPublicKeys = (username: string): Promise => { + return findUser(username).then((user) => { + if (!user) { + throw new Error('User not found'); + } + return Array.isArray(user.publicKeys) ? user.publicKeys : []; + }); +}; + +export const addPublicKey = (username: string, record: PublicKeyRecord): Promise => + new Promise((resolve, reject) => { + findUser(username) + .then((user) => { + if (!user) { + reject(new Error('User not found')); + return; + } + if (!Array.isArray(user.publicKeys)) user.publicKeys = []; + + const exists = user.publicKeys.some((r) => r.key === record.key); + if (exists) return resolve(user); + + user.publicKeys.push({ + ...record, + addedAt: record.addedAt ?? new Date().toISOString(), + }); + + updateUser(user) + .then(() => resolve(user)) + .catch(reject); + }) + .catch(reject); + }); + +export const removePublicKey = function (username: string, canonicalKey: string) { + return new Promise((resolve, reject) => { + findUser(username) + .then((user) => { + if (!user) { + reject(new Error('User not found')); + return; + } + + if (!Array.isArray(user.publicKeys)) { + user.publicKeys = []; + return resolve(user); + } + + const before = user.publicKeys.length; + user.publicKeys = user.publicKeys.filter((rec) => rec.key !== canonicalKey); + + if (user.publicKeys.length === before) { + return reject(new Error('SSH key not found')); + } + + updateUser(user) + .then(() => resolve(user)) + .catch(reject); + }) + .catch(reject); + }); +}; + +export const findUserBySSHKey = (sshKey: string): Promise => + new Promise((resolve, reject) => { + db.findOne({ 'publicKeys.key': sshKey }, (err, doc) => { if (err) { reject(err); } else { - resolve(docs); + 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..cbc89a5f4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -82,4 +82,8 @@ export const { canUserApproveRejectPush, canUserCancelPush, getSessionStore, + addPublicKey, + removePublicKey, + getPublicKeys, + 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..c7cf18513 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -1,4 +1,4 @@ -import { User } from '../types'; +import { PublicKeyRecord, User } from '../types'; const connect = require('./helper').connect; const collectionName = 'users'; @@ -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,45 @@ 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, record: PublicKeyRecord) => { + const collection = await connect(collectionName); + + return collection.updateOne( + { + username: username.toLowerCase(), + 'publicKeys.key': { $ne: record.key }, + }, + { + $push: { + publicKeys: { + key: record.key, + name: record.name, + addedAt: record.addedAt ?? new Date().toISOString(), + }, + }, + }, + ); +}; + +export const removePublicKey = async (username: string, canonicalKey: string) => { + const collection = await connect(collectionName); + + return collection.updateOne( + { username: username.toLowerCase() }, + { $pull: { publicKeys: { key: canonicalKey } } }, + ); +}; + +export const findUserBySSHKey = async (sshKey: string): Promise => { + const collection = await connect(collectionName); + return collection.findOne({ 'publicKeys.key': sshKey }); +}; + +export const getPublicKeys = async (username: string): Promise => { + const user = await findUser(username.toLowerCase()); + if (!user) { + throw new Error('User not found'); + } + return Array.isArray(user.publicKeys) ? user.publicKeys : []; +}; diff --git a/src/db/types.ts b/src/db/types.ts index 04951a699..11eace141 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -15,6 +15,12 @@ export type Repo = { _id: string; }; +export type PublicKeyRecord = { + key: string; + name: string; + addedAt: string; +}; + export type User = { _id: string; username: string; @@ -23,6 +29,7 @@ export type User = { email: string; admin: boolean; oidcId: string | null; + publicKeys: PublicKeyRecord[]; }; 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 79c91791a..00ebe7bac 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -9,11 +9,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; @@ -51,6 +53,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 b4794a8ae..7b1ccfebc 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -55,6 +55,20 @@ const validGitRequest = (url: string, headers: any): boolean => { return false; }; +// 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; +}; + const isPackPost = (req: Request) => req.method === 'POST' && // eslint-disable-next-line no-useless-escape @@ -134,6 +148,14 @@ 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; + }, proxyErrorHandler: (err, res, next) => { console.log(`ERROR=${err}`); 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..56edd9520 100644 --- a/src/service/routes/users.js +++ b/src/service/routes/users.js @@ -1,3 +1,5 @@ +const { normalisePublicKey, fingerprintSHA256 } = require('../utils/ssh-utils'); + const express = require('express'); const router = new express.Router(); const db = require('../../db'); @@ -17,7 +19,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((rec) => rec.key.trim()); + } + } + res.send(users); }); router.get('/:id', async (req, res) => { @@ -29,4 +38,145 @@ 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, name } = req.body; + + if (!name || typeof name !== 'string') + return res.status(400).json({ error: 'Key name is required' }); + if (!publicKey) { + res.status(400).json({ error: 'Public key is required' }); + return; + } + + let canonicalKey; + try { + canonicalKey = normalisePublicKey(publicKey); + } catch { + return res.status(422).json({ error: 'Invalid SSH public key' }); + } + + try { + const record = { + key: canonicalKey, + name: name.trim(), + addedAt: new Date().toISOString(), + }; + await db.addPublicKey(targetUsername, record); + 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) return res.status(400).json({ error: 'Public key is required' }); + + let canonicalKey; + try { + canonicalKey = normalisePublicKey(publicKey); + } catch { + return res.status(422).json({ error: 'Invalid SSH public key' }); + } + + try { + await db.removePublicKey(targetUsername, canonicalKey); + 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' }); + } +}); + +router.delete('/:username/ssh-keys/fingerprint', async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const targetUsername = req.params.username.toLowerCase(); + + if (req.user.username !== targetUsername && !req.user.admin) { + return res.status(403).json({ error: 'Not authorized to remove keys for this user' }); + } + + const { fingerprint } = req.body; + if (!fingerprint) { + return res.status(400).json({ error: 'Fingerprint is required' }); + } + + try { + const keys = await db.getPublicKeys(targetUsername); + const keyToDelete = keys.find((rec) => { + const keyFingerprint = fingerprintSHA256(rec.key); + return keyFingerprint === fingerprint; + }); + + if (!keyToDelete) { + return res.status(404).json({ error: 'SSH key not found for supplied fingerprint' }); + } + + await db.removePublicKey(targetUsername, keyToDelete.key); + res.status(200).json({ message: 'SSH key removed successfully' }); + } catch (err) { + console.error('Error removing SSH key:', err); + res.status(500).json({ error: 'Failed to remove SSH key' }); + } +}); + +router.get('/:username/ssh-keys', async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const targetUsername = req.params.username.toLowerCase(); + + // A user can view their own keys; admins can view anyone's + if (req.user.username !== targetUsername && !req.user.admin) { + return res.status(403).json({ error: 'Not authorized to view keys for this user' }); + } + + try { + const keys = await db.getPublicKeys(targetUsername); + const result = keys.map((rec) => ({ + name: rec.name, + fingerprint: fingerprintSHA256(rec.key), + addedAt: rec.addedAt, + })); + + res.status(200).json({ publicKeys: result }); + } catch (err) { + console.error('Error fetching SSH keys:', err); + res.status(500).json({ error: 'Failed to fetch SSH keys' }); + } +}); + module.exports = router; diff --git a/src/service/utils/ssh-utils.js b/src/service/utils/ssh-utils.js new file mode 100644 index 000000000..8f06415f4 --- /dev/null +++ b/src/service/utils/ssh-utils.js @@ -0,0 +1,12 @@ +import sshpk from 'sshpk'; + +export function normalisePublicKey(raw) { + // sshpk trims & ignores trailing comment + const key = sshpk.parseKey(raw, 'ssh'); + return key.toString('ssh'); +} + +export function fingerprintSHA256(pubKey) { + const key = sshpk.parseKey(pubKey, 'ssh'); + return key.fingerprint('sha256').toString(); +} diff --git a/src/ui/components/CustomButtons/CodeActionButton.jsx b/src/ui/components/CustomButtons/CodeActionButton.jsx index 68c796316..9e8fb0a6f 100644 --- a/src/ui/components/CustomButtons/CodeActionButton.jsx +++ b/src/ui/components/CustomButtons/CodeActionButton.jsx @@ -1,3 +1,4 @@ +import React, { useState, useEffect } from 'react'; import Popper from '@material-ui/core/Popper'; import Paper from '@material-ui/core/Paper'; import { @@ -6,14 +7,48 @@ import { CodeIcon, CopyIcon, TerminalIcon, + KeyIcon, } from '@primer/octicons-react'; -import React, { useState } from 'react'; +import { getSshConfig } from '../../services/ssh'; const CodeActionButton = ({ cloneURL }) => { const [anchorEl, setAnchorEl] = useState(null); const [open, setOpen] = useState(false); const [placement, setPlacement] = useState(); const [isCopied, setIsCopied] = useState(false); + const [protocol, setProtocol] = useState('https'); + + const [sshCfg, setSshCfg] = useState({ enabled: true, port: 22 }); + useEffect(() => { + let active = true; + (async () => { + const cfg = await getSshConfig(); + if (active) setSshCfg(cfg); + })(); + return () => { + active = false; + }; + }, []); + + const getSSHUrl = () => { + try { + const urlObj = new URL(cloneURL); + const host = urlObj.hostname; // hostname w/out any port + let path = urlObj.pathname; + if (path.startsWith('/')) path = path.slice(1); + + // Default port + if (!sshCfg.port || sshCfg.port === 22) { + return `git@${host}:${path}`; + } + // Custom port + return `ssh://git@${host}:${sshCfg.port}/${path}`; + } catch { + return cloneURL; + } + }; + + const selectedUrl = protocol === 'ssh' ? getSSHUrl() : cloneURL; const handleClick = (newPlacement) => (event) => { setIsCopied(false); @@ -22,86 +57,116 @@ const CodeActionButton = ({ cloneURL }) => { setPlacement(newPlacement); }; + const handleCopy = () => { + const command = `git clone ${selectedUrl}`; + navigator.clipboard.writeText(command); + setIsCopied(true); + }; + return ( <> - {' '} - Code + + Clone + -
- {' '} - Clone -
+
+ {/* Protocol tabs */} +
+
{ + setProtocol('https'); + setIsCopied(false); + }} + style={{ + flex: 1, + textAlign: 'center', + padding: '6px 0', + cursor: 'pointer', + fontWeight: protocol === 'https' ? 'bold' : 'normal', + borderBottom: + protocol === 'https' ? '2px solid #2f81f7' : '2px solid transparent', + }} + > + HTTPS +
+
{ + setProtocol('ssh'); + setIsCopied(false); + }} + style={{ + flex: 1, + textAlign: 'center', + padding: '6px 0', + cursor: 'pointer', + fontWeight: protocol === 'ssh' ? 'bold' : 'normal', + borderBottom: protocol === 'ssh' ? '2px solid #2f81f7' : '2px solid transparent', + }} + > + SSH +
+
+ + {/* Clone command box */} +
- - {cloneURL} - - - {!isCopied && ( - { - navigator.clipboard.writeText(`git clone ${cloneURL}`); - setIsCopied(true); - }} - > - - - )} - {isCopied && ( - - - - )} - + git clone {selectedUrl} +
+
+ {!isCopied ? ( + + + + ) : ( + + + + )}
+
Use Git and run this command in your IDE or Terminal 👍 diff --git a/src/ui/components/SSHKeysManager/SSHKeysManager.jsx b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx new file mode 100644 index 000000000..cd8d9594c --- /dev/null +++ b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Typography, + Button, + IconButton, + Grid, + Paper, + Modal, + TextField, + Snackbar, +} from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import { makeStyles } from '@material-ui/core/styles'; +import DeleteIcon from '@material-ui/icons/Delete'; +import VpnKeyIcon from '@material-ui/icons/VpnKey'; +import dayjs from 'dayjs'; + +import { getSSHKeys, deleteSSHKey, addSSHKey } from '../../services/ssh'; + +const useStyles = makeStyles((theme) => ({ + root: { padding: theme.spacing(3), width: '100%' }, + button: { + marginBottom: theme.spacing(2), + backgroundColor: '#4caf50', + color: 'white', + '&:hover': { backgroundColor: '#388e3c' }, + }, + keyContainer: { padding: theme.spacing(2), marginBottom: theme.spacing(2) }, + deleteButton: { color: '#ff4444' }, + modal: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, + modalContent: { + backgroundColor: 'white', + padding: theme.spacing(4), + borderRadius: 8, + width: 400, + }, + formField: { marginBottom: theme.spacing(2) }, +})); + +export default function SSHKeysManager({ username }) { + const classes = useStyles(); + + const [keys, setKeys] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [newKeyValue, setNewKeyValue] = useState(''); + const [newKeyName, setNewKeyName] = useState(''); + + const [banner, setBanner] = useState(null); // { type, text } + + /* ------------------------------------------- */ + const loadKeys = useCallback(async () => { + if (!username) return; + try { + const data = await getSSHKeys(username); + setKeys(data); + } catch (err) { + console.error(err); + setBanner({ type: 'error', text: 'Failed to load SSH keys' }); + } + }, [username]); + + useEffect(() => void loadKeys(), [loadKeys]); + + /* ----------------------------------------------------------- + * Delete by fingerprint + * --------------------------------------------------------- */ + const handleDelete = async (index) => { + const { fingerprint } = keys[index]; + try { + await deleteSSHKey(username, fingerprint); + await loadKeys(); + setBanner({ type: 'success', text: 'SSH key removed' }); + } catch (err) { + console.error(err); + setBanner({ + type: 'error', + text: err.response?.data?.error || 'Failed to remove SSH key', + }); + } + }; + + /* ----------------------------------------------------------- + * Add new public key, then refresh list + * --------------------------------------------------------- */ + const handleAddKey = async () => { + const publicKey = newKeyValue.trim(); + const name = newKeyName.trim(); + if (!publicKey || !name) return; + + try { + await addSSHKey(username, { publicKey, name }); + await loadKeys(); + setBanner({ type: 'success', text: 'SSH key added' }); + setNewKeyValue(''); + setNewKeyName(''); + setIsModalOpen(false); + } catch (err) { + console.error(err); + setBanner({ + type: 'error', + text: err.response?.data?.error || 'Failed to add SSH key', + }); + } + }; + + return ( +
+ setBanner(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + {banner && ( + setBanner(null)} severity={banner.type} variant='filled'> + {banner.text} + + )} + + + + SSH Keys + + + These are the SSH keys linked to your account. + + + + + {keys.map((key, idx) => ( + + + + + + + {key.name} + + + + + {key.fingerprint} + + + Added on {dayjs(key.addedAt).format('YYYY-MM-DD HH:mm')} + + + handleDelete(idx)} + style={{ float: 'right' }} + > + + + + ))} + + setIsModalOpen(false)} className={classes.modal}> +
+ + Add a new SSH key + + + setNewKeyName(e.target.value)} + placeholder='e.g. MacBook Pro' + /> + + setNewKeyValue(e.target.value)} + placeholder='ssh-ed25519 AAAAC3Nz... user@example' + /> + + +
+
+
+ ); +} diff --git a/src/ui/services/config.js b/src/ui/services/config.js index 5536e4a35..e25fc2d40 100644 --- a/src/ui/services/config.js +++ b/src/ui/services/config.js @@ -32,9 +32,17 @@ const getUIRouteAuth = async (setData) => { }); }; -export { - getAttestationConfig, - getURLShortener, - getEmailContact, - getUIRouteAuth, +const getSSHConfig = async (setData) => { + const url = new URL(`${baseUrl}/config/ssh`); + await axios(url.toString(), { withCredentials: true }) + .then((response) => { + const { enabled = true, port = 22 } = response.data ?? {}; + setData({ enabled, port }); + }) + .catch((err) => { + console.error('Failed to load SSH config:', err); + setData({ enabled: true, port: 22 }); + }); }; + +export { getAttestationConfig, getURLShortener, getEmailContact, getUIRouteAuth, getSSHConfig }; diff --git a/src/ui/services/ssh.js b/src/ui/services/ssh.js new file mode 100644 index 000000000..59e17b347 --- /dev/null +++ b/src/ui/services/ssh.js @@ -0,0 +1,23 @@ +import axios from 'axios'; + +const BASE_URL = import.meta.env.VITE_API_URI + ? `${import.meta.env.VITE_API_URI}/api/v1/user` + : `${location.origin}/api/v1/user`; + +const AXIOS_CFG = { withCredentials: true }; + +export async function getSSHKeys(username) { + const { data } = await axios.get(`${BASE_URL}/${username}/ssh-keys`, AXIOS_CFG); + return data.publicKeys || []; +} + +export async function deleteSSHKey(username, fingerprint) { + await axios.delete(`${BASE_URL}/${username}/ssh-keys/fingerprint`, { + ...AXIOS_CFG, + data: { fingerprint }, + }); +} + +export async function addSSHKey(username, key) { + await axios.post(`${BASE_URL}/${username}/ssh-keys`, key, AXIOS_CFG); +} diff --git a/src/ui/views/User/User.jsx b/src/ui/views/User/User.jsx index 44fa64e1c..7d571ebc7 100644 --- a/src/ui/views/User/User.jsx +++ b/src/ui/views/User/User.jsx @@ -13,6 +13,7 @@ import { LogoGithubIcon } from '@primer/octicons-react'; import CloseRounded from '@material-ui/icons/CloseRounded'; import { Check, Save } from '@material-ui/icons'; import { TextField } from '@material-ui/core'; +import SSHKeysManager from '../../components/SSHKeysManager/SSHKeysManager'; const useStyles = makeStyles((theme) => ({ root: { @@ -161,6 +162,10 @@ export default function Dashboard() {
+
+
+ +
) : null} 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; + }); + }); +});