diff --git a/package.json b/package.json index 244307a..c8fa84a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "cd docker && ./build.sh" }, "dependencies": { + "async-exit-hook": "^2.0.1", + "axios": "^0.18.0", "dockerode": "^2.5.8", "dotenv": "^6.2.0", "fs-extra": "^7.0.1", diff --git a/src/config.js b/src/config.js index 21a4e12..e462e8d 100644 --- a/src/config.js +++ b/src/config.js @@ -15,6 +15,7 @@ module.exports = { pass: VPN_PASS, imageName: IMAGE_NAME, dockerPrefix: DOCKER_PREFIX, + reqLimit: 1, proxy: { startsFrom: 5000, port: PORT diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..d5cd2e5 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,14 @@ +'use strict' + +const _ = require('lodash') +const axios = require('axios') + +exports.sample = (arr, num) => { + if (!num) return _.sample(arr) + return _.shuffle(arr).slice(0, num) +} + +exports.myIp = async () => { + const { data } = await axios('https://api.ipify.org/?format=json') + return data.ip +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..36bc97f --- /dev/null +++ b/src/index.js @@ -0,0 +1,181 @@ +'use strict' + +const Docker = require('dockerode') +const fs = require('fs-extra') +const http = require('http') +const httpProxy = require('http-proxy') +const exitHook = require('async-exit-hook') + +const config = require('./config') +const { sample, myIp } = require('./helpers') + +const docker = new Docker() +const proxy = httpProxy.createProxyServer() + +const state = { + allFiles: [], + publicIp: null +} + +const create = async (name, port, auth = {}) => { + if (!state.allFiles.find(n => n === name)) { + throw Error(`ovpn file ${name} not found.`) + } + + const created = await docker.createContainer({ + Image: config.imageName, + name: `${config.dockerPrefix}-${name}`, + AttachStdin: false, + AttachStdout: false, + AttachStderr: false, + StdinOnce: false, + OpenStdin: false, + Tty: true, + Cmd: ['supervisord', '-n'], + ExposedPorts: { + '8118/tcp': {} + }, + Volumes: { + '/ovpn': {} + }, + Env: [ + `VPN_USER=${auth.user || config.user}`, + `VPN_PASS=${auth.pass || config.pass}`, + `OVPN_FILE=${name}` + ], + HostConfig: { + PortBindings: { + '8118/tcp': [{ HostIp: '127.0.0.1', HostPort: String(port) }] + }, + Dns: ['8.8.8.8', '8.8.4.4'], + Binds: [`${__dirname}/../ovpn:/ovpn`], + CapAdd: ['NET_ADMIN'], + Devices: [{ + PathOnHost: '/dev/net/tun', + PathInContainer: '/dev/net/tun', + CgroupPermissions: 'rwm' + }] + } + }) + + return created +} + +const getContainers = async () => { + const all = await docker.listContainers({ all: true }) + return all.filter(c => c.Image === config.imageName) +} + +const renewVpn = async (name, retry) => { + console.log('renew VPN', name) + const vpn = state.vpns[name] + + const found = (await getContainers()).find(c => + c.Names[0] === `/${config.dockerPrefix}-${name}` + ) + const container = docker.getContainer(found.Id) + try { + await container.stop() + console.log('stopped', name, found.Id) + await container.remove({ force: true }) + console.log('removed', name, found.Id) + + const newVpn = sample(state.filtered) + const _newCont = await create(newVpn, vpn.port) + await _newCont.start() + + state.vpns[newVpn] = { + ...vpn, + name: newVpn, + ready: true, + count: 0 + } + + delete state.vpns[name] + console.log(newVpn, 'started', 'on port', vpn.port) + } catch (err) { + console.error(err.message) + + if (!retry) { + console.log('retrying...') + renewVpn(name, true) + } + } +} + +// Proxy server +const server = http.createServer((req, res) => { + const available = Object.keys(state.vpns) + .filter(vpn => state.vpns[vpn].ready) + const vpn = sample(available) + console.log({ vpn }, state.publicIp) + proxy.web(req, res, { + target: { + host: 'localhost', + port: state.vpns[vpn].port + } + }, e => console.error(e.message)) + + const last = ++state.vpns[vpn].count > config.reqLimit + if (last) state.vpns[vpn].ready = false + + res.on('finish', () => last && renewVpn(vpn)) +}) + +// Default regex for US and Chilean VPNs +const initialConfig = async (regex = '^(us|cl)') => { + state.allFiles = await fs.readdir('./ovpn') + state.filtered = state.allFiles + .filter(f => f.endsWith('ovpn')) + .filter(s => new RegExp(regex).test(s)) + + state.vpns = sample(state.filtered, 6).reduce((obj, vpn, i) => ({ + ...obj, + [vpn]: { + name: vpn, + port: config.proxy.startsFrom + i, + count: 0, + ready: false + } + }), {}) + + return state.vpns +} + +const main = async () => { + await initialConfig() + console.log('state.vpns', state.vpns) + + await Promise.all(Object.values(state.vpns).map(async vpn => { + const _newCont = await create(vpn.name, vpn.port) + await _newCont.start() + + state.vpns[vpn.name].ready = true + + console.log(vpn.name, 'started', 'on port', vpn.port) + })) + + console.log(state.vpns) + console.log('listening on port 5050') + server.listen(config.proxy.port) + state.publicIp = await myIp() + console.log('public ip', state.publicIp) +} + +exitHook(async callback => { + const containers = await getContainers() + + await Promise.all(containers.map(async ({ Id, Names }) => { + const container = docker.getContainer(Id) + try { + await container.remove({ force: true }) + console.log(Names[0], 'removed') + } catch (e) { + console.error(e.message) + } + })) + + callback() +}) + +main() diff --git a/yarn.lock b/yarn.lock index fbaa9c9..87b52ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -65,6 +65,17 @@ array-includes@^3.0.3: define-properties "^1.1.2" es-abstract "^1.7.0" +async-exit-hook@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3" + +axios@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102" + dependencies: + follow-redirects "^1.3.0" + is-buffer "^1.1.5" + babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -526,7 +537,7 @@ flat-cache@^1.2.1: rimraf "~2.6.2" write "^0.2.1" -follow-redirects@^1.0.0: +follow-redirects@^1.0.0, follow-redirects@^1.3.0: version "1.7.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76" dependencies: @@ -662,6 +673,10 @@ is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + is-callable@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75"