diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..23d3932 --- /dev/null +++ b/.babelrc @@ -0,0 +1,12 @@ +{ + "compact": false, + "comments": false, + "presets": ["es2015"], + "plugins": [ + "syntax-async-functions", + "syntax-object-rest-spread", + "transform-object-rest-spread", + "transform-regenerator", + "transform-runtime" + ] +} diff --git a/.gitignore b/.gitignore index 123ae94..e933182 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ build/Release # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules + +lib +cache +log diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..ba49d74 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +src +node_modules diff --git a/README.md b/README.md index cbdd6bd..8955d1b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # kcsp + +[![NPM](https://nodei.co/npm/kcsp.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/kcsp/) + A simple proxy server for KanColle to avoid network problems. diff --git a/bin/kcsp b/bin/kcsp new file mode 100644 index 0000000..1a1e39a --- /dev/null +++ b/bin/kcsp @@ -0,0 +1,39 @@ +#!/usr/bin/node + +var getopt = require('node-getopt'); +var env = require('../lib/env'); + +var cmd = getopt.create([ + ['c', 'cache-path=ARG', 'set cache database path'], + ['l', 'log-path=ARG', 'set log file path'], + ['p', 'path=ARG', 'set base path'], + ['v', 'version', 'show version'] +]).bindHelp().parseSystem(); + +if (cmd.options['version']) { + console.log(env.APP_VERSION); + process.exit(); +} + +require('daemon')(); + +var args = []; +if (cmd.options['path']) { + args.push('--path=' + cmd.options['path']); +} +if (cmd.options['cache-path']) { + args.push('--cache-path=' + cmd.options['cache-path']); +} +if (cmd.options['log-path']) { + args.push('--log-path=' + cmd.options['log-path']); +} + +process.title = 'kcsp monitor'; + +var forever = require('forever-monitor'); + +var child = new (forever.Monitor)(__dirname + '/../lib/cli.js', { + args: args +}); + +child.start(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf92faa --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "kcsp", + "version": "0.4.0", + "description": "A simple proxy server for KanColle to avoid network problems.", + "bin": { + "kcsp": "./bin/kcsp" + }, + "scripts": { + "compile": "babel -d lib/ src/", + "prepublish": "npm run compile" + }, + "author": "Gizeta", + "license": "GPL-2.0", + "files": [ + "bin", + "lib" + ], + "repository": { + "type": "git", + "url": "https://github.com/Gizeta/kcsp.git" + }, + "keywords": [ + "kancolle", + "proxy" + ], + "bugs": { + "url": "https://github.com/Gizeta/kcsp/issues" + }, + "homepage": "https://github.com/Gizeta/kcsp", + "dependencies": { + "babel-polyfill": "^6.3.14", + "daemon": "^1.1.0", + "forever-monitor": "^1.7.0", + "level": "^1.4.0", + "level-ttl": "^3.1.0", + "node-getopt": "^0.2.3", + "request": "^2.67.0", + "tracer": "^0.8.2" + }, + "devDependencies": { + "babel-cli": "^6.4.0", + "babel-plugin-transform-object-rest-spread": "^6.3.13", + "babel-plugin-transform-regenerator": "^6.3.26", + "babel-preset-es2015": "^6.3.13" + } +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..86b1ad2 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,7 @@ +import server from './server'; +import config from './config'; +import logger from './logger'; +import 'babel-polyfill'; + +server.listen(config.port); +logger.info('kcsp server started.'); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..134dbd3 --- /dev/null +++ b/src/config.js @@ -0,0 +1,20 @@ +import getopt from 'node-getopt'; + +let cmd = getopt.create([ + ['b', 'path=ARG', 'set base path'], + ['c', 'cache-path=ARG', 'set cache database path'], + ['l', 'log-path=ARG', 'set log file path'], + ['p', 'port=ARG', 'set web port'] +]).bindHelp().parseSystem(); + +let basePath = cmd.options['path'] || __dirname + '/..'; +let logPath = cmd.options['log-path'] || (basePath + '/log'); +let cachePath = cmd.options['cache-path'] || (basePath + '/cache'); +let port = cmd.options['cache-path'] ? parseInt(cmd.options['cache-path']) : 8099; + +module.exports = { + basePath: basePath, + cachePath: cachePath, + logPath: logPath, + port: port +}; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..fdb5cb4 --- /dev/null +++ b/src/db.js @@ -0,0 +1,43 @@ +import levelup from 'level'; +import ttl from 'level-ttl'; +import config from './config'; +import logger from './logger'; + +let db = levelup(config.cachePath); +db = ttl(db, { + checkFrequency: 2 * 60 * 60 * 1000, + defaultTTL: 30 * 60 * 1000 +}); + +export async function get(key) { + return new Promise((resolve, reject) => { + db.get(key, function(err, value) { + if (err) { + if (err.notFound) { + resolve(); + return; + } + logger.error('database error, %s', err); + reject(err); + return; + } + resolve(value); + }); + }); +} + +export function put(key, value) { + db.put(key, value, function(err) { + if (err) { + logger.error('database error, %s', err); + } + }); +} + +export function del(key) { + db.del(key, function(err) { + if (err) { + logger.error('database error, %s', err); + } + }); +} diff --git a/src/env.js b/src/env.js new file mode 100644 index 0000000..2c608b5 --- /dev/null +++ b/src/env.js @@ -0,0 +1,3 @@ +module.exports = { + APP_VERSION: '0.4.0' +} diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..2dbb5fe --- /dev/null +++ b/src/logger.js @@ -0,0 +1,11 @@ +import tracer from 'tracer'; +import fs from 'fs'; +import config from './config'; + +(function(){ + if (!fs.existsSync(config.logPath)) { + fs.mkdirSync(config.logPath); + } +})(); + +module.exports = tracer.dailyfile({root: config.logPath}); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..45883f2 --- /dev/null +++ b/src/server.js @@ -0,0 +1,300 @@ +import http from 'http'; +import url from 'url'; +import request from 'request'; +import zlib from 'zlib'; +import logger from './logger'; +import * as db from './db'; +import env from './env'; + +const responseError = { + 403: '

HTTP 403 - Forbidden

参数错误或无访问权限。', + 410: '

HTTP 410 - Gone

获取数据失败。', + 500: '

HTTP 500 - Internal Server Error

服务器内部执行过程中遇到错误。请向webmaster提交错误报告以解决问题。', + 503: '

HTTP 503 - Service Unavailable

暂未获取到数据。请稍后再试。' +} + +const kcIpList = [ + '203.104.209.7', + '203.104.209.71', + '125.6.184.15', + '125.6.184.16', + '125.6.187.205', + '125.6.187.229', + '125.6.187.253', + '125.6.188.25', + '203.104.248.135', + '125.6.189.7', + '125.6.189.39', + '125.6.189.71', + '125.6.189.103', + '125.6.189.135', + '125.6.189.167', + '125.6.189.215', + '125.6.189.247', + '203.104.209.23', + '203.104.209.39', + '203.104.209.55', + '203.104.209.102' +]; +const kcCacheableApiList = [ + '/kcsapi/api_start2' +]; + +let server = http.createServer((req, resp) => { + let chunks = []; + let chunkSize = 0; + req.params = { + ip: getIp(req), + requestPath: '', + postData: '', + requestTime: new Date().getTime() + }; + + req.on('data', chunk => { + chunks.push(chunk); + chunkSize += chunk.length; + }); + + req.on('end', async () => { + let data = null; + switch (chunks.length) { + case 0: + data = new Buffer(0); + break; + case 1: + data = chunks[0]; + break; + default: + data = new Buffer(chunkSize); + for (var i = 0, pos = 0, l = chunks.length; i < l; i++) { + var chunk = chunks[i]; + chunk.copy(data, pos); + pos += chunk.length; + } + break; + } + req.params.postData = data.toString(); + + logger.info(`${req.params.ip} requests ${req.url}`); + + let locked = await db.get('lock'); + if (locked === 'true') { + renderErrorPage(resp, 503); + logger.info(`send error 503 to ${req.params.ip}, handled in ${(new Date().getTime() - req.params.requestTime) / 1000}s`); + return; + } + + if (!validateRequest(req)) { + renderErrorPage(resp, 403); + logger.info(`send error 403 to ${req.params.ip}, handled in ${(new Date().getTime() - req.params.requestTime) / 1000}s`); + return; + } + + try { + let content = await processRequest(req); + renderContent(resp, { + ...content, + acceptEncoding: req.headers['accept-encoding'] || '' + }); + logger.info(`response to ${req.params.ip}, handled in ${(new Date().getTime() - req.params.requestTime) / 1000}s`); + } + catch(err) { + let errCode = 500; + switch(err) { + case "unavailable": + errCode = 503; + break; + case "gone": + errCode = 410; + break; + case "forbidden": + errCode = 403; + break; + } + renderErrorPage(resp, errCode); + logger.info(`send error ${errCode} to ${req.params.ip}, handled in ${(new Date().getTime() - req.params.requestTime) / 1000}s`); + } + }); +}); + +function validateRequest(req) { + if (req.method !== 'POST' || + req.headers['request-uri'] == null || + req.headers['cache-token'] == null) + return false; + + let objUrl = url.parse(req.headers['request-uri']); + if (objUrl.pathname.startsWith('/kcsapi/') && kcIpList.indexOf(objUrl.hostname) >= 0) { + req.params.requestPath = objUrl.pathname; + return true; + } + + return false; +} + +async function processRequest(req) { + let cacheable = kcCacheableApiList.indexOf(req.params.requestPath) >= 0; + let cacheToken = cacheable ? req.params.requestPath : req.headers['cache-token']; + + logger.info(`process request, user: ${req.params.ip}, token: ${cacheToken}`); + + let data = await db.get(cacheToken); + if (data === '__REQUEST__') { + throw new Error('unavailable'); + } + else if (data === '__BLOCK__') { + throw new Error('gone'); + } + else if (data != null) { + /* maybe still need to post to KADOKAWA although api data can be cached like api_start2 */ + /* not implement to avoid sending repeat request */ + + return { + statusCode: 200, + content: data + }; + } + else { + return await postToRemote({ + url: req.headers['request-uri'], + headers: filterHeaders(req.headers), + postData: req.params.postData, + cacheToken: cacheToken, + cacheable: cacheable + }); + } +} + +async function postToRemote(conn) { + logger.info(`requesting ${conn.url}`); + + db.put(conn.cacheToken, '__REQUEST__'); + return new Promise((resolve, reject) => { + request.post({ + url: conn.url, + form: conn.postData, + headers: conn.headers, + timeout: 180000, + gzip: true + }, function(error, response, body) { + if (error) { + logger.error([ + 'meet error during requesting.', + `error: ${error}`, + `url: ${conn.url}`, + `headers: ${JSON.stringify(conn.headers)}`, + `post: ${conn.postData}` + ].join('\n\t')); + + if (conn.cacheable) { + reject(new Error('unavailable')); + } + else { + db.put(conn.cacheToken, '__BLOCK__'); + reject(new Error('gone')); + } + return; + } + + if (response.statusCode >= 400) { + logger.error([ + 'remote server response error.', + `code: ${response.statusCode}`, + `url: ${conn.url}`, + `headers: ${JSON.stringify(conn.headers)}`, + `post: ${conn.postData}`, + `response: ${body}` + ].join('\n\t')); + + if (conn.cacheable) { + reject(new Error('unavailable')); + } + else { + db.put(conn.cacheToken, body); + resolve({ + statusCode: response.statusCode, + content: body + }); + } + return; + } + + logger.info(`remote server responsed, code: ${response.statusCode}`); + db.put(conn.cacheToken, body); + resolve({ + statusCode: response.statusCode, + content: body + }); + }); + }); +} + +function filterHeaders(data) { + var headers = {}; + for (var key in data) { + if (key !== 'host' && + key !== 'expect' && + key !== 'connection' && + key !== 'proxy-connection' && + key !== 'content-length' && + key !== 'cache-token' && + key !== 'request-uri') { + headers[key] = data[key]; + } + } + + return headers; +} + +function getIp(req) { + return req.headers['x-forwarded-for'] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket.remoteAddress; +} + +function renderContent(resp, data) { + if (data.acceptEncoding.indexOf('gzip') >= 0) { + zlib.gzip(data.content, function(err, result) { + if (err) { + logger.error([ + 'meet error during compressing.', + `error: ${err}`, + `content: ${data.content}` + ].join('\n\t')); + throw new Error(err); + } + + resp.writeHead(data.statusCode, {'content-type': 'text/plain', 'content-encoding': 'gzip'}); + resp.end(result); + }); + } + else if (data.acceptEncoding.indexOf('deflate') >= 0) { + zlib.deflate(data.content, function(err, result) { + if (err) { + logger.error([ + 'meet error during compressing.', + `error: ${err}`, + `content: ${data.content}` + ].join('\n\t')); + throw new Error(err); + } + + resp.writeHead(data.statusCode, {'content-type': 'text/plain', 'content-encoding': 'deflate'}); + resp.end(result); + }); + } + else { + resp.writeHead(data.statusCode, {'content-type': 'text/plain'}); + resp.end(result); + } +} + +function renderErrorPage(resp, code) { + resp.writeHead(code, {'content-type': 'text/html'}); + resp.write(''); + resp.write(responseError[code]); + resp.end('
Powered by KCSP Server/' + env.APP_VERSION + ''); +} + +export default server;