From 515162d79f54d59c3bcd71d34e8c735b06ddc595 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Mon, 31 Mar 2025 10:54:13 +0200 Subject: [PATCH 01/13] feat: add ssh ui --- package.json | 1 + .../SSHKeysManager/SSHKeysManager.jsx | 180 ++++++++++++++++++ src/ui/views/User/User.jsx | 5 + 3 files changed, 186 insertions(+) create mode 100644 src/ui/components/SSHKeysManager/SSHKeysManager.jsx diff --git a/package.json b/package.json index d6f214f72..51543fc3d 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-html-parser": "^2.0.2", + "react-icons": "^5.5.0", "react-router-dom": "6.28.2", "simple-git": "^3.25.0", "uuid": "^11.0.0", diff --git a/src/ui/components/SSHKeysManager/SSHKeysManager.jsx b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx new file mode 100644 index 000000000..32cb7fcaa --- /dev/null +++ b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { Typography, Button, IconButton, Grid, Paper, Modal, TextField } from '@material-ui/core'; +import DeleteIcon from '@material-ui/icons/Delete'; +import VpnKeyIcon from '@material-ui/icons/VpnKey'; +import axios from 'axios'; + +const useStyles = makeStyles((theme) => ({ + root: { + padding: theme.spacing(3), + minHeight: 'auto', + width: '100%', + boxSizing: 'border-box', + margin: '0 auto', + }, + button: { + marginBottom: theme.spacing(2), + backgroundColor: '#4caf50', + color: 'white', + '&:hover': { + backgroundColor: '#388e3c', + }, + }, + deleteButton: { + color: '#ff4444', + }, + keyContainer: { + padding: theme.spacing(2), + borderRadius: '8px', + marginBottom: theme.spacing(2), + width: '100%', + }, + modal: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + modalContent: { + backgroundColor: 'white', + padding: theme.spacing(4), + borderRadius: '8px', + width: '400px', + boxShadow: theme.shadows[5], + }, + formField: { + marginBottom: theme.spacing(2), + }, +})); + +export default function SSHKeysManager({ username }) { + const classes = useStyles(); + const [keys, setKeys] = useState([ + { + name: 'macOS', + hash: 'SHA256:+s1qm8b66N1BQtVMWFeeTJb+QsJiJzxaswyO0lJ7kNw', + }, + { + name: 'dev', + hash: 'SHA256:RHNzb7j+QyoE/xrCZCc0IiQ8+XdAF8tEno/tZ1rzqF0', + }, + ]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyValue, setNewKeyValue] = useState(''); + + const handleDelete = async (index) => { + const keyToRemove = keys[index].hash; + try { + await axios.delete(`/api/${username}/ssh-keys`, { + data: { publicKey: keyToRemove }, + }); + setKeys(keys.filter((_, i) => i !== index)); + } catch (error) { + console.error('Failed to remove SSH key:', error); + } + }; + + const handleAddKey = async () => { + if (newKeyName.trim() && newKeyValue.trim()) { + try { + await axios.post(`/api/${username}/ssh-keys`, { + publicKey: newKeyValue.trim(), + }); + setKeys([ + ...keys, + { + name: newKeyName.trim(), + hash: newKeyValue.trim(), + }, + ]); + setNewKeyName(''); + setNewKeyValue(''); + setIsModalOpen(false); + } catch (error) { + console.error('Failed to add SSH key:', error); + } + } + }; + + return ( +
+ + + + SSH Keys + + + This is the list of SSH keys currently associated with your account. + + +
+ {keys.map((key, index) => ( + + + + + + + + + {key.name} + + + + {key.hash} + + + + handleDelete(index)} + > + + + + + + ))} +
+
+
+ + {/* Modal for adding a new SSH key */} + setIsModalOpen(false)} className={classes.modal}> +
+ + Add New SSH Key + + setNewKeyName(e.target.value)} + /> + setNewKeyValue(e.target.value)} + /> + +
+
+
+ ); +} diff --git a/src/ui/views/User/User.jsx b/src/ui/views/User/User.jsx index c8b46ebe5..34c2611a0 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} From f514691cd75d5f1bf02831bec565eb096aed862e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 28 Apr 2025 14:04:14 +0200 Subject: [PATCH 02/13] feat: adds ssh support for git operations --- .gitignore | 7 +- README.md | 4 +- config.schema.json | 30 + docs/SSH.md | 165 +++++ package-lock.json | 78 +- package.json | 4 +- packages/git-proxy-cli/index.js | 88 ++- proxy.config.json | 8 + src/chain/index.d.ts | 11 + src/cli/ssh-key.js | 122 ++++ src/config/index.d.ts | 4 + src/config/index.ts | 12 + src/config/types.ts | 9 + src/db/file/index.ts | 3 + src/db/file/users.ts | 66 ++ src/db/index.d.ts | 4 + src/db/index.ts | 3 + src/db/mongo/index.ts | 9 +- src/db/mongo/users.ts | 29 +- src/db/types.ts | 7 +- src/proxy/actions/Action.ts | 11 +- src/proxy/index.ts | 8 + .../processors/pre-processor/parseAction.ts | 7 +- .../processors/push-action/pullRemote.ts | 36 +- src/proxy/routes/index.ts | 26 +- src/proxy/ssh/server.js | 683 ++++++++++++++++++ src/service/routes/users.js | 73 +- test/ssh/sshServer.test.js | 341 +++++++++ 28 files changed, 1811 insertions(+), 37 deletions(-) create mode 100644 docs/SSH.md create mode 100644 src/chain/index.d.ts create mode 100644 src/cli/ssh-key.js create mode 100644 src/config/index.d.ts create mode 100644 src/db/index.d.ts create mode 100644 src/proxy/ssh/server.js create mode 100644 test/ssh/sshServer.test.js diff --git a/.gitignore b/.gitignore index 1849589c4..dcfcdf02d 100644 --- a/.gitignore +++ b/.gitignore @@ -246,6 +246,7 @@ dist # testing /coverage +.temp # production /build @@ -263,4 +264,8 @@ yarn-error.log* # Docusaurus website website/build -website/.docusaurus \ No newline at end of file +website/.docusaurus + + +# IDE files +.idea \ No newline at end of file diff --git a/README.md b/README.md index 24e178b1b..2f3f95e1b 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,14 @@ $ git push proxy $(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remo Using the default configuration, GitProxy intercepts the push and _blocks_ it. To enable code pushing to your fork via GitProxy, add your repository URL into the GitProxy config file (`proxy.config.json`). For more information, refer to [our documentation](https://git-proxy.finos.org). ## Documentation + For detailed step-by-step instructions for how to install, deploy & configure GitProxy and customize for your environment, see the [project's documentation](https://git-proxy.finos.org/docs/): - [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 @@ -115,4 +117,4 @@ If you can't access Slack, you can also [subscribe to our mailing list](mailto:g Join our [fortnightly Zoom meeting](https://zoom.us/j/97235277537?pwd=aDJsaE8zcDJpYW1vZHJmSTJ0RXNZUT09) on Monday, 11AM EST (odd week numbers). Send an e-mail to [help@finos.org](mailto:help@finos.org) to get a calendar invitation. -Otherwise, if you have a deeper query or require more support, please [raise an issue](https://github.com/finos/git-proxy/issues). +Otherwise, if you have a deeper query or require more support, please [raise an issue](https://github.com/finos/git-proxy/issues). diff --git a/config.schema.json b/config.schema.json index c0ac89663..bd71700a3 100644 --- a/config.schema.json +++ b/config.schema.json @@ -103,6 +103,36 @@ } } }, + "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 619cb0fce..9255dc862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,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" }, @@ -68,6 +69,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", @@ -3843,6 +3845,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", @@ -4700,7 +4729,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" @@ -4900,6 +4928,15 @@ "node": "*" } }, + "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", @@ -5605,6 +5642,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", @@ -10255,6 +10306,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", @@ -12371,6 +12429,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", @@ -13445,7 +13520,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 fcad06c46..0733abc1f 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} --config ./.prettierrc", "gen-schema-doc": "node ./scripts/doc-schema.js", @@ -75,6 +75,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" }, @@ -89,6 +90,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 b0090a4bf..0cbc9264e 100755 --- a/packages/git-proxy-cli/index.js +++ b/packages/git-proxy-cli/index.js @@ -7,7 +7,8 @@ const util = require('util'); const GIT_PROXY_COOKIE_FILE = 'git-proxy-cookie'; // GitProxy UI HOST and PORT (configurable via environment variable) -const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 8080 } = process.env; +const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 8080 } = + process.env; const baseUrl = `${uiHost}:${uiPort}`; @@ -306,6 +307,60 @@ async function logout() { console.log('Logout: OK'); } +/** + * 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({ @@ -436,6 +491,37 @@ yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused rejectGitPush(argv.id); }, }) + .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 580982cd4..6af63718f 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -106,5 +106,13 @@ "enabled": true, "key": "certs/key.pem", "cert": "certs/cert.pem" + }, + "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 d041344a4..b399069aa 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -31,6 +31,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 @@ -47,6 +48,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 a1907477a..155115fcf 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -20,9 +20,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 a31610173..4fceb8b07 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -33,4 +33,7 @@ export const { createUser, deleteUser, updateUser, + addPublicKey, + removePublicKey, + findUserBySSHKey, } = users; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index d72443c97..de2fb1bc6 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -40,6 +40,10 @@ export const findUserByOIDC = function (oidcId: string) { }; export const createUser = function (user: User) { + if (!user.publicKeys) { + user.publicKeys = []; + } + return new Promise((resolve, reject) => { db.insert(user, (err) => { if (err) { @@ -64,6 +68,10 @@ export const deleteUser = (username: string) => { }; export const updateUser = (user: User) => { + if (!user.publicKeys) { + user.publicKeys = []; + } + return new Promise((resolve, reject) => { const options = { multi: false, upsert: false }; db.update({ username: user.username }, user, options, (err) => { @@ -87,3 +95,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 0fc681058..73e941e78 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -81,4 +81,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 11f526c2a..0b60cb8ad 100644 --- a/src/db/mongo/index.ts +++ b/src/db/mongo/index.ts @@ -1,11 +1,9 @@ import * as helper from './helper'; import * as pushes from './pushes'; -import * as repo from './repo'; +import * as repo from './repo'; import * as users from './users'; -export const { - getSessionStore, -} = helper; +export const { getSessionStore } = helper; export const { getPushes, @@ -37,4 +35,7 @@ export const { createUser, deleteUser, updateUser, + addPublicKey, + removePublicKey, + findUserBySSHKey, } = users; diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index 0bfa1a941..6980c181e 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -1,4 +1,4 @@ -import { User } from "../types"; +import { User } from '../types'; const connect = require('./helper').connect; const collectionName = 'users'; @@ -20,14 +20,41 @@ export const deleteUser = async function (username: string) { }; export const createUser = async function (user: User) { + if (!user.publicKeys) { + user.publicKeys = []; + } user.username = user.username.toLowerCase(); const collection = await connect(collectionName); return collection.insertOne(user); }; export const updateUser = async (user: User) => { + if (!user.publicKeys) { + user.publicKeys = []; + } user.username = user.username.toLowerCase(); const options = { upsert: true }; 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 dba9bdf3a..31b5af949 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -1,8 +1,8 @@ export type PushQuery = { error: boolean; - blocked: boolean, - allowPush: boolean, - authorised: boolean + blocked: boolean; + allowPush: boolean; + authorised: boolean; }; export type UserRole = 'canPush' | 'canAuthorise'; @@ -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 78dbc2ef0..51c137854 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -1,5 +1,5 @@ -import { getProxyUrl } from "../../config"; -import { Step } from "./Step"; +import { getProxyUrl } from '../../config'; +import { Step } from './Step'; /** * Represents a commit. @@ -48,6 +48,7 @@ class Action { attestation?: string; lastStep?: Step; proxyGitPath?: string; + protocol: 'https' | 'ssh' = 'https'; /** * Create an action. @@ -62,15 +63,15 @@ class Action { this.type = type; this.method = method; this.timestamp = timestamp; - this.project = repo.split("/")[0]; - this.repoName = repo.split("/")[1]; + this.project = repo.split('/')[0]; + this.repoName = repo.split('/')[1]; this.url = `${getProxyUrl()}/${repo}`; this.repo = repo; } /** * Add a step to the action. - * @param {Step} step + * @param {Step} step */ addStep(step: Step): void { this.steps.push(step); diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 0a49d0a6f..9128b7932 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; @@ -44,6 +46,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 ed610d9d1..6fed33675 100644 --- a/src/proxy/processors/pre-processor/parseAction.ts +++ b/src/proxy/processors/pre-processor/parseAction.ts @@ -1,6 +1,11 @@ import { Action } from '../../actions'; -const exec = async (req: { originalUrl: string; method: string; headers: Record }) => { +const exec = async (req: { + originalUrl: string; + method: string; + headers: Record; + isSSH: boolean; +}) => { const id = Date.now(); const timestamp = id; const repoName = getRepoNameFromUrl(req.originalUrl); diff --git a/src/proxy/processors/push-action/pullRemote.ts b/src/proxy/processors/push-action/pullRemote.ts index c7559643f..9a06df827 100644 --- a/src/proxy/processors/push-action/pullRemote.ts +++ b/src/proxy/processors/push-action/pullRemote.ts @@ -1,7 +1,8 @@ import { Action, Step } from '../../actions'; -import fs from 'fs' +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,16 +22,30 @@ const exec = async (req: any, action: Action): Promise => { fs.mkdirSync(action.proxyGitPath, 0o755); } - const cmd = `git clone ${action.url}`; - step.log(`Exectuting ${cmd}`); + let cloneUrl = action.url; + let cmd = 'git clone'; - const authHeader = req.headers?.authorization; - const [username, password] = Buffer.from(authHeader.split(' ')[1], 'base64') - .toString() - .split(':'); + if (action.protocol === 'ssh') { + // Convert HTTPS URL to SSH URL + cloneUrl = action.url.replace('https://', 'git@'); + cmd += ` ${cloneUrl}`; + step.log(`Executing ${cmd}`); - await git - .clone({ + // 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, @@ -40,8 +55,9 @@ const exec = async (req: any, action: Action): Promise => { }), dir: `${action.proxyGitPath}/${action.repoName}`, }); + } - console.log('Clone Success: ', action.url); + 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 c49853376..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; }, @@ -132,9 +151,4 @@ const handleMessage = (message: string): string => { return packetMessage; }; -export { - router, - handleMessage, - validGitRequest, - stripGitHubFromGitPath, -}; +export { router, handleMessage, validGitRequest, stripGitHubFromGitPath }; 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; + }); + }); +}); From c5e622e796ef9ea2208bf4d01addbde5e816e3da Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 28 May 2025 13:25:29 +0200 Subject: [PATCH 03/13] feat: add getPublicKey in database --- src/db/file/index.ts | 1 + src/db/file/users.ts | 96 +++++++++++++++++-------------------------- src/db/index.ts | 1 + src/db/mongo/users.ts | 8 ++++ 4 files changed, 47 insertions(+), 59 deletions(-) diff --git a/src/db/file/index.ts b/src/db/file/index.ts index 4fceb8b07..532778d4f 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -35,5 +35,6 @@ export const { updateUser, addPublicKey, removePublicKey, + getPublicKeys, findUserBySSHKey, } = users; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index de2fb1bc6..ea316def7 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -13,73 +13,52 @@ export const findUser = (username: string) => { if (err) { reject(err); } else { - if (!doc) { - resolve(null); - } else { - resolve(doc); - } + resolve(doc || null); } }); }); }; -export const findUserByOIDC = function (oidcId: string) { - return new Promise((resolve, reject) => { - db.findOne({ oidcId: oidcId }, (err, doc) => { +export const findUserByOIDC = (oidcId: string) => { + return new Promise((resolve, reject) => { + db.findOne({ oidcId }, (err, doc: User) => { if (err) { reject(err); } else { - if (!doc) { - resolve(null); - } else { - resolve(doc); - } + resolve(doc || null); } }); }); }; -export const createUser = function (user: User) { +export const createUser = (user: User) => { if (!user.publicKeys) { user.publicKeys = []; } - - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { db.insert(user, (err) => { - if (err) { - reject(err); - } else { - resolve(user); - } + if (err) reject(err); + else resolve(user); }); }); }; export const deleteUser = (username: string) => { return new Promise((resolve, reject) => { - db.remove({ username: username }, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } + db.remove({ username }, (err) => { + if (err) reject(err); + else resolve(); }); }); }; export const updateUser = (user: User) => { - if (!user.publicKeys) { - user.publicKeys = []; - } - - return new Promise((resolve, reject) => { + if (!user.publicKeys) user.publicKeys = []; + return new Promise((resolve, reject) => { const options = { multi: false, upsert: false }; db.update({ username: user.username }, user, options, (err) => { - if (err) { - reject(err); - } else { - resolve(null); - } + if (err) reject(err); + else resolve(null); }); }); }; @@ -87,16 +66,13 @@ export const updateUser = (user: User) => { export const getUsers = (query: any = {}) => { return new Promise((resolve, reject) => { db.find(query, (err: Error, docs: User[]) => { - if (err) { - reject(err); - } else { - resolve(docs); - } + if (err) reject(err); + else resolve(docs); }); }); }; -export const addPublicKey = function (username: string, publicKey: string) { +export const addPublicKey = (username: string, publicKey: string) => { return new Promise((resolve, reject) => { findUser(username) .then((user) => { @@ -104,12 +80,10 @@ export const addPublicKey = function (username: string, publicKey: string) { reject(new Error('User not found')); return; } - if (!user.publicKeys) { - user.publicKeys = []; - } + if (!user.publicKeys) user.publicKeys = []; if (!user.publicKeys.includes(publicKey)) { user.publicKeys.push(publicKey); - exports.updateUser(user).then(resolve).catch(reject); + updateUser(user).then(resolve).catch(reject); } else { resolve(user); } @@ -118,7 +92,7 @@ export const addPublicKey = function (username: string, publicKey: string) { }); }; -export const removePublicKey = function (username: string, publicKey: string) { +export const removePublicKey = (username: string, publicKey: string) => { return new Promise((resolve, reject) => { findUser(username) .then((user) => { @@ -131,25 +105,29 @@ export const removePublicKey = function (username: string, publicKey: string) { resolve(user); return; } + console.log('key to remove:', publicKey); user.publicKeys = user.publicKeys.filter((key) => key !== publicKey); - exports.updateUser(user).then(resolve).catch(reject); + console.log('publicKeys after removal:', user.publicKeys); + updateUser(user).then(resolve).catch(reject); }) .catch(reject); }); }; -export const findUserBySSHKey = function (sshKey: string) { +export const findUserBySSHKey = (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); - } - } + if (err) reject(err); + else resolve(doc || null); }); }); }; + +export const getPublicKeys = (username: string): Promise => { + return findUser(username).then((user) => { + if (!user) { + throw new Error('User not found'); + } + return user.publicKeys || []; + }); +}; diff --git a/src/db/index.ts b/src/db/index.ts index 73e941e78..780f7617c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -83,5 +83,6 @@ export const { getSessionStore, addPublicKey, removePublicKey, + getPublicKeys, findUserBySSHKey, } = sink; diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index 6980c181e..b8a4983c6 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -58,3 +58,11 @@ export const findUserBySSHKey = async function (sshKey: string) { const collection = await connect(collectionName); return collection.findOne({ publicKeys: { $eq: sshKey } }); }; + +export const getPublicKeys = async function (username: string): Promise { + const user = await findUser(username); + if (!user) { + throw new Error('User not found'); + } + return user.publicKeys || []; +}; From e474edfd1120b430e7619636c15543f6a19a3597 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 28 May 2025 13:26:58 +0200 Subject: [PATCH 04/13] feat: add endpoints for retrieving SHA fingerprint and delete SHA by his fingerprint --- src/service/routes/users.js | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/service/routes/users.js b/src/service/routes/users.js index 0b64a0174..f56a7163d 100644 --- a/src/service/routes/users.js +++ b/src/service/routes/users.js @@ -1,3 +1,5 @@ +import crypto from 'node:crypto'; + const express = require('express'); const router = new express.Router(); const db = require('../../db'); @@ -100,4 +102,78 @@ router.delete('/:username/ssh-keys', async (req, res) => { } }); +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); + console.log(`Found ${keys} keys for user ${targetUsername}`); + const keyToDelete = keys.find((k) => { + const keyFingerprint = sshFingerprintSHA256(k); + return keyFingerprint === fingerprint; + }); + + if (!keyToDelete) { + return res.status(404).json({ error: 'SSH key not found for supplied fingerprint' }); + } + + await db.removePublicKey(targetUsername, keyToDelete); + 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' }); + } +}); + +// Utility: compute the fingerprint "SHA256:" +function sshFingerprintSHA256(pubKey) { + if (!pubKey) return ''; + + // OpenSSH keys are: " [comment]" + const b64 = pubKey.trim().split(/\s+/)[1]; + if (!b64) return ''; + + const raw = Buffer.from(b64, 'base64'); // raw key bytes + const hash = crypto.createHash('sha256').update(raw).digest('base64'); + + return 'SHA256:' + hash.replace(/=+$/, ''); +} + +// Return only fingerprints & metadata, +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((k) => sshFingerprintSHA256(k)); + + 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; From c017a787bac412de0ff41b5782cc3b78b84dc461 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 28 May 2025 13:27:38 +0200 Subject: [PATCH 05/13] feat: use endpoints in SSH UI --- .../SSHKeysManager/SSHKeysManager.jsx | 239 +++++++++--------- 1 file changed, 114 insertions(+), 125 deletions(-) diff --git a/src/ui/components/SSHKeysManager/SSHKeysManager.jsx b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx index 32cb7fcaa..b16e93bd6 100644 --- a/src/ui/components/SSHKeysManager/SSHKeysManager.jsx +++ b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx @@ -1,167 +1,155 @@ -import React, { useState } from 'react'; -import { makeStyles } from '@material-ui/core/styles'; +import React, { useState, useEffect, useCallback } from 'react'; import { Typography, Button, IconButton, Grid, Paper, Modal, TextField } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; import DeleteIcon from '@material-ui/icons/Delete'; import VpnKeyIcon from '@material-ui/icons/VpnKey'; import axios from 'axios'; const useStyles = makeStyles((theme) => ({ - root: { - padding: theme.spacing(3), - minHeight: 'auto', - width: '100%', - boxSizing: 'border-box', - margin: '0 auto', - }, + root: { padding: theme.spacing(3), width: '100%' }, button: { marginBottom: theme.spacing(2), backgroundColor: '#4caf50', color: 'white', - '&:hover': { - backgroundColor: '#388e3c', - }, - }, - deleteButton: { - color: '#ff4444', - }, - keyContainer: { - padding: theme.spacing(2), - borderRadius: '8px', - marginBottom: theme.spacing(2), - width: '100%', - }, - modal: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + '&: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: '8px', - width: '400px', - boxShadow: theme.shadows[5], - }, - formField: { - marginBottom: theme.spacing(2), + borderRadius: 8, + width: 400, }, + formField: { marginBottom: theme.spacing(2) }, })); +const API_BASE = `${import.meta.env.VITE_API_URI}/api/v1/user`; + export default function SSHKeysManager({ username }) { const classes = useStyles(); - const [keys, setKeys] = useState([ - { - name: 'macOS', - hash: 'SHA256:+s1qm8b66N1BQtVMWFeeTJb+QsJiJzxaswyO0lJ7kNw', - }, - { - name: 'dev', - hash: 'SHA256:RHNzb7j+QyoE/xrCZCc0IiQ8+XdAF8tEno/tZ1rzqF0', - }, - ]); + + const [keys, setKeys] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); - const [newKeyName, setNewKeyName] = useState(''); const [newKeyValue, setNewKeyValue] = useState(''); + /* ----------------------------------------------------------- + * Helper: fetch fingerprints from backend + * --------------------------------------------------------- */ + const loadKeys = useCallback(async () => { + if (!username) return; + try { + const res = await axios.get(`${API_BASE}/${username}/ssh-keys`, { + withCredentials: true, + }); + + const fingerprints = res.data.publicKeys || res.data.keys || []; + setKeys( + fingerprints.map((hash, i) => ({ + name: `key${i + 1}`, + hash, + })), + ); + } catch (err) { + console.error('Could not fetch SSH keys:', err); + } + }, [username]); + + /* Load keys on mount / username change */ + useEffect(() => { + loadKeys(); + }, [loadKeys]); + + /* ----------------------------------------------------------- + * Delete by fingerprint + * --------------------------------------------------------- */ const handleDelete = async (index) => { - const keyToRemove = keys[index].hash; + const { hash: fingerprint } = keys[index]; try { - await axios.delete(`/api/${username}/ssh-keys`, { - data: { publicKey: keyToRemove }, + await axios.delete(`${API_BASE}/${username}/ssh-keys/fingerprint`, { + data: { fingerprint }, + withCredentials: true, }); - setKeys(keys.filter((_, i) => i !== index)); - } catch (error) { - console.error('Failed to remove SSH key:', error); + await loadKeys(); + } catch (err) { + console.error('Failed to remove SSH key:', err); } }; + /* ----------------------------------------------------------- + * Add new public key, then refresh list + * --------------------------------------------------------- */ const handleAddKey = async () => { - if (newKeyName.trim() && newKeyValue.trim()) { - try { - await axios.post(`/api/${username}/ssh-keys`, { - publicKey: newKeyValue.trim(), - }); - setKeys([ - ...keys, - { - name: newKeyName.trim(), - hash: newKeyValue.trim(), - }, - ]); - setNewKeyName(''); - setNewKeyValue(''); - setIsModalOpen(false); - } catch (error) { - console.error('Failed to add SSH key:', error); - } + const trimmed = newKeyValue.trim(); + if (!trimmed) return; + + try { + await axios.post( + `${API_BASE}/${username}/ssh-keys`, + { publicKey: trimmed }, + { + withCredentials: true, + headers: { 'Content-Type': 'application/json' }, + }, + ); + await loadKeys(); // reload full list with new fingerprint + setNewKeyValue(''); + setIsModalOpen(false); + } catch (err) { + console.error('Failed to add SSH key:', err); } }; return (
- - - - SSH Keys - - - This is the list of SSH keys currently associated with your account. - - -
- {keys.map((key, index) => ( - - - - - - - - - {key.name} - - - - {key.hash} - - - - handleDelete(index)} - > - - - - - - ))} -
-
-
- - {/* Modal for adding a new SSH key */} + + SSH Keys + + + These are the SSH keys linked to your account. + + + + + {keys.map((key, idx) => ( + + + + + + + {key.name} + + + + + + + {key.hash} + + + + handleDelete(idx)}> + + + + + + ))} + + {/* Modal for new key input */} setIsModalOpen(false)} className={classes.modal}>
- Add New SSH Key + Add a new SSH key + setNewKeyName(e.target.value)} - /> - setNewKeyValue(e.target.value)} /> + From 0eca75bb13f381eaa0fe4a77c8af8e944a20fc32 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 28 May 2025 13:28:30 +0200 Subject: [PATCH 06/13] feat: add tab for SSH in CodeActionButton --- .../CustomButtons/CodeActionButton.jsx | 132 ++++++++++++------ 1 file changed, 89 insertions(+), 43 deletions(-) diff --git a/src/ui/components/CustomButtons/CodeActionButton.jsx b/src/ui/components/CustomButtons/CodeActionButton.jsx index 68c796316..85c90bfca 100644 --- a/src/ui/components/CustomButtons/CodeActionButton.jsx +++ b/src/ui/components/CustomButtons/CodeActionButton.jsx @@ -1,3 +1,4 @@ +import React, { useState } from 'react'; import Popper from '@material-ui/core/Popper'; import Paper from '@material-ui/core/Paper'; import { @@ -6,14 +7,29 @@ import { CodeIcon, CopyIcon, TerminalIcon, + KeyIcon, } from '@primer/octicons-react'; -import React, { useState } from 'react'; 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 getSSHUrl = () => { + try { + const urlObj = new URL(cloneURL); + const host = urlObj.host; + let path = urlObj.pathname; + if (path.startsWith('/')) path = path.substring(1); + return `git@${host}:${path}`; + } catch { + return cloneURL; + } + }; + + const selectedUrl = protocol === 'ssh' ? getSSHUrl() : cloneURL; const handleClick = (newPlacement) => (event) => { setIsCopied(false); @@ -22,6 +38,12 @@ const CodeActionButton = ({ cloneURL }) => { setPlacement(newPlacement); }; + const handleCopy = () => { + const command = `git clone ${selectedUrl}`; + navigator.clipboard.writeText(command); + setIsCopied(true); + }; + return ( <> { background: '#2da44e', borderRadius: '5px', color: 'white', - padding: '8px 10px 8px 10px', + padding: '8px 10px', fontWeight: 'bold', cursor: 'pointer', border: '1px solid rgba(240,246,252,0.1)', whiteSpace: 'nowrap', + display: 'inline-flex', + alignItems: 'center', }} onClick={handleClick('bottom-end')} > - {' '} - 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 👍 From 7f62e3bf3ab424e419ffc824146598e0f6f4de8a Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 28 May 2025 13:28:49 +0200 Subject: [PATCH 07/13] feat: add icons --- package-lock.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9255dc862..6a2b95844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-html-parser": "^2.0.2", + "react-icons": "^5.5.0", "react-router-dom": "6.28.2", "simple-git": "^3.25.0", "ssh2": "^1.16.0", @@ -11525,6 +11526,14 @@ "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", From 2d205d547622fcb6556ef8351eb1f20e34d66407 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 4 Jun 2025 16:27:15 +0200 Subject: [PATCH 08/13] fix: fetch ssh port to create the correct url --- src/config/index.ts | 7 +++ src/service/routes/config.js | 4 ++ .../CustomButtons/CodeActionButton.jsx | 46 ++++++++++++++----- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index b399069aa..be7544318 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -58,6 +58,13 @@ export const getSSHConfig = () => { } 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 = () => { diff --git a/src/service/routes/config.js b/src/service/routes/config.js index 82712ca48..dabdc41dd 100644 --- a/src/service/routes/config.js +++ b/src/service/routes/config.js @@ -15,4 +15,8 @@ router.get('/contactEmail', function ({ res }) { res.send(config.getContactEmail()); }); +router.get('/ssh', (req, res) => { + res.send(config.getPublicSSHConfig()); +}); + module.exports = router; diff --git a/src/ui/components/CustomButtons/CodeActionButton.jsx b/src/ui/components/CustomButtons/CodeActionButton.jsx index 85c90bfca..69c6dcfd8 100644 --- a/src/ui/components/CustomButtons/CodeActionButton.jsx +++ b/src/ui/components/CustomButtons/CodeActionButton.jsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; import Popper from '@material-ui/core/Popper'; import Paper from '@material-ui/core/Paper'; import { @@ -10,6 +11,8 @@ import { KeyIcon, } from '@primer/octicons-react'; +const API_BASE = import.meta.env.VITE_API_URI ?? ''; + const CodeActionButton = ({ cloneURL }) => { const [anchorEl, setAnchorEl] = useState(null); const [open, setOpen] = useState(false); @@ -17,13 +20,32 @@ const CodeActionButton = ({ cloneURL }) => { const [isCopied, setIsCopied] = useState(false); const [protocol, setProtocol] = useState('https'); + const [sshCfg, setSshCfg] = useState({ enabled: true, port: 22 }); + useEffect(() => { + axios + .get(`${API_BASE}/api/v1/config/ssh`, { withCredentials: true }) + .then((res) => { + const { enabled = true, port = 22 } = res.data || {}; + setSshCfg({ enabled, port }); + }) + .catch((err) => { + console.error('Failed to load SSH config:', err); + }); + }, []); + const getSSHUrl = () => { try { const urlObj = new URL(cloneURL); - const host = urlObj.host; + const host = urlObj.hostname; // hostname w/out any port let path = urlObj.pathname; - if (path.startsWith('/')) path = path.substring(1); - return `git@${host}:${path}`; + 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; } @@ -49,7 +71,7 @@ const CodeActionButton = ({ cloneURL }) => { { anchorEl={anchorEl} placement={placement} style={{ - border: '1px solid rgba(211, 211, 211, 0.3)', - borderRadius: '5px', - minWidth: '300px', - maxWidth: '450px', + border: '1px solid rgba(211,211,211,0.3)', + borderRadius: 5, + minWidth: 300, + maxWidth: 450, zIndex: 99, }} > @@ -126,8 +148,8 @@ const CodeActionButton = ({ cloneURL }) => { flex: 1, padding: '5px 8px', border: '1px solid gray', - borderRadius: '5px', - fontSize: '12px', + borderRadius: 5, + fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -135,7 +157,7 @@ const CodeActionButton = ({ cloneURL }) => { > git clone {selectedUrl}
-
+
{!isCopied ? ( From 6667d150d6f36513673bc1d092f66edbab59d00b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 28 Apr 2025 14:04:14 +0200 Subject: [PATCH 09/13] feat: adds ssh support for git operations --- .gitignore | 1 + README.md | 1 + config.schema.json | 30 + docs/SSH.md | 165 +++++ package-lock.json | 78 +- package.json | 4 +- packages/git-proxy-cli/index.js | 85 +++ proxy.config.json | 8 + src/chain/index.d.ts | 11 + src/cli/ssh-key.js | 122 ++++ src/config/index.d.ts | 4 + src/config/index.ts | 12 + src/config/types.ts | 9 + src/db/file/index.ts | 12 +- src/db/file/users.ts | 66 ++ src/db/index.d.ts | 4 + src/db/index.ts | 3 + src/db/mongo/index.ts | 11 +- src/db/mongo/users.ts | 27 + src/db/types.ts | 1 + src/proxy/actions/Action.ts | 1 + src/proxy/index.ts | 8 + .../processors/pre-processor/parseAction.ts | 1 + .../processors/push-action/pullRemote.ts | 57 +- src/proxy/routes/index.ts | 19 + src/proxy/ssh/server.js | 683 ++++++++++++++++++ src/service/routes/users.js | 73 +- test/ssh/sshServer.test.js | 341 +++++++++ 28 files changed, 1811 insertions(+), 26 deletions(-) create mode 100644 docs/SSH.md create mode 100644 src/chain/index.d.ts create mode 100644 src/cli/ssh-key.js create mode 100644 src/config/index.d.ts create mode 100644 src/db/index.d.ts create mode 100644 src/proxy/ssh/server.js create mode 100644 test/ssh/sshServer.test.js 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; + }); + }); +}); From a80b65b7669b2507d92a5703323a291b84bcb042 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Mon, 16 Jun 2025 14:31:24 +0200 Subject: [PATCH 10/13] chore: remove unused dependancy --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 20570b937..b6c32669c 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-html-parser": "^2.0.2", - "react-icons": "^5.5.0", "react-router-dom": "6.28.2", "simple-git": "^3.25.0", "ssh2": "^1.16.0", From ce5a80ae16358ca8e0b2c64d7139f8c428f622f0 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 9 Jul 2025 10:30:36 +0200 Subject: [PATCH 11/13] feat: add allert for bad ssh key --- package-lock.json | 38 ++++++++++---- package.json | 1 + src/service/routes/users.js | 44 +++++++---------- src/service/utils/ssh-utils.js | 12 +++++ .../SSHKeysManager/SSHKeysManager.jsx | 49 +++++++++++++++++-- 5 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 src/service/utils/ssh-utils.js diff --git a/package-lock.json b/package-lock.json index 8fa8b768d..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", @@ -50,7 +51,6 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-html-parser": "^2.0.2", - "react-icons": "^5.5.0", "react-router-dom": "6.28.2", "simple-git": "^3.25.0", "ssh2": "^1.16.0", @@ -2600,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", @@ -11694,14 +11722,6 @@ "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0" } }, - "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index e7d4b86c2..b0bf1a54e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/service/routes/users.js b/src/service/routes/users.js index f56a7163d..ac7fd5f13 100644 --- a/src/service/routes/users.js +++ b/src/service/routes/users.js @@ -1,4 +1,4 @@ -import crypto from 'node:crypto'; +const { normalisePublicKey, fingerprintSHA256 } = require('../utils/ssh-utils'); const express = require('express'); const router = new express.Router(); @@ -59,12 +59,15 @@ router.post('/:username/ssh-keys', async (req, res) => { return; } - // Strip the comment from the key (everything after the last space) - const keyWithoutComment = publicKey.split(' ').slice(0, 2).join(' '); + let canonicalKey; + try { + canonicalKey = normalisePublicKey(publicKey); + } catch { + return res.status(422).json({ error: 'Invalid SSH public key' }); + } - console.log('Adding SSH key', { targetUsername, keyWithoutComment }); try { - await db.addPublicKey(targetUsername, keyWithoutComment); + await db.addPublicKey(targetUsername, canonicalKey); res.status(201).json({ message: 'SSH key added successfully' }); } catch (error) { console.error('Error adding SSH key:', error); @@ -88,13 +91,17 @@ router.delete('/:username/ssh-keys', async (req, res) => { } const { publicKey } = req.body; - if (!publicKey) { - res.status(400).json({ error: 'Public key is required' }); - return; + 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, publicKey); + await db.removePublicKey(targetUsername, canonicalKey); res.status(200).json({ message: 'SSH key removed successfully' }); } catch (error) { console.error('Error removing SSH key:', error); @@ -122,7 +129,7 @@ router.delete('/:username/ssh-keys/fingerprint', async (req, res) => { const keys = await db.getPublicKeys(targetUsername); console.log(`Found ${keys} keys for user ${targetUsername}`); const keyToDelete = keys.find((k) => { - const keyFingerprint = sshFingerprintSHA256(k); + const keyFingerprint = fingerprintSHA256(k); return keyFingerprint === fingerprint; }); @@ -138,21 +145,6 @@ router.delete('/:username/ssh-keys/fingerprint', async (req, res) => { } }); -// Utility: compute the fingerprint "SHA256:" -function sshFingerprintSHA256(pubKey) { - if (!pubKey) return ''; - - // OpenSSH keys are: " [comment]" - const b64 = pubKey.trim().split(/\s+/)[1]; - if (!b64) return ''; - - const raw = Buffer.from(b64, 'base64'); // raw key bytes - const hash = crypto.createHash('sha256').update(raw).digest('base64'); - - return 'SHA256:' + hash.replace(/=+$/, ''); -} - -// Return only fingerprints & metadata, router.get('/:username/ssh-keys', async (req, res) => { if (!req.user) { return res.status(401).json({ error: 'Authentication required' }); @@ -167,7 +159,7 @@ router.get('/:username/ssh-keys', async (req, res) => { try { const keys = await db.getPublicKeys(targetUsername); - const result = keys.map((k) => sshFingerprintSHA256(k)); + const result = keys.map((k) => fingerprintSHA256(k)); res.status(200).json({ publicKeys: result }); } catch (err) { 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/SSHKeysManager/SSHKeysManager.jsx b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx index b16e93bd6..c276bec97 100644 --- a/src/ui/components/SSHKeysManager/SSHKeysManager.jsx +++ b/src/ui/components/SSHKeysManager/SSHKeysManager.jsx @@ -1,5 +1,15 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { Typography, Button, IconButton, Grid, Paper, Modal, TextField } from '@material-ui/core'; +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'; @@ -31,10 +41,11 @@ export default function SSHKeysManager({ username }) { const classes = useStyles(); const [keys, setKeys] = useState([]); - const [isModalOpen, setIsModalOpen] = useState(false); const [newKeyValue, setNewKeyValue] = useState(''); + const [banner, setBanner] = useState(null); + /* ----------------------------------------------------------- * Helper: fetch fingerprints from backend * --------------------------------------------------------- */ @@ -45,7 +56,7 @@ export default function SSHKeysManager({ username }) { withCredentials: true, }); - const fingerprints = res.data.publicKeys || res.data.keys || []; + const fingerprints = res.data.publicKeys || []; setKeys( fingerprints.map((hash, i) => ({ name: `key${i + 1}`, @@ -54,6 +65,7 @@ export default function SSHKeysManager({ username }) { ); } catch (err) { console.error('Could not fetch SSH keys:', err); + setBanner({ type: 'error', text: 'Failed to load SSH keys' }); } }, [username]); @@ -73,8 +85,13 @@ export default function SSHKeysManager({ username }) { withCredentials: true, }); await loadKeys(); + setBanner({ type: 'success', text: 'SSH key removed' }); } catch (err) { console.error('Failed to remove SSH key:', err); + setBanner({ + type: 'error', + text: err.response?.data?.error || 'Failed to remove SSH key', + }); } }; @@ -94,16 +111,39 @@ export default function SSHKeysManager({ username }) { headers: { 'Content-Type': 'application/json' }, }, ); - await loadKeys(); // reload full list with new fingerprint + await loadKeys(); + setBanner({ type: 'success', text: 'SSH key added' }); setNewKeyValue(''); setIsModalOpen(false); } catch (err) { console.error('Failed to add SSH key:', err); + setBanner({ + type: 'error', + text: err.response?.data?.error || 'Failed to add SSH key', + }); } }; return (
+ {/* -------- Snackbar banner -------- */} + setBanner(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + {banner && ( + setBanner(null)} + severity={banner.type === 'success' ? 'success' : 'error'} + variant='filled' + > + {banner.text} + + )} + + SSH Keys @@ -157,6 +197,7 @@ export default function SSHKeysManager({ username }) { className={classes.formField} value={newKeyValue} onChange={(e) => setNewKeyValue(e.target.value)} + placeholder='ssh-ed25519 AAAAC3Nz... user@example' />