diff --git a/.gitignore b/.gitignore index d1bed12..695f304 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ typings/ # next.js build output .next +id_rsa +id_rsa.pub +package-lock.json diff --git a/README.md b/README.md index d8a226a..fd3162b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -# git-http-mock-server +# git-http-mock-server / git-ssh-mock-server [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.amrom.workers.dev%2Fisomorphic-git%2Fgit-http-mock-server.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.amrom.workers.dev%2Fisomorphic-git%2Fgit-http-mock-server?ref=badge_shield) - -Clone and push to git repository test fixtures over HTTP. +Clone and push to git repository test fixtures over HTTP or SSH. ## What it does @@ -22,6 +21,12 @@ It also supports HTTP Basic Auth password protection of repos so you can test ho Using `isomorphic-git` and testing things from browsers? Fear not, `git-http-mock-server` includes appropriate CORS headers. +`git-ssh-mock-server` is similar, but because authentication happens before the client can say which repo +they are interested in, the authentication can't be customized per repository. +By default it allows anonymous SSH access. You can disable anonymous access and activate password authentication by setting the `GIT_SSH_MOCK_SERVER_PASSWORD` evironment variable. +(When password auth is activated, any username will work as long as the password matches the environment variable.) +Alternatively, you can set the `GIT_SSH_MOCK_SERVER_PUBKEY` environment variable to true to disable anonymous access and activate Public Key authentication. What key to use is explained in detail later in this document. + ## How to use ```sh @@ -44,6 +49,20 @@ Now in another shell, clone and push away... > git clone http://localhost:8174/imaginatively-named-repo.git ``` +To do the same thing but with SSH + +```sh +> cd __fixtures__ +> ls +test-repo1.git test-repo2.git imaginatively-named-repo.git +> git-ssh-mock-server +``` + +Now in another shell, +```sh +> git clone ssh://localhost:2222/imaginatively-named-repo.git +``` + ## Run in the background If you want to reuse the same shell (as part of a shell script, for example) @@ -58,14 +77,27 @@ you can run the server as a daemon in the background: Just be sure to run `start` and `stop` from the same working directory. (The `start` command writes the PID of the server to `./git-http-mock-server.pid` so that the `stop` command knows what process to kill.) +Same thing for SSH: + +```sh +> git-ssh-mock-server start +> # do stuff +> git-ssh-mock-server stop +``` + ### Environment Variables - `GIT_HTTP_MOCK_SERVER_PORT` default is 8174 (to be compatible with [git-http-server](https://github.com/bahamas10/node-git-http-server)) - `GIT_HTTP_MOCK_SERVER_ROUTE` default is `/` - `GIT_HTTP_MOCK_SERVER_ROOT` default is `process.cwd()` - `GIT_HTTP_MOCK_SERVER_ALLOW_ORIGIN` default is `*` (used for CORS) +- `GIT_SSH_MOCK_SERVER_PORT` default is 2222 +- `GIT_SSH_MOCK_SERVER_ROUTE` default is `/` +- `GIT_SSH_MOCK_SERVER_ROOT` default is `process.cwd()` +- `GIT_SSH_MOCK_SERVER_PASSWORD` activate Password Authentication and use this password (leave blank to allow anonymous SSH access.) +- `GIT_SSH_MOCK_SERVER_PUBKEY` activate PubKey Authentication using the self-generated keypair (leave blank to allow anonymous SSH access.) -### .htpasswd support +### .htpasswd support (http-only) You can place an Apache-style `.htpasswd` file in a bare repo to protect it with Basic Authentication. @@ -80,13 +112,30 @@ testuser:$apr1$BRdvH4Mu$3HrpeyBrWiS88GcSPidgq/ If you don't have `htpasswd` on your machine, you can use [htpasswd](https://npm.im/htpasswd) which is a cross-platform Node implementation of `htpasswd`. +### Public Key Auth support (ssh-only) + +`git-ssh-mock-server` generates its own keypair using the system's native `ssh-keygen` the first time it's run, +in order to create encrypted SSH connections. +This key can be used to authenticate with the server as well! + +1. Run `GIT_SSH_MOCK_SERVER_PUBKEY=true git-ssh-mock-server` +2. Try cloning (e.g. `git clone ssh://localhost:2222/imaginatively-named-repo.git`). It shouldn't work. +2. Run `git-ssh-mock-server exportKeys` which will copy the key files to `./id_rsa` and `./id_rsa.pub` in the working directory with the correct file permissions (`600`). +3. Run `ssh-add ./id_rsa` +4. Now try cloning. It works! +5. To clear the key from the ssh-agent, use `ssh-add -d ./id_rsa` + +You can use `GIT_SSH_MOCK_SERVER_PUBKEY` and `GIT_SSH_MOCK_SERVER_PASSWORD` together, but using either one disables anonymous SSH access. + ## Dependencies - [basic-auth](https://ghub.io/basic-auth): node.js basic auth parser +- [buffer-equal-constant-time](https://ghub.io/buffer-equal-constant-time): Constant-time comparison of Buffers - [chalk](https://ghub.io/chalk): Terminal string styling done right - [fixturez](https://ghub.io/fixturez): Easily create and maintain test fixtures in the file system - [git-http-backend](https://ghub.io/git-http-backend): serve a git repository over http - [htpasswd-js](https://ghub.io/htpasswd-js): Pure JS htpasswd authentication +- [ssh2](https://ghub.io/ssh2): SSH2 client and server modules written in pure JavaScript for node.js originally inspired by '[git-http-server](https://github.com/bahamas10/node-git-http-server)' @@ -94,9 +143,10 @@ originally inspired by '[git-http-server](https://github.com/bahamas10/node-git- MIT - [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.amrom.workers.dev%2Fisomorphic-git%2Fgit-http-mock-server.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.amrom.workers.dev%2Fisomorphic-git%2Fgit-http-mock-server?ref=badge_large) ## Changelog +1.2.0 - add SSH server +1.1.0 - support running in background and CORS headers 1.0.0 - Initial release \ No newline at end of file diff --git a/daemon.js b/daemon.js index 70fba75..59a5fd8 100644 --- a/daemon.js +++ b/daemon.js @@ -5,57 +5,57 @@ const {spawn} = require('child_process') const kill = require('tree-kill') const minimisted = require('minimisted') -const cmdName = 'git-http-mock-server' -const target = require.resolve('./bin.js') -const args = [ - target -] +module.exports = function (cmdName, target) { + const args = [ + target + ] -async function main({_: [cmd]}) { - switch (cmd) { - case 'start': { - require('daemonize-process')() - let server = spawn( - 'node', args, - { - stdio: 'inherit', - windowsHide: true, - } - ) - fs.writeFileSync( - path.join(process.cwd(), `${cmdName}.pid`), - String(process.pid), - 'utf8' - ) - process.on('exit', server.kill) - return - } - case 'stop': { - let pid - try { - pid = fs.readFileSync( + async function main({_: [cmd]}) { + switch (cmd) { + case 'start': { + require('daemonize-process')() + let server = spawn( + 'node', args, + { + stdio: 'inherit', + windowsHide: true, + } + ) + fs.writeFileSync( path.join(process.cwd(), `${cmdName}.pid`), + String(process.pid), 'utf8' - ); - } catch (err) { - console.log(`No ${cmdName}.pid file`) + ) + process.on('exit', server.kill) return } - pid = parseInt(pid) - console.log('killing', pid) - kill(pid, (err) => { - if (err) { - console.log(err) - } else { - fs.unlinkSync(path.join(process.cwd(), `${cmdName}.pid`)) + case 'stop': { + let pid + try { + pid = fs.readFileSync( + path.join(process.cwd(), `${cmdName}.pid`), + 'utf8' + ); + } catch (err) { + console.log(`No ${cmdName}.pid file`) + return } - }) - return - } - default: { - require(target) + pid = parseInt(pid) + console.log('killing', pid) + kill(pid, (err) => { + if (err) { + console.log(err) + } else { + fs.unlinkSync(path.join(process.cwd(), `${cmdName}.pid`)) + } + }) + return + } + default: { + require(target) + } } } -} - -minimisted(main) \ No newline at end of file + + minimisted(main) +} \ No newline at end of file diff --git a/http-daemon.js b/http-daemon.js new file mode 100755 index 0000000..2c03860 --- /dev/null +++ b/http-daemon.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +const cmdName = 'git-http-mock-server' +const target = require.resolve('./http-server.js') +require('./daemon.js')(cmdName, target) diff --git a/bin.js b/http-server.js old mode 100644 new mode 100755 similarity index 100% rename from bin.js rename to http-server.js diff --git a/package-lock.json b/package-lock.json index e1b4035..64b7885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -327,6 +327,14 @@ "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=" }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1212,6 +1220,11 @@ "integrity": "sha1-M3dm2hWAEhD92VbCLpxokaudAzc=", "dev": true }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", @@ -4057,6 +4070,11 @@ "ret": "~0.1.10" } }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "semantic-release": { "version": "15.1.7", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-15.1.7.tgz", @@ -4093,8 +4111,7 @@ "semver": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", - "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==", - "dev": true + "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==" }, "semver-diff": { "version": "2.1.0", @@ -4389,6 +4406,32 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "ssh-keygen": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/ssh-keygen/-/ssh-keygen-0.4.2.tgz", + "integrity": "sha512-SlEWW3cCtz87jwtCTfxo+tR+SQd4jJXWaBI/D9JVd74b2/N9ZvrWcd9lMFwFv0iMYb4aVAeMderH4AK5ZyW+Nw==", + "requires": { + "underscore": "1.4.x" + } + }, + "ssh2": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-0.6.1.tgz", + "integrity": "sha512-fNvocq+xetsaAZtBG/9Vhh0GDjw1jQeW7Uq/DPh4fVrJd0XxSfXAqBjOGVk4o2jyWHvyC6HiaPFpfHlR12coDw==", + "requires": { + "ssh2-streams": "~0.2.0" + } + }, + "ssh2-streams": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.2.1.tgz", + "integrity": "sha512-3zCOsmunh1JWgPshfhKmBCL3lUtHPoh+a/cyQ49Ft0Q0aF7xgN06b76L+oKtFi0fgO57FLjFztb1GlJcEZ4a3Q==", + "requires": { + "asn1": "~0.2.0", + "semver": "^5.1.0", + "streamsearch": "~0.1.2" + } + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -4420,6 +4463,11 @@ "readable-stream": "^2.0.2" } }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, "strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", @@ -4721,6 +4769,11 @@ } } }, + "underscore": { + "version": "1.4.4", + "resolved": "http://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=" + }, "union-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", diff --git a/package.json b/package.json index a34d63b..2389f3f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Clone and push to git repository test fixtures over HTTP.", "main": "index.js", "bin": { - "git-http-mock-server": "daemon.js" + "git-http-mock-server": "http-daemon.js", + "git-ssh-mock-server": "ssh-daemon.js" }, "scripts": { "test": "echo \"No tests\"", @@ -29,6 +30,7 @@ "homepage": "https://github.com/isomorphic-git/git-http-mock-server#readme", "dependencies": { "basic-auth": "^2.0.0", + "buffer-equal-constant-time": "^1.0.1", "chalk": "^2.4.1", "daemonize-process": "^1.0.9", "fixturez": "^1.1.0", @@ -36,7 +38,9 @@ "htpasswd-js": "^1.0.2", "micro-cors": "^0.1.1", "minimisted": "^2.0.0", - "tree-kill": "^1.2.0" + "tree-kill": "^1.2.0", + "ssh-keygen": "^0.4.2", + "ssh2": "^0.6.1" }, "devDependencies": { "semantic-release": "15.1.7", diff --git a/ssh-daemon.js b/ssh-daemon.js new file mode 100755 index 0000000..be5104a --- /dev/null +++ b/ssh-daemon.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +const cmdName = 'git-ssh-mock-server' +const target = require.resolve('./ssh-server.js') +require('./daemon.js')(cmdName, target) diff --git a/ssh-server.js b/ssh-server.js new file mode 100755 index 0000000..9cd2608 --- /dev/null +++ b/ssh-server.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +const { spawn, spawnSync } = require('child_process') +var crypto = require('crypto') +var fs = require('fs') +var path = require('path') + +var buffersEqual = require('buffer-equal-constant-time') +var fixturez = require('fixturez') +var ssh2 = require('ssh2') + +var config = { + root: path.resolve(process.cwd(), process.env.GIT_SSH_MOCK_SERVER_ROOT || '.'), + glob: '*', + route: process.env.GIT_SSH_MOCK_SERVER_ROUTE || '/' +} + +new Promise((resolve, reject) => { + try { + let key = fs.readFileSync(path.join(__dirname, 'id_rsa')) + let pubKey = fs.readFileSync(path.join(__dirname, 'id_rsa.pub')) + return resolve({key, pubKey}) + } catch (err) { + try { + // Note: PEM is to workaround https://github.com/mscdex/ssh2/issues/746 + let proc = spawnSync('ssh-keygen', ['-m', 'PEM', '-C', '"git-ssh-mock-server@localhost"', '-N', '""', '-f', 'id_rsa'], { + cwd: __dirname, + shell: true + }) + console.log(proc.stdout.toString('utf8')) + let key = fs.readFileSync(path.join(__dirname, 'id_rsa')) + let pubKey = fs.readFileSync(path.join(__dirname, 'id_rsa.pub')) + return resolve({key, pubKey}) + } catch (err) { + reject(err) + } + } +}) +.then(keypair => { + if (process.argv[2] === 'exportKeys') { + fs.writeFileSync(path.join(process.cwd(), 'id_rsa'), keypair.key, { mode: 0o600, flag: 'wx' }) + fs.writeFileSync(path.join(process.cwd(), 'id_rsa.pub'), keypair.pubKey, { mode: 0o600, flag: 'wx' }) + process.exit() + } + var pubKey = ssh2.utils.genPublicKey(ssh2.utils.parseKey(keypair.pubKey)) + var f = fixturez(config.root, {root: process.cwd(), glob: config.glob}) + + const PASSWORD_BUFFER = Buffer.from(process.env.GIT_SSH_MOCK_SERVER_PASSWORD || '') + + new ssh2.Server({ hostKeys: [keypair.key] }, function (client) { + console.log('client connected') + client + .on('authentication', function (ctx) { + if ( + ctx.method === 'none' && + !process.env.GIT_SSH_MOCK_SERVER_PASSWORD && + !process.env.GIT_SSH_MOCK_SERVER_PUBKEY + ) { + ctx.accept() + } else if ( + ctx.method === 'password' && + process.env.GIT_SSH_MOCK_SERVER_PASSWORD && + // After much thought... screw usernames. + buffersEqual(Buffer.from(ctx.password || ''), PASSWORD_BUFFER) + ) { + ctx.accept() + } else if ( + ctx.method === 'publickey' && + ctx.key.algo === pubKey.fulltype && + process.env.GIT_SSH_MOCK_SERVER_PUBKEY && + buffersEqual(ctx.key.data, pubKey.public) + ) { + if (ctx.signature) { + var verifier = crypto.createVerify(ctx.sigAlgo) + verifier.update(ctx.blob) + if (verifier.verify(pubKey.publicOrig, ctx.signature)) ctx.accept() + else ctx.reject() + } else { + // if no signature present, that means the client is just checking + // the validity of the given public key + ctx.accept() + } + } else { + ctx.reject() + } + }) + .on('ready', function () { + console.log('client authenticated') + + client.on('session', function (accept, reject) { + var session = accept() + session.once('exec', function (accept, reject, info) { + console.log(info.command) + let [_, command, gitdir] = info.command.match(/^([a-z-]+) '(.*)'/) + // Only allow these two commands to be executed + if (command !== 'git-upload-pack' && command !== 'git-receive-pack') { + console.log('invalid command:', command) + return reject() + } + if (gitdir !== path.posix.normalize(gitdir)) { + // something fishy about this filepath + console.log('suspicious file path:', gitdir) + return reject() + } + + // Do copy-on-write trick for git push + let fixtureName = path.posix.basename(gitdir) + let fulldir + if (command === 'git-upload-pack') { + fulldir = f.find(fixtureName) + } else if (command === 'git-receive-pack') { + fulldir = f.copy(fixtureName) + } + + try { + fs.accessSync(fulldir) + } catch (err) { + console.log(fulldir + ' does not exist.') + return reject() + } + + var stream = accept() + console.log('exec:', command, gitdir) + console.log('actual:', command, fulldir) + let proc = spawn(command, [fulldir]) + stream.exit(0) // always set a successful exit code + stream.pipe(proc.stdin) + proc.stdout.pipe(stream) + proc.stderr.pipe(stream.stderr) + }) + }) + }) + .on('end', function () { + console.log('client disconnected') + }) + } + ).listen(process.env.GIT_SSH_MOCK_SERVER_PORT || 2222, '127.0.0.1', function () { + console.log('Listening on port ' + this.address().port) + }) +})