|
| 1 | +const chai = require('chai'); |
| 2 | +const sinon = require('sinon'); |
| 3 | +const expect = chai.expect; |
| 4 | +const fs = require('fs'); |
| 5 | +const ssh2 = require('ssh2'); |
| 6 | +const config = require('../../src/config'); |
| 7 | +const db = require('../../src/db'); |
| 8 | +const chain = require('../../src/proxy/chain'); |
| 9 | +const SSHServer = require('../../src/proxy/ssh/server'); |
| 10 | +const { execSync } = require('child_process'); |
| 11 | + |
| 12 | +describe('SSHServer', () => { |
| 13 | + let server; |
| 14 | + let mockConfig; |
| 15 | + let mockDb; |
| 16 | + let mockChain; |
| 17 | + let mockSsh2Server; |
| 18 | + let mockFs; |
| 19 | + const testKeysDir = 'test/keys'; |
| 20 | + let testKeyContent; |
| 21 | + |
| 22 | + before(() => { |
| 23 | + // Create directory for test keys |
| 24 | + if (!fs.existsSync(testKeysDir)) { |
| 25 | + fs.mkdirSync(testKeysDir, { recursive: true }); |
| 26 | + } |
| 27 | + // Generate test SSH key pair |
| 28 | + execSync(`ssh-keygen -t rsa -b 4096 -f ${testKeysDir}/test_key -N "" -C "test@git-proxy"`); |
| 29 | + // Read the key once and store it |
| 30 | + testKeyContent = fs.readFileSync(`${testKeysDir}/test_key`); |
| 31 | + }); |
| 32 | + |
| 33 | + after(() => { |
| 34 | + // Clean up test keys |
| 35 | + if (fs.existsSync(testKeysDir)) { |
| 36 | + fs.rmSync(testKeysDir, { recursive: true, force: true }); |
| 37 | + } |
| 38 | + }); |
| 39 | + |
| 40 | + beforeEach(() => { |
| 41 | + // Create stubs for all dependencies |
| 42 | + mockConfig = { |
| 43 | + getSSHConfig: sinon.stub().returns({ |
| 44 | + hostKey: { |
| 45 | + privateKeyPath: `${testKeysDir}/test_key`, |
| 46 | + publicKeyPath: `${testKeysDir}/test_key.pub`, |
| 47 | + }, |
| 48 | + port: 22, |
| 49 | + }), |
| 50 | + getProxyUrl: sinon.stub().returns('https://github.com'), |
| 51 | + }; |
| 52 | + |
| 53 | + mockDb = { |
| 54 | + findUserBySSHKey: sinon.stub(), |
| 55 | + findUser: sinon.stub(), |
| 56 | + }; |
| 57 | + |
| 58 | + mockChain = { |
| 59 | + executeChain: sinon.stub(), |
| 60 | + }; |
| 61 | + |
| 62 | + mockFs = { |
| 63 | + readFileSync: sinon.stub().callsFake((path) => { |
| 64 | + if (path === `${testKeysDir}/test_key`) { |
| 65 | + return testKeyContent; |
| 66 | + } |
| 67 | + return 'mock-key-data'; |
| 68 | + }), |
| 69 | + }; |
| 70 | + |
| 71 | + // Create a more complete mock for the SSH2 server |
| 72 | + mockSsh2Server = { |
| 73 | + Server: sinon.stub().returns({ |
| 74 | + listen: sinon.stub(), |
| 75 | + on: sinon.stub(), |
| 76 | + }), |
| 77 | + }; |
| 78 | + |
| 79 | + // Replace the real modules with our stubs |
| 80 | + sinon.stub(config, 'getSSHConfig').callsFake(mockConfig.getSSHConfig); |
| 81 | + sinon.stub(config, 'getProxyUrl').callsFake(mockConfig.getProxyUrl); |
| 82 | + sinon.stub(db, 'findUserBySSHKey').callsFake(mockDb.findUserBySSHKey); |
| 83 | + sinon.stub(db, 'findUser').callsFake(mockDb.findUser); |
| 84 | + sinon.stub(chain, 'executeChain').callsFake(mockChain.executeChain); |
| 85 | + sinon.stub(fs, 'readFileSync').callsFake(mockFs.readFileSync); |
| 86 | + sinon.stub(ssh2, 'Server').callsFake(mockSsh2Server.Server); |
| 87 | + |
| 88 | + server = new SSHServer(); |
| 89 | + }); |
| 90 | + |
| 91 | + afterEach(() => { |
| 92 | + // Restore all stubs |
| 93 | + sinon.restore(); |
| 94 | + }); |
| 95 | + |
| 96 | + describe('constructor', () => { |
| 97 | + it('should create a new SSH2 server with correct configuration', () => { |
| 98 | + expect(ssh2.Server.calledOnce).to.be.true; |
| 99 | + const serverConfig = ssh2.Server.firstCall.args[0]; |
| 100 | + expect(serverConfig.hostKeys).to.be.an('array'); |
| 101 | + expect(serverConfig.authMethods).to.deep.equal(['publickey', 'password']); |
| 102 | + expect(serverConfig.keepaliveInterval).to.equal(5000); |
| 103 | + expect(serverConfig.keepaliveCountMax).to.equal(10); |
| 104 | + expect(serverConfig.readyTimeout).to.equal(30000); |
| 105 | + }); |
| 106 | + }); |
| 107 | + |
| 108 | + describe('start', () => { |
| 109 | + it('should start listening on the configured port', () => { |
| 110 | + server.start(); |
| 111 | + expect(server.server.listen.calledWith(22, '0.0.0.0')).to.be.true; |
| 112 | + }); |
| 113 | + }); |
| 114 | + |
| 115 | + describe('handleClient', () => { |
| 116 | + let mockClient; |
| 117 | + |
| 118 | + beforeEach(() => { |
| 119 | + mockClient = { |
| 120 | + on: sinon.stub(), |
| 121 | + username: null, |
| 122 | + userPrivateKey: null, |
| 123 | + }; |
| 124 | + }); |
| 125 | + |
| 126 | + it('should set up client event handlers', () => { |
| 127 | + server.handleClient(mockClient); |
| 128 | + expect(mockClient.on.calledWith('error')).to.be.true; |
| 129 | + expect(mockClient.on.calledWith('end')).to.be.true; |
| 130 | + expect(mockClient.on.calledWith('close')).to.be.true; |
| 131 | + expect(mockClient.on.calledWith('global request')).to.be.true; |
| 132 | + expect(mockClient.on.calledWith('ready')).to.be.true; |
| 133 | + expect(mockClient.on.calledWith('authentication')).to.be.true; |
| 134 | + }); |
| 135 | + |
| 136 | + describe('authentication', () => { |
| 137 | + it('should handle public key authentication successfully', async () => { |
| 138 | + const mockCtx = { |
| 139 | + method: 'publickey', |
| 140 | + key: { |
| 141 | + algo: 'ssh-rsa', |
| 142 | + data: Buffer.from('mock-key-data'), |
| 143 | + comment: 'test-key', |
| 144 | + }, |
| 145 | + accept: sinon.stub(), |
| 146 | + reject: sinon.stub(), |
| 147 | + }; |
| 148 | + |
| 149 | + mockDb.findUserBySSHKey.resolves({ username: 'test-user' }); |
| 150 | + |
| 151 | + server.handleClient(mockClient); |
| 152 | + const authHandler = mockClient.on.withArgs('authentication').firstCall.args[1]; |
| 153 | + await authHandler(mockCtx); |
| 154 | + |
| 155 | + expect(mockDb.findUserBySSHKey.calledOnce).to.be.true; |
| 156 | + expect(mockCtx.accept.calledOnce).to.be.true; |
| 157 | + expect(mockClient.username).to.equal('test-user'); |
| 158 | + expect(mockClient.userPrivateKey).to.deep.equal(mockCtx.key); |
| 159 | + }); |
| 160 | + |
| 161 | + it('should handle password authentication successfully', async () => { |
| 162 | + const mockCtx = { |
| 163 | + method: 'password', |
| 164 | + username: 'test-user', |
| 165 | + password: 'test-password', |
| 166 | + accept: sinon.stub(), |
| 167 | + reject: sinon.stub(), |
| 168 | + }; |
| 169 | + |
| 170 | + mockDb.findUser.resolves({ |
| 171 | + username: 'test-user', |
| 172 | + password: '$2a$10$mockHash', |
| 173 | + }); |
| 174 | + |
| 175 | + const bcrypt = require('bcryptjs'); |
| 176 | + sinon.stub(bcrypt, 'compare').resolves(true); |
| 177 | + |
| 178 | + server.handleClient(mockClient); |
| 179 | + const authHandler = mockClient.on.withArgs('authentication').firstCall.args[1]; |
| 180 | + await authHandler(mockCtx); |
| 181 | + |
| 182 | + expect(mockDb.findUser.calledWith('test-user')).to.be.true; |
| 183 | + expect(bcrypt.compare.calledWith('test-password', '$2a$10$mockHash')).to.be.true; |
| 184 | + expect(mockCtx.accept.calledOnce).to.be.true; |
| 185 | + }); |
| 186 | + }); |
| 187 | + }); |
| 188 | + |
| 189 | + describe('handleSession', () => { |
| 190 | + let mockSession; |
| 191 | + let mockStream; |
| 192 | + let mockAccept; |
| 193 | + let mockReject; |
| 194 | + |
| 195 | + beforeEach(() => { |
| 196 | + mockStream = { |
| 197 | + write: sinon.stub(), |
| 198 | + end: sinon.stub(), |
| 199 | + exit: sinon.stub(), |
| 200 | + on: sinon.stub(), |
| 201 | + }; |
| 202 | + |
| 203 | + mockSession = { |
| 204 | + on: sinon.stub(), |
| 205 | + _channel: { |
| 206 | + _client: { |
| 207 | + userPrivateKey: null, |
| 208 | + }, |
| 209 | + }, |
| 210 | + }; |
| 211 | + |
| 212 | + mockAccept = sinon.stub().returns(mockSession); |
| 213 | + mockReject = sinon.stub(); |
| 214 | + }); |
| 215 | + |
| 216 | + it('should handle git-upload-pack command', async () => { |
| 217 | + const mockInfo = { |
| 218 | + command: "git-upload-pack 'test/repo'", |
| 219 | + }; |
| 220 | + |
| 221 | + mockChain.executeChain.resolves({ |
| 222 | + error: false, |
| 223 | + blocked: false, |
| 224 | + }); |
| 225 | + |
| 226 | + const { Client } = require('ssh2'); |
| 227 | + const mockSsh2Client = { |
| 228 | + on: sinon.stub(), |
| 229 | + connect: sinon.stub(), |
| 230 | + exec: sinon.stub(), |
| 231 | + }; |
| 232 | + |
| 233 | + // Mock the SSH client constructor |
| 234 | + sinon.stub(Client.prototype, 'on').callsFake(mockSsh2Client.on); |
| 235 | + sinon.stub(Client.prototype, 'connect').callsFake(mockSsh2Client.connect); |
| 236 | + sinon.stub(Client.prototype, 'exec').callsFake(mockSsh2Client.exec); |
| 237 | + |
| 238 | + // Mock the ready event |
| 239 | + mockSsh2Client.on.withArgs('ready').callsFake((event, callback) => { |
| 240 | + callback(); |
| 241 | + }); |
| 242 | + |
| 243 | + // Mock the exec response |
| 244 | + mockSsh2Client.exec.callsFake((command, options, callback) => { |
| 245 | + const mockStream = { |
| 246 | + on: sinon.stub(), |
| 247 | + write: sinon.stub(), |
| 248 | + end: sinon.stub(), |
| 249 | + }; |
| 250 | + callback(null, mockStream); |
| 251 | + }); |
| 252 | + |
| 253 | + server.handleSession(mockAccept, mockReject); |
| 254 | + const execHandler = mockSession.on.withArgs('exec').firstCall.args[1]; |
| 255 | + await execHandler(mockAccept, mockReject, mockInfo); |
| 256 | + |
| 257 | + expect( |
| 258 | + mockChain.executeChain.calledWith({ |
| 259 | + method: 'GET', |
| 260 | + originalUrl: " 'test/repo", |
| 261 | + isSSH: true, |
| 262 | + headers: { |
| 263 | + 'user-agent': 'git/2.0.0', |
| 264 | + 'content-type': undefined, |
| 265 | + }, |
| 266 | + }), |
| 267 | + ).to.be.true; |
| 268 | + }); |
| 269 | + |
| 270 | + it('should handle git-receive-pack command', async () => { |
| 271 | + const mockInfo = { |
| 272 | + command: "git-receive-pack 'test/repo'", |
| 273 | + }; |
| 274 | + |
| 275 | + mockChain.executeChain.resolves({ |
| 276 | + error: false, |
| 277 | + blocked: false, |
| 278 | + }); |
| 279 | + |
| 280 | + const { Client } = require('ssh2'); |
| 281 | + const mockSsh2Client = { |
| 282 | + on: sinon.stub(), |
| 283 | + connect: sinon.stub(), |
| 284 | + exec: sinon.stub(), |
| 285 | + }; |
| 286 | + sinon.stub(Client.prototype, 'on').callsFake(mockSsh2Client.on); |
| 287 | + sinon.stub(Client.prototype, 'connect').callsFake(mockSsh2Client.connect); |
| 288 | + sinon.stub(Client.prototype, 'exec').callsFake(mockSsh2Client.exec); |
| 289 | + |
| 290 | + server.handleSession(mockAccept, mockReject); |
| 291 | + const execHandler = mockSession.on.withArgs('exec').firstCall.args[1]; |
| 292 | + await execHandler(mockAccept, mockReject, mockInfo); |
| 293 | + |
| 294 | + expect( |
| 295 | + mockChain.executeChain.calledWith({ |
| 296 | + method: 'POST', |
| 297 | + originalUrl: " 'test/repo", |
| 298 | + isSSH: true, |
| 299 | + headers: { |
| 300 | + 'user-agent': 'git/2.0.0', |
| 301 | + 'content-type': 'application/x-git-receive-pack-request', |
| 302 | + }, |
| 303 | + }), |
| 304 | + ).to.be.true; |
| 305 | + }); |
| 306 | + |
| 307 | + it('should handle unsupported commands', async () => { |
| 308 | + const mockInfo = { |
| 309 | + command: 'unsupported-command', |
| 310 | + }; |
| 311 | + |
| 312 | + // Mock the stream that accept() returns |
| 313 | + mockStream = { |
| 314 | + write: sinon.stub(), |
| 315 | + end: sinon.stub(), |
| 316 | + }; |
| 317 | + |
| 318 | + // Mock the session |
| 319 | + const mockSession = { |
| 320 | + on: sinon.stub(), |
| 321 | + }; |
| 322 | + |
| 323 | + // Set up the exec handler |
| 324 | + mockSession.on.withArgs('exec').callsFake((event, handler) => { |
| 325 | + // First accept call returns the session |
| 326 | + // const sessionAccept = () => mockSession; |
| 327 | + // Second accept call returns the stream |
| 328 | + const streamAccept = () => mockStream; |
| 329 | + handler(streamAccept, mockReject, mockInfo); |
| 330 | + }); |
| 331 | + |
| 332 | + // Update mockAccept to return our mock session |
| 333 | + mockAccept = sinon.stub().returns(mockSession); |
| 334 | + |
| 335 | + server.handleSession(mockAccept, mockReject); |
| 336 | + |
| 337 | + expect(mockStream.write.calledWith('Unsupported command')).to.be.true; |
| 338 | + expect(mockStream.end.calledOnce).to.be.true; |
| 339 | + }); |
| 340 | + }); |
| 341 | +}); |
0 commit comments