From a60d3fcc9d5ee0cec1ec0eba64afa47b0afa8b93 Mon Sep 17 00:00:00 2001 From: Micooz Date: Sat, 17 Feb 2018 22:10:06 +0800 Subject: [PATCH] core: re-implement ACL in core --- bin/init.js | 4 + src/core/acl.js | 366 +++++++++++++++++++++++++++++++++++ src/core/config.js | 59 ++++-- src/core/relay.js | 54 ++++-- src/presets/actions.js | 7 - src/transports/tcp.js | 56 ++---- test/src/core/config.test.js | 8 - 7 files changed, 467 insertions(+), 87 deletions(-) create mode 100644 src/core/acl.js diff --git a/bin/init.js b/bin/init.js index 5e8d31b..5b4c9d6 100644 --- a/bin/init.js +++ b/bin/init.js @@ -74,6 +74,8 @@ module.exports = function init({ isMinimal, isOverwrite }) { ], 'tls_key': 'key.pem', 'tls_cert': 'cert.pem', + 'acl': true, + 'acl_conf': 'acl.txt', 'mux': false, 'dns': [], 'dns_expire': 3600, @@ -87,6 +89,8 @@ module.exports = function init({ isMinimal, isOverwrite }) { if (isMinimal) { delete serverJson.tls_key; delete serverJson.tls_cert; + delete serverJson.acl; + delete serverJson.acl_conf; delete serverJson.mux; delete serverJson.dns; delete serverJson.dns_expire; diff --git a/src/core/acl.js b/src/core/acl.js new file mode 100644 index 0000000..0413902 --- /dev/null +++ b/src/core/acl.js @@ -0,0 +1,366 @@ +import fs from 'fs'; +import net from 'net'; +import EventEmitter from 'events'; +import readline from 'readline'; +import ip from 'ip'; +import { PIPE_ENCODE } from '../constants'; +import { logger, isValidHostname, isValidPort } from '../utils'; + +export const ACL_CLOSE_CONNECTION = 'acl_close_connection'; +export const ACL_PAUSE_RECV = 'acl_pause_recv'; +export const ACL_PAUSE_SEND = 'acl_pause_send'; +export const ACL_RESUME_RECV = 'acl_resume_recv'; +export const ACL_RESUME_SEND = 'acl_resume_send'; + +// rule's methods + +function ruleIsMatch(host, port) { + const { host: rHost, port: rPort } = this; + const slashIndex = rHost.indexOf('/'); + + let isHostMatch = false; + if (slashIndex !== -1 && net.isIP(host)) { + isHostMatch = ip.cidrSubnet(rHost).contains(host); + } else { + isHostMatch = (rHost === host); + } + + if (rHost === '*' || isHostMatch) { + if (rPort === '*' || port === rPort) { + return true; + } + } + return false; +} + +function ruleToString() { + return `${this.host}:${this.port} ${this.isBan ? 1 : 0} ${this.upLimit} ${this.dlLimit}`; +} + +// rule parsing + +function parseHost(host) { + const slashIndex = host.indexOf('/'); + if (slashIndex < 0) { + if (host !== '*' && !net.isIP(host) && !isValidHostname(host)) { + return null; + } + return host; + } + if (slashIndex < 7) { + return null; + } + const parts = host.split('/'); + const ip = parts[0]; + const mask = parts[parts.length - 1]; + if (!net.isIP(ip)) { + return null; + } + if (mask === '' || !Number.isInteger(+mask) || +mask < 0 || +mask > 32) { + return null; + } + return host; +} + +function parseSpeed(speed) { + const regex = /^(\d+)(b|k|kb|m|mb|g|gb)$/g; + const results = regex.exec(speed.toLowerCase()); + if (results !== null) { + const [, num, unit] = results; + return +num * { + 'b': 1, + 'k': 1024, + 'kb': 1024, + 'm': 1048576, + 'mb': 1048576, + 'g': 1073741824, + 'gb': 1073741824, + }[unit]; + } + return null; +} + +function parseLine(line) { + if (line.length > 300) { + return null; + } + line = line.trim(); + if (line.length < 1) { + return null; + } + if (line[0] === '#') { + return null; + } + if (line.indexOf('#') > 0) { + line = line.substr(0, line.indexOf('#')); + } + const [addr, ban, up, dl] = line.split(' ').filter(p => p.length > 0); + + let _host = null; + let _port = null; + let _isBan = false; + let _upLimit = '-'; + let _dlLimit = '-'; + + // [addr[/mask][:port]] + if (addr.indexOf(':') > 0) { + const parts = addr.split(':'); + const host = parts[0]; + const port = parts[parts.length - 1]; + _host = parseHost(host); + if (port !== '*') { + if (!isValidPort(+port)) { + return null; + } + _port = +port; + } else { + _port = port; + } + } else { + _host = parseHost(addr); + _port = '*'; + } + + if (_host === null) { + return null; + } + + // [ban] + if (ban !== undefined) { + if (ban !== '0' && ban !== '1') { + return null; + } + _isBan = ban !== '0'; + } + + // [max_upload_speed(/s)] + if (up !== undefined && up !== '-') { + _upLimit = parseSpeed(up); + if (!_upLimit) { + return null; + } + } + + // [max_download_speed(/s)] + if (dl !== undefined && dl !== '-') { + _dlLimit = parseSpeed(dl); + if (!_dlLimit) { + return null; + } + } + + return { + host: _host, + port: _port, + isBan: _isBan, + upLimit: _upLimit, + dlLimit: _dlLimit, + isMatch: ruleIsMatch, + toString: ruleToString, + }; +} + +const DEFAULT_MAX_TRIES = 2; + +/** + * Access Control List + * + * // acl.txt + * # [addr[/mask][:port]] [ban] [max_upload_speed(/s)] [max_download_speed(/s)] + * + * example.com 1 # prevent access to example.com + * example.com:* 1 # prevent access to example.com:*, equal to above + * example.com:443 1 # prevent access to example.com:443 only + * *:25 1 # prevent access to SMTP servers + * *:* 1 # prevent all access from/to all endpoints + * 127.0.0.1 1 # ban localhost + * 192.168.0.0/16 1 # ban hosts in 192.168.*.* + * 172.27.1.100 0 120K # limit upload speed to 120KB/s + * 172.27.1.100 0 - 120K # limit download speed to 120KB/s + * 172.27.1.100 0 120K 120K # limit upload and download speed to 120KB/s + */ +export class ACL extends EventEmitter { + + _rules = []; + + _cachedRules = { + // : + }; + + _maxTries = 0; + + // members + + _hrTimeBegin = process.hrtime(); + + _remoteHost = null; + + _remotePort = null; + + _dstHost = null; + + _dstPort = null; + + _totalOut = 0; + + _totalIn = 0; + + // flags + + _isDlPaused = false; + + _isUpPaused = false; + + static async loadRules(aclPath) { + return new Promise((resolve, reject) => { + logger.verbose('[acl] loading access control list'); + const rs = fs.createReadStream(aclPath, { encoding: 'utf-8' }); + rs.on('error', (err) => { + logger.warn(`[acl] fail to reload access control list: ${err.message}`); + reject(err); + }); + const rl = readline.createInterface({ input: rs }); + const _rules = []; + rl.on('line', (line) => { + const rule = parseLine(line); + if (rule !== null) { + _rules.push(rule); + } + }); + rl.on('close', () => { + const rules = _rules.reverse(); + logger.info(`[acl] ${rules.length} rules loaded`); + resolve(rules); + }); + }); + } + + constructor({ remoteInfo, rules, max_tries = DEFAULT_MAX_TRIES }) { + super(); + this._remoteHost = remoteInfo.host; + this._remotePort = remoteInfo.port; + this._rules = rules; + this._maxTries = max_tries; + } + + findRule(host, port) { + const cacheKey = `${host}:${port}`; + const cacheRule = this._cachedRules[cacheKey]; + if (cacheRule !== undefined) { + return cacheRule; + } else { + for (const rule of this._rules) { + if (rule.isMatch(host, port)) { + return this._cachedRules[cacheKey] = rule; + } + } + // rule not found + return this._cachedRules[cacheKey] = null; + } + } + + applyRule(rule) { + const { isBan, upLimit, dlLimit } = rule; + logger.debug(`[acl] [${this._remoteHost}:${this._remotePort}] apply rule: "${rule}"`); + + // ban + if (isBan) { + logger.info(`[acl] baned by rule: "${rule}"`); + this.emit('action', { type: ACL_CLOSE_CONNECTION }); + return true; + } + + // max_upload_speed + if (upLimit !== '-') { + // calculate average download speed + const [sec, nano] = process.hrtime(this._hrTimeBegin); + const speed = Math.ceil(this._totalIn / (sec + nano / 1e9)); // b/s + + logger.debug(`[acl] upload speed: ${speed}b/s`); + + if (speed > upLimit && !this._isUpPaused) { + this._isUpPaused = true; + + // determine timeout to resume + const timeout = speed / upLimit * 1.1; // more 10% cost + const direction = `[${this._remoteHost}:${this._remotePort}] -> [${this._dstHost}:${this._dstPort}]`; + logger.info(`[acl] ${direction} upload speed exceed: ${speed}b/s > ${upLimit}b/s, pause for ${timeout}s...`); + + this.emit('action', { type: ACL_PAUSE_RECV }); + setTimeout(() => { + this.emit('action', { type: ACL_RESUME_RECV }); + this._isUpPaused = false; + }, timeout * 1e3); + return true; + } + } + + // max_download_speed + if (dlLimit !== '-') { + // calculate average download speed + const [sec, nano] = process.hrtime(this._hrTimeBegin); + const speed = Math.ceil(this._totalOut / (sec + nano / 1e9)); // b/s + + logger.debug(`[acl] download speed: ${speed}b/s`); + + if (speed > dlLimit && !this._isDlPaused) { + this._isDlPaused = true; + + // determine timeout to resume + const timeout = speed / dlLimit * 1.1; // more 10% cost + const direction = `[${this._remoteHost}:${this._remotePort}] <- [${this._dstHost}:${this._dstPort}]`; + logger.info(`[acl] ${direction} download speed exceed: ${speed}b/s > ${dlLimit}b/s, pause for ${timeout}s...`); + + this.emit('action', { type: ACL_PAUSE_SEND }); + setTimeout(() => { + this.emit('action', { type: ACL_RESUME_SEND }); + this._isDlPaused = false; + }, timeout * 1e3); + return true; + } + } + + return false; + } + + checkRule(host, port) { + const rule = this.findRule(host, port); + if (rule !== null) { + return this.applyRule(rule, host, port); + } + return false; + } + + setTargetAddress({ host, port }) { + this._dstHost = host; + this._dstPort = port; + this.checkRule(host, port); + } + + checkFailTimes(tries) { + const host = this._remoteHost; + const maxTries = this._maxTries; + if (tries[host] === undefined) { + tries[host] = 0; + } + if (++tries[host] >= maxTries) { + logger.warn(`[acl] [${host}] max tries=${maxTries} exceed, ban it`); + if (this.findRule(host, '*') === null) { + this._rules.push(parseLine(`${host}:* 1`)); + } + this.emit('action', { type: ACL_CLOSE_CONNECTION }); + return true; + } + } + + collect(type, size) { + if (type === PIPE_ENCODE) { + this._totalOut += size; + } else { + this._totalIn += size; + } + this.checkRule(this._remoteHost, this._remotePort); + this.checkRule(this._dstHost, this._dstPort); + } + +} diff --git a/src/core/config.js b/src/core/config.js index 5630b23..17fb420 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -8,7 +8,9 @@ import qs from 'qs'; import chalk from 'chalk'; import winston from 'winston'; import isPlainObject from 'lodash.isplainobject'; -import { getPresetClassByName, IPresetAddressing } from '../presets'; +import { ACL } from './acl'; +import { getPresetClassByName } from '../presets'; +import { IPresetAddressing } from '../presets/defs'; import { DNSCache, isValidHostname, isValidPort, logger, DNS_DEFAULT_EXPIRE } from '../utils'; function loadFileSync(file) { @@ -30,7 +32,6 @@ export class Config { timeout = null; redirect = null; - workers = null; dns_expire = null; dns = null; @@ -42,6 +43,11 @@ export class Config { tls_key = null; key = null; + acl = false; + acl_conf = ''; + acl_rules = []; + acl_tries = {}; + presets = null; udp_presets = null; @@ -99,9 +105,9 @@ export class Config { this.forward_port = +port; } + // common + this.timeout = (json.timeout !== undefined) ? json.timeout * 1e3 : 600 * 1e3; - this.redirect = (json.redirect !== '') ? json.redirect : null; - this.workers = (json.workers !== undefined) ? json.workers : 0; this.dns_expire = (json.dns_expire !== undefined) ? json.dns_expire * 1e3 : DNS_DEFAULT_EXPIRE; // dns @@ -135,6 +141,23 @@ export class Config { this.presets = server.presets; this.udp_presets = server.presets; + // acl + if (server.acl !== undefined) { + this.acl = server.acl; + } + + // acl_conf, acl_rules + if (server.acl_conf !== undefined && server.acl) { + this.acl_conf = server.acl_conf; + ACL.loadRules(path.resolve(process.cwd(), server.acl_conf)) + .then((rules) => this.acl_rules = rules); + } + + // redirect + if (server.redirect !== undefined) { + this.redirect = server.redirect; + } + // mux this.mux = !!server.mux; if (this.is_client) { @@ -357,6 +380,20 @@ export class Config { throw Error('"server.key" must be a non-empty string'); } + // acl, acl_conf + if (!from_client && server.acl !== undefined) { + if (typeof server.acl !== 'boolean') { + throw Error('"server.acl" must be true or false'); + } + if (typeof server.acl_conf !== 'string' || server.acl_conf === '') { + throw Error('"server.acl_conf" must be a non-empty string'); + } + const conf = path.resolve(process.cwd(), server.acl_conf); + if (!fs.existsSync(conf)) { + throw Error(`"server.acl_conf" "${conf}" not exist`); + } + } + // mux if (server.mux !== undefined) { if (typeof server.mux !== 'boolean') { @@ -438,20 +475,6 @@ export class Config { } } - // workers - if (common.workers !== undefined) { - const { workers } = common; - if (typeof workers !== 'number') { - throw Error('"workers" must be a number'); - } - if (workers < 0) { - throw Error('"workers" must be an integer'); - } - if (workers > os.cpus().length) { - console.warn(`[config] "workers" is greater than the number of CPUs, is ${workers} workers expected?`); - } - } - // dns if (common.dns !== undefined) { const { dns } = common; diff --git a/src/core/relay.js b/src/core/relay.js index 05d3279..fb9ccb6 100644 --- a/src/core/relay.js +++ b/src/core/relay.js @@ -1,4 +1,5 @@ import EventEmitter from 'events'; +import { ACL } from './acl'; import { Pipe } from './pipe'; import { logger } from '../utils'; import { PIPE_ENCODE, PIPE_DECODE } from '../constants'; @@ -17,6 +18,7 @@ import { CONNECTION_CLOSED, CONNECTION_WILL_CLOSE, CHANGE_PRESET_SUITE, + PRESET_FAILED, } from '../presets/actions'; /** @@ -34,8 +36,12 @@ export class Relay extends EventEmitter { static idcounter = 0; + _config = null; + _id = null; + _acl = null; + _ctx = null; _transport = null; @@ -54,8 +60,6 @@ export class Relay extends EventEmitter { _destroyed = false; - _config = null; - get id() { return this._id; } @@ -67,10 +71,6 @@ export class Relay extends EventEmitter { constructor({ config, transport, context, presets = [] }) { super(); - this.updatePresets = this.updatePresets.bind(this); - this.onBroadcast = this.onBroadcast.bind(this); - this.onEncoded = this.onEncoded.bind(this); - this.onDecoded = this.onDecoded.bind(this); this._id = Relay.idcounter++; this._config = config; this._transport = transport; @@ -78,6 +78,7 @@ export class Relay extends EventEmitter { // pipe this._presets = this.preparePresets(presets); this._pipe = this.createPipe(this._presets); + // ctx this._ctx = { pipe: this._pipe, thisRelay: this, @@ -98,6 +99,11 @@ export class Relay extends EventEmitter { this._inbound.setOutbound(this._outbound); this._inbound.on('close', () => this.onBoundClose(inbound, outbound)); this._inbound.on('updatePresets', this.updatePresets); + // acl + if (config.acl) { + this._acl = new ACL({ remoteInfo: this._remoteInfo, rules: config.acl_rules }); + this._acl.on('action', this.onBroadcast); + } } init({ proxyRequest }) { @@ -163,24 +169,31 @@ export class Relay extends EventEmitter { // hooks of pipe - onBroadcast(action) { - const type = action.type; - if (type === CONNECT_TO_REMOTE) { + onBroadcast = (action) => { + if (action.type === CONNECT_TO_REMOTE) { const remote = `${this._remoteInfo.host}:${this._remoteInfo.port}`; const target = `${action.payload.host}:${action.payload.port}`; if (this._config.mux && this._config.is_client && this._transport !== 'udp') { logger.info(`[relay-${this.id}] [${remote}] request over mux-${this._ctx.muxRelay.id}: ${target}`); return; } + if (this._acl) { + this._acl.setTargetAddress(action.payload); + } logger.info(`[relay] [${remote}] request: ${target}`); } - if (type === CHANGE_PRESET_SUITE) { + if (action.type === PRESET_FAILED) { + if (this._acl) { + this._acl.checkFailTimes(this._config.acl_tries); + } + } + if (action.type === CHANGE_PRESET_SUITE) { this.onChangePresetSuite(action); return; } this._inbound && this._inbound.onBroadcast(action); this._outbound && this._outbound.onBroadcast(action); - } + }; onChangePresetSuite(action) { const { type, suite, data } = action.payload; @@ -207,21 +220,27 @@ export class Relay extends EventEmitter { this._pipe.feed(type, data); } - onEncoded(buffer) { + onEncoded = (buffer) => { if (this._config.is_client) { this._outbound.write(buffer); } else { + if (this._acl) { + this._acl.collect(PIPE_ENCODE, buffer.length); + } this._inbound.write(buffer); } - } + }; - onDecoded(buffer) { + onDecoded = (buffer) => { if (this._config.is_client) { this._inbound.write(buffer); } else { + if (this._acl !== null) { + this._acl.collect(PIPE_DECODE, buffer.length); + } this._outbound.write(buffer); } - } + }; // methods @@ -263,10 +282,10 @@ export class Relay extends EventEmitter { * update presets of pipe * @param value */ - updatePresets(value) { + updatePresets = (value) => { this._presets = typeof value === 'function' ? value(this._presets) : value; this._pipe.updatePresets(this._presets); - } + }; /** * create pipes for both data forward and backward @@ -295,7 +314,6 @@ export class Relay extends EventEmitter { this._remoteInfo = null; this._proxyRequest = null; this._destroyed = true; - this._config = null; } } diff --git a/src/presets/actions.js b/src/presets/actions.js index 7ccfb17..b50d081 100644 --- a/src/presets/actions.js +++ b/src/presets/actions.js @@ -83,13 +83,6 @@ export const PRESET_FAILED = '@action:preset_failed'; */ export const CHANGE_PRESET_SUITE = '@action:change_preset_suite'; -export const PRESET_CLOSE_CONNECTION = '@action:preset_close_connection'; - -export const PRESET_PAUSE_RECV = '@action:preset_pause_recv'; -export const PRESET_PAUSE_SEND = '@action:preset_pause_send'; -export const PRESET_RESUME_RECV = '@action:preset_resume_recv'; -export const PRESET_RESUME_SEND = '@action:preset_resume_send'; - export const MUX_NEW_CONN = '@action:mux_new_conn'; export const MUX_DATA_FRAME = '@action:mux_data_frame'; export const MUX_CLOSE_CONN = '@action:mux_close_conn'; diff --git a/src/transports/tcp.js b/src/transports/tcp.js index 7ee829d..7d6e581 100644 --- a/src/transports/tcp.js +++ b/src/transports/tcp.js @@ -2,15 +2,19 @@ import net from 'net'; import { Inbound, Outbound } from './defs'; import { MAX_BUFFERED_SIZE, PIPE_ENCODE, PIPE_DECODE } from '../constants'; import { DNSCache, logger, getRandomInt } from '../utils'; + +import { + ACL_CLOSE_CONNECTION, + ACL_PAUSE_RECV, + ACL_PAUSE_SEND, + ACL_RESUME_RECV, + ACL_RESUME_SEND, +} from '../core/acl'; + import { CONNECT_TO_REMOTE, CONNECTED_TO_REMOTE, PRESET_FAILED, - PRESET_CLOSE_CONNECTION, - PRESET_PAUSE_RECV, - PRESET_PAUSE_SEND, - PRESET_RESUME_RECV, - PRESET_RESUME_SEND, } from '../presets/actions'; export class TcpInbound extends Inbound { @@ -134,14 +138,15 @@ export class TcpInbound extends Inbound { case PRESET_FAILED: this.onPresetFailed(action); break; - case PRESET_CLOSE_CONNECTION: - this.onPresetCloseConnection(); + case ACL_CLOSE_CONNECTION: + logger.info(`[${this.name}] [${this.remote}] acl request to close connection`); + this.close(); break; - case PRESET_PAUSE_RECV: - this.onPresetPauseRecv(); + case ACL_PAUSE_RECV: + this._socket && this._socket.pause(); break; - case PRESET_RESUME_RECV: - this.onPresetResumeRecv(); + case ACL_RESUME_RECV: + this._socket && this._socket.resume(); break; default: break; @@ -183,19 +188,6 @@ export class TcpInbound extends Inbound { } } - onPresetCloseConnection() { - logger.info(`[${this.name}] [${this.remote}] preset request to close connection`); - this.close(); - } - - onPresetPauseRecv() { - this._config.is_server && (this._socket && this._socket.pause()) - } - - onPresetResumeRecv() { - this._config.is_server && (this._socket && this._socket.resume()); - } - } export class TcpOutbound extends Outbound { @@ -303,11 +295,11 @@ export class TcpOutbound extends Outbound { case CONNECT_TO_REMOTE: this.onConnectToRemote(action); break; - case PRESET_PAUSE_SEND: - this.onPresetPauseSend(); + case ACL_PAUSE_SEND: + this._socket && this._socket.pause(); break; - case PRESET_RESUME_SEND: - this.onPresetResumeSend(); + case ACL_RESUME_SEND: + this._socket && this._socket.resume(); break; default: break; @@ -344,14 +336,6 @@ export class TcpOutbound extends Outbound { } } - onPresetPauseSend() { - this._config.is_server && (this._socket && this._socket.pause()); - } - - onPresetResumeSend() { - this._config.is_server && (this._socket && this._socket.resume()); - } - async connect({ host, port }) { // close alive connection before create a new one if (this._socket && !this._socket.destroyed) { diff --git a/test/src/core/config.test.js b/test/src/core/config.test.js index 283bf47..5bf9b20 100644 --- a/test/src/core/config.test.js +++ b/test/src/core/config.test.js @@ -67,13 +67,6 @@ describe('Config#test', () => { expect(() => Config.test({ ...baseConf, log_max_days: 1 })).not.toThrow(); }); - it('should throw when workers(if provided) is invalid', () => { - expect(() => Config.test({ ...baseConf, workers: '0' })).toThrow(); - expect(() => Config.test({ ...baseConf, workers: -1 })).toThrow(); - expect(() => Config.test({ ...baseConf, workers: 0 })).not.toThrow(); - expect(() => Config.test({ ...baseConf, workers: 100 })).not.toThrow(); - }); - it('should throw when dns(is provided) is invalid', () => { expect(() => Config.test({ ...baseConf, dns: null })).toThrow(); expect(() => Config.test({ ...baseConf, dns: [''] })).toThrow(); @@ -199,7 +192,6 @@ describe('Config#init', () => { expect(config.is_client).toBe(true); expect(config.is_server).toBe(false); expect(config.timeout).toBe(600 * 1e3); - expect(config.workers).toBe(0); expect(config.log_level).toBe('warn'); expect(config.log_path.endsWith('blinksocks.log')).toBe(true); expect(config.log_max_days).toBe(30);