Repository: mafintosh/torrent-docker Branch: master Commit: 79bbefb6220a Files: 17 Total size: 26.7 KB Directory structure: gitextract_ct1_8s_w/ ├── .gitignore ├── LICENSE ├── README.md ├── bin/ │ ├── boot.js │ ├── create.js │ ├── destroy.js │ ├── seed.js │ └── tracker.js ├── bin.js ├── docs/ │ ├── boot.txt │ ├── create.txt │ ├── destroy.txt │ ├── seed.txt │ └── tracker.txt ├── filesystem.js ├── help.txt └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ image.tar image.db image.db.tgz index.tgz node_modules mnt container *.torrent containers/* ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2014 Mathias Buus Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # torrent-docker MAD SCIENCE realtime boot of remote docker images using bittorrent ``` npm install -g torrent-docker torrent-docker --help ``` ## HOLD ON TO YOUR BRAIN Docker images are HUGE. A simple `hello world` node app easily takes up `> 600MB` space. Downloading/uploading these images can a looong time. To fix this `torrent-docker` implements a union file system that allows you to mount a docker image shared using bittorrent and boot a container - all in realtime! ![whoa](http://i.imgur.com/rfFWukr.gif) ## Usage ### Seed a docker image First create a docker image ``` FROM ubuntu:14.04 RUN apt-get update && apt-get install -qy curl vim ``` Then build it ``` docker build -t test-image . ``` Now all we need to do is create a torrent from the docker image ``` torrent-docker create test-image ``` This creates a file `test-image.torrent` and a data folder `test-image/`. Share this torrent using your favorite torrent client or do ``` torrent-docker seed test-image.torrent # will print a activity log ``` ### Realtime boot the docker image Now copy `test-image.torrent` to another machine. To boot the image do ``` torrent-docker boot test-image.torrent my-container ``` This will mount the torrent as a union file system (that is writable!) and boot the docker image. In addition it will also seed the torrent which means the more containers you boot the more the torrent will be seeded. You can attach to the debug log to see download speed, how many peers your are connected to, which files are being accessed etc using ``` nc localhost 10000 # will tail the debug log from the boot process ``` After a couple of seconds (depending on your internet connection, ymmw) you should be attached to a bash process running in your image! If for some reason your boot process cannot find a seeder you can specify them doing ``` torrent-docker boot test-image.torrent my-container --peer 128.199.33.21:6441 ``` Optionally you can start your own tracker ``` torrent-docker tracker --port 8080 torrent-docker boot test-image.torrent my-container --tracker 127.0.0.1:8080 ``` ## Dependencies On OSX you'll need the following * boot2docker, https://github.com/boot2docker/boot2docker * osx fuse, http://sourceforge.net/projects/osxfuse/files/latest/download?source=files * pkg-config, `brew install pkg-config` To make `/var`, `/etc` belong to root you need to run the following after installing boot2docker ``` boot2docker ssh sudo umount /Users sudo mount -t vboxsf Users /Users/ ``` You need to run this everytime you boot boot2docker ## Troubleshooting THIS IS HIGHLY EXPERIMENTAL. Currently I have only tested this on OSX using OSX fuse and boot2docker. ================================================ FILE: bin/boot.js ================================================ #!/usr/bin/env node var torrents = require('torrent-stream') var filesystem = require('../filesystem') var mkdirp = require('mkdirp') var fs = require('fs') var pretty = require('pretty-bytes') var proc = require('child_process') var net = require('net') var minimist = require('minimist') var argv = minimist(process.argv.slice(2), {alias:{peer:'p', tracker:'t', nomount:'n'}}) var trackers = argv.t && [].concat(argv.t) var torrent = argv._[0] var container = argv._[1] if (!torrent || !container || argv.help) { console.error(fs.readFileSync(__dirname+'/../docs/boot.txt', 'utf-8')) process.exit(1) } var noMount = [].concat(argv.nomount || []) var engine = torrents(fs.readFileSync(torrent), {trackers:trackers}) var mnt = container+'/mnt' var data = container+'/data' // TODO: remove me - this is the address of registry.mathiasbuus.eu - incase i forget for me demo // engine.swarm.add('128.199.33.21:6881') var peers = [].concat(argv.peer || []) peers.forEach(function(p) { engine.swarm.add(p) }) // engine.on('peer', function(peer) { // console.log(peer) // }) engine.files.forEach(function(f) { f.select() }) engine.listen(function() { console.log('engine is listening on port %d', engine.port) }) mkdirp.sync(mnt) container = fs.realpathSync(container) var sockets = [] var server = net.createServer(function(socket) { sockets.push(socket) socket.on('error', socket.destroy) socket.on('close', function() { var i = sockets.indexOf(socket) if (i > -1) sockets.splice(i, 1) }) }) var log = function() { var msg = require('util').format.apply(null, arguments) sockets.forEach(function(s) { s.write(msg+'\n') }) } setInterval(function() { log('down: %s/s, up: %s/s, peers: %d', pretty(engine.swarm.downloadSpeed()), pretty(engine.swarm.uploadSpeed()), engine.swarm.wires.length) }, 1000) server.listen(10000) server.once('error', function() { freeport(function(err, port) { if (err) throw err server.listen(port) }) }) server.on('listening', function() { console.log('mounting container drive here: '+container+'/mnt') if (noMount.length) console.log('not mounting: '+noMount.join(' ')) console.log('access log server by doing: nc localhost %d', server.address().port) console.log('downloading filesystem index...') filesystem(mnt, data, { createImageStream: function(opts) { return engine.files[0].createReadStream(opts) }, createIndexStream: function() { return engine.files[1].createReadStream() }, log: log, uid: argv.uid !== undefined ? Number(argv.uid) : process.getuid(), gid: argv.gid !== undefined ? Number(argv.gid) : process.getgid() }, function(err, fs) { if (err) throw err if (argv.docker === false) return console.log('torrent mounted...') console.log('filesystem index loaded. booting vm...') fs.readdir('/', function(err, files) { if (err) throw err files = files .filter(function(file) { return file !== '.' && file !== '..' && file !== 'proc' && file !== 'dev' && noMount.indexOf(file) === -1 }) .map(function(file) { return '-v '+container+'/mnt/'+file+':/'+file+' ' }) .join('').trim().split(/\s+/) var vars = [].concat(argv.e || []).concat(argv.env || []) var env = [] vars.forEach(function(v) { env.push('-e', v) }) var spawn = function() { proc.spawn('docker', ['run', '--net', argv.net || 'bridge', '-it', '--rm', '--entrypoint=/bin/bash'].concat(env).concat(files).concat('tianon/true'), {stdio:'inherit'}).on('exit', function() { process.exit() }) } var ns = new Buffer('nameserver 8.8.8.8\nnameserver 8.8.4.4\n') fs.open('/etc/resolv.conf', 1, function(err, fd) { if (err < 0) return spawn() fs.write('/etc/resolv.conf', 0, ns.length, ns, fd, function(err) { if (err < 0) return spawn() fs.release('/etc/resolv.conf', fd, spawn) }) }) }) }) }) ================================================ FILE: bin/create.js ================================================ #!/usr/bin/env node var docker = require('docker-remote-api') var zlib = require('zlib') var level = require('level') var mkdirp = require('mkdirp') var rimraf = require('rimraf') var fs = require('fs') var lexint = require('lexicographic-integer') var tar = require('tar-stream') var tarfs = require('tar-fs') var createTorrent = require('create-torrent') var minimist = require('minimist') var argv = minimist(process.argv.slice(2), {alias:{'announce-list':'a'}}) var image = argv._[0] var announceList = argv.a && [].concat(argv.a) if (!image || argv.help) { console.error(fs.readFileSync(__dirname+'/../docs/create.txt', 'utf-8')) process.exit(1) } var request = docker() var getImage = function(cb) { request.post('/containers/create', { json: { Image: image } }, function(err, c) { if (err) return cb(err) request.get('/containers/'+c.Id+'/export', function(err, stream) { if (err) return cb(err) stream.on('end', function() { request.del('/containers/'+c.Id) }) cb(null, stream, c.Id) }) }) } var dir = image.replace(/[\/:]/g, '-') var toIndexKey = function(name) { var depth = name.split('/').length-1 return lexint.pack(depth, 'hex')+name } console.log('creating a torrent for %s', image) getImage(function(err, stream, id) { if (err) throw err console.log('exporting docker image layer') mkdirp(dir, function(err) { if (err) throw err stream.pipe(fs.createWriteStream(dir+'/image.tar')).on('finish', function() { console.log('indexing image layer') var db = level(dir+'/index') fs.createReadStream(dir+'/image.tar').pipe(tar.extract()) .on('entry', function(header, stream, next) { header.name = ('/' + header.name).replace('//', '/').replace(/(.)\/$/, '$1') stream.resume() var entry = { key: toIndexKey(header.name), value: { name: header.name, mode: header.mode, type: header.type, start: stream.offset, size: header.size, linkname: header.linkname } } db.put(entry.key, entry.value, {valueEncoding:'json'}, next) }) .on('finish', function() { tarfs.pack(dir+'/index').pipe(zlib.createGzip()).pipe(fs.createWriteStream(dir+'/index.tgz')).on('finish', function() { rimraf(dir+'/index', function() { opts = {} if (!!announceList) { opts.announceList = [announceList] } console.log('generating torrent file') createTorrent(dir, opts, function(err, buf) { if (err) throw err fs.writeFile(dir+'.torrent', buf, function(err) { if (err) throw err console.log('torrent created and written to '+dir+'.torrent') }) }) }) }) }) }) }) }) ================================================ FILE: bin/destroy.js ================================================ #!/usr/bin/env node var fuse = require('fuse-bindings') var rimraf = require('rimraf') var fs = require('fs') var minimist = require('minimist') var argv = minimist(process.argv.slice(2)) var name = argv._[0] if (!name || argv.help) { console.error(fs.readFileSync(__dirname+'/../docs/destroy.txt', 'utf-8')) process.exit(0) } fuse.unmount(name+'/mnt', function() { rimraf.sync(name) }) ================================================ FILE: bin/seed.js ================================================ #!/usr/bin/env node var torrents = require('torrent-stream') var pretty = require('pretty-bytes') var path = require('path') var mkdirp = require('mkdirp') var fs = require('fs') var minimist = require('minimist') var argv = minimist(process.argv.slice(2), {alias:{peer:'p', tracker:'t', nomount:'n'}}) var trackers = argv.t && [].concat(argv.t) var torrent = argv._[0] if (!torrent || argv.help) { console.error(fs.readFileSync(__dirname+'/../docs/seed.txt', 'utf-8')) process.exit(1) } var engine = torrents(fs.readFileSync(torrent), { path: path.dirname(torrent), trackers: trackers }) var peers = [].concat(argv.peer || []) peers.forEach(function(p) { engine.swarm.add(p) }) var peers = 0 engine.on('peer', function(peer) { peers++ }) engine.files.forEach(function(f) { f.select() }) engine.listen(function() { console.log('seeding on port %d', engine.port) setInterval(function() { console.log('connected to %d peers. found %d in total. upload: %s', engine.swarm.wires.length, peers, pretty(engine.swarm.uploadSpeed())) }, 1000) }) ================================================ FILE: bin/tracker.js ================================================ #!/usr/bin/env node var minimist = require('minimist') var tracker = require('bittorrent-tracker/server') var fs = require('fs') var argv = minimist(process.argv.slice(2)) if (argv.help) { console.error(fs.readFileSync(__dirname+'/../docs/tracker.txt', 'utf-8')) process.exit(1) } var server = tracker() server.on('warning', function (err) { // client sent bad data. probably not a problem, just a buggy client. console.log(err.message) }) server.on('listening', function (port) { console.log('tracker server is now listening on ' + port) }) // listen for individual tracker messages from peers: server.on('start', function (addr) { console.log('got start message from ' + addr) }) server.on('complete', function (addr) { console.log('got complete message from '+addr) }) server.on('update', function (addr) { console.log('got update message from '+addr) }) server.on('stop', function (addr) { console.log('got stop message from '+addr) }) // start tracker server listening! server.listen(argv.port || 80) ================================================ FILE: bin.js ================================================ #!/usr/bin/env node var cmd = process.argv[2] var fs = require('fs') process.argv.splice(2, 1) if (cmd === 'seed') require('./bin/seed') else if (cmd === 'create') require('./bin/create') else if (cmd === 'run' || cmd === 'boot') require('./bin/boot') else if (cmd === 'destroy') require('./bin/destroy') else if (cmd === 'tracker') require('./bin/tracker') else console.log(fs.readFileSync(__dirname+'/help.txt', 'utf-8')) ================================================ FILE: docs/boot.txt ================================================ Usage: torrent-docker boot [torrent-file] [container-name] Starts a new docker containers based on the image shared by the torrent file. The container state will be stored in ./container-name and the union file system mounted in ./container-name/mnt --peer,-p to force connect to a peer --tracker,-t to add additional trackers --net to set the docker --net option --no-docker do not boot the docker container - only mount the filesystem --nomount do not mount this folder from the torrent --env,-e set an env var NAME=VALUE You can attach to the debug log by following the instruction printed out when booting this container (usualy nc localhost 10000) ================================================ FILE: docs/create.txt ================================================ Usage: torrent-docker create [image-name] Creates a new torrent from an docker image. The torrent file will be saved in ./image-name.torrent and the torrent content will be stored in ./image-name/ --announce-list, -a List of trackers for the torrent (comma separated) ================================================ FILE: docs/destroy.txt ================================================ Usage: torrent-docker destroy [container-name] Completely removes a local container ================================================ FILE: docs/seed.txt ================================================ Usage: torrent-docker seed [torrent-file] Seeds a docker image torrent. You can also seed the torrent using your favorite torrent client --peer,-p to force connect to a peer --tracker,-t to add additional trackers ================================================ FILE: docs/tracker.txt ================================================ Usage: torrent-docker tracker Start a torrent tracker. Pass an address to this tracker to the seed and boot command using --tracker [addr] --port, -p Port to listen on. Defaults to 80 ================================================ FILE: filesystem.js ================================================ var fuse = require('fuse-bindings') var fs = require('fs') var collect = require('stream-collector') var p = require('path') var os = require('os') var pump = require('pump') var cuid = require('cuid') var mkdirp = require('mkdirp') var lexint = require('lexicographic-integer') var level = require('level') var tar = require('tar-fs') var zlib = require('zlib') var shasum = require('shasum') var stream = require('stream') var ENOENT = -2 var EPERM = -1 var EINVAL = -22 var toIndexKey = function(name) { var depth = name.split('/').length-1 return lexint.pack(depth, 'hex')+name } var empty = function() { var p = new stream.PassThrough() p.end() return p } module.exports = function(mnt, container, opts, cb) { if (typeof opts === 'function') return module.exports(mnt, container, null, opts) if (!opts) opts = {} var dmode = 0 var fmode = 0 var log = opts.log || function() {} if (opts.readable) { dmode |= 0555 fmode |= 0444 } if (opts.writable) { dmode |= 0333 fmode |= 0222 } var handlers = {} var store = container var createImageStream = opts.createImageStream || empty var createIndexStream = opts.createIndexStream || empty var createReadStream = function(entry, offset) { var end = entry.start + entry.size - 1 var start = entry.start+offset if (end < start) return empty() if (!entry.size) return empty() return createImageStream({start:start, end:end}) } var ready = function() { var db = level(p.join(store, 'db')) var get = function(path, cb) { if (path === '/') return cb(null, {name:'/', mode: 0755, type:'directory'}) db.get(toIndexKey(path), {valueEncoding:'json'}, function(err, entry) { if (err) return cb(err) if (entry.type === 'symlink') return get(p.resolve(p.dirname(path), entry.linkname), cb) if (!entry.layer) return cb(null, entry) fs.stat(entry.layer, function(err, stat) { entry.size = stat ? stat.size : 0 cb(null, entry) }) }) } handlers.getattr = function(path, cb) { log('getattr', path) get(path, function(err, entry) { if (err) return cb(ENOENT) var stat = {} if (opts.uid !== undefined) stat.uid = opts.uid if (opts.gid !== undefined) stat.gid = opts.gid if (entry.type === 'file') { stat.size = entry.size stat.mode = 0100000 | entry.mode | fmode return cb(0, stat) } stat.size = 4096 stat.mode = 040000 | entry.mode | dmode return cb(0, stat) }) } handlers.readdir = function(path, cb) { log('readdir', path) if (!/\/$/.test(path)) path += '/' var prefix = toIndexKey(path) var rs = db.createReadStream({ gte: prefix, lt: prefix+'\xff', valueEncoding: 'json' }) collect(rs, function(err, entries) { if (err) return cb(ENOENT) var files = entries.map(function(entry) { return p.basename(entry.value.name) }) cb(0, files) }) } var files = [] var toFlag = function(flags) { flags = flags & 3 if (flags === 0) return 'r' if (flags === 1) return 'w' return 'r+' } var open = function(path, flags, cb) { var push = function(data) { var list = files[path] = files[path] || [true, true, true] // fd > 3 var fd = list.indexOf(null) if (fd === -1) fd = list.length list[fd] = data cb(0, fd) } get(path, function(err, entry) { if (err) return cb(ENOENT) if (entry.type !== 'file') return cb(EINVAL) if (!entry.layer) return push({offset:0, entry:entry}) fs.open(entry.layer, toFlag(flags), function(err, fd) { if (err) return cb(EPERM) push({fd:fd, entry:entry}) }) }) } var copyOnWrite = function(path, mode, upsert, cb) { log('copy-on-write', path) var target = p.join(store, 'layer', shasum(path+'-'+Date.now())) var done = function(entry) { db.put(toIndexKey(entry.name), entry, {valueEncoding:'json'}, function(err) { if (err) return cb(EPERM) cb(0) }) } var create = function() { var entry = {name:path, size:0, type:'file', mode:mode, layer:target} fs.writeFile(target, '', function(err) { if (err) return cb(EPERM) done(entry) }) } get(path, function(err, entry) { if (entry && entry.layer) return cb(0) if (!entry && upsert) return create() if (!entry) return cb(ENOENT) entry.layer = target if (mode) entry.mode = mode pump(createReadStream(entry, 0), fs.createWriteStream(target), function(err) { if (err) return cb(EPERM) done(entry) }) }) } handlers.open = function(path, flags, cb) { log('open', path, flags) if (flags === 0) return open(path, flags, cb) copyOnWrite(path, 0, false, function(err) { if (err) return cb(err) open(path, flags, cb) }) } handlers.release = function(path, handle, cb) { log('release', path, handle) var list = files[path] || [] var file = list[handle] if (!file) return cb(ENOENT) if (file.stream) file.stream.destroy() list[handle] = null if (!list.length) delete files[path] if (file.fd === undefined) return cb(0) fs.close(file.fd, function(err) { if (err) return cb(EPERM) cb(0) }) } handlers.read = function(path, handle, buf, len, offset, cb) { log('read', path, offset, len, handle) var list = files[path] || [] var file = list[handle] if (!file) return cb(ENOENT) if (len + offset > file.entry.size) len = file.entry.size - offset; if (file.fd !== undefined) { fs.read(file.fd, buf, 0, len, offset, function(err, bytes) { if (err) return cb(EPERM) cb(bytes) }) return } if (file.stream && file.offset !== offset) { file.stream.destroy() file.stream = null } if (!file.stream) { file.stream = createReadStream(file.entry, offset) file.offset = offset } var loop = function() { var result = file.stream.read(len) if (!result) return file.stream.once('readable', loop) file.offset += len result.copy(buf) cb(result.length) } loop() } handlers.truncate = function(path, size, cb) { log('truncate', path, size) copyOnWrite(path, 0, false, function(err) { if (err) return cb(err) get(path, function(err, entry) { if (err || !entry.layer) return cb(EPERM) fs.truncate(entry.layer, size, function(err) { if (err) return cb(EPERM) cb(0) }) }) }) } handlers.write = function(path, handle, buf, len, offset, cb) { log('write', path, offset, len, handle) var list = files[path] || [] var file = list[handle] if (!file) return cb(ENOENT) if (file.fd === undefined) { return cb(EPERM) } fs.write(file.fd, buf, 0, len, offset, function(err, bytes) { if (err) return cb(EPERM) cb(bytes) }) } handlers.unlink = function(path, cb) { log('unlink', path) get(path, function(err, entry) { if (!entry) return cb(ENOENT) db.del(toIndexKey(path), function() { if (!entry.layer) return cb(0) fs.unlink(p.join(store, 'layer', entry.layer), function() { cb(0) }) }) }) } handlers.rename = function(src, dst, cb) { log('rename', src, dst) copyOnWrite(src, 0, false, function(err) { if (err) return cb(err) get(src, function(err, entry) { if (err || !entry.layer) return cb(EPERM) var batch = [{type:'del', key:toIndexKey(entry.name)}, {type:'put', key:toIndexKey(dst), valueEncoding:'json', value:entry}] entry.name = dst db.batch(batch, function(err) { if (err) return cb(EPERM) cb(0) }) }) }) } handlers.mkdir = function(path, mode, cb) { log('mkdir', path) db.put(toIndexKey(path), {name:path, mode:mode, type:'directory', size:0}, {valueEncoding:'json'}, function(err) { if (err) return cb(EPERM) cb(0) }) } handlers.rmdir = function(path, cb) { log('rmdir', path) handlers.readdir(path, function(err, list) { if (err) return cb(EPERM) if (list.length) return cb(EPERM) handlers.unlink(path, cb) }) } handlers.chown = function() { console.error('chown is not implemented') } handlers.chmod = function(path, mode, cb) { log('chmod', path, mode) get(path, function(err, entry) { if (err) return cb(err) entry.mode = mode db.put(toIndexKey(path), entry, {valueEncoding:'json'}, function(err) { if (err) return cb(EPERM) cb(0) }) }) } handlers.create = function(path, mode, cb) { log('create', path, mode) copyOnWrite(path, mode, true, function(err) { if (err) return cb(err) open(path, 1, cb) }) } handlers.getxattr = function(path, name, buffer, length, offset, cb) { log('getxattr') cb(EPERM) } handlers.setxattr = function(path, name, buffer, length, offset, flags, cb) { log('setxattr') cb(0) } handlers.statfs = function(path, cb) { cb(0, { bsize: 1000000, frsize: 1000000, blocks: 1000000, bfree: 1000000, bavail: 1000000, files: 1000000, ffree: 1000000, favail: 1000000, fsid: 1000000, flag: 1000000, namemax: 1000000 }) } handlers.destroy = function(cb) { cb() } fuse.mount(mnt, handlers, function (err) { if (err) return cb(err) cb(null, handlers) }) } fs.exists(p.join(store, 'db'), function(exists) { if (exists) return fuse.unmount(mnt, ready) mkdirp(p.join(store, 'layer'), function() { pump( createIndexStream(), zlib.createGunzip(), tar.extract(p.join(store, 'db')), function() { fuse.unmount(mnt, ready) } ) }) }) } ================================================ FILE: help.txt ================================================ Usage: torrent-docker [cmd] Available commands are create create a new docker torrent seed seed a docker torrent boot boot a container from a torrent destroy destroy a container tracker start a tracker Add --help after any command for detailed help ================================================ FILE: package.json ================================================ { "name": "torrent-docker", "version": "1.6.0", "description": "MAD SCIENCE realtime boot of remote docker images using bittorrent", "main": "bin.js", "bin": { "torrent-docker": "./bin.js" }, "author": "Mathias Buus (@mafintosh)", "license": "MIT", "dependencies": { "bittorrent-tracker": "^2.7.0", "create-torrent": "^3.2.0", "cuid": "^1.2.4", "docker-remote-api": "^4.4.0", "docker-run": "^2.0.0", "duplexify": "^3.2.0", "freeport": "^1.0.3", "fuse-bindings": "^2.1.2", "level": "^0.18.0", "lexicographic-integer": "^1.1.0", "minimist": "^1.1.0", "mkdirp": "^0.5.0", "pretty-bytes": "^1.0.1", "pump": "^1.0.0", "rimraf": "^2.2.8", "shasum": "^1.0.0", "stream-collector": "^1.0.1", "tar-fs": "^1.2.0", "tar-stream": "^1.1.1", "through2": "^0.6.3", "torrent-stream": "^0.18.1" }, "devDependencies": {}, "repository": { "type": "git", "url": "https://github.com/mafintosh/torrent-docker.git" }, "bugs": { "url": "https://github.com/mafintosh/torrent-docker/issues" }, "homepage": "https://github.com/mafintosh/torrent-docker" }