TSW作为一款提供染色抓包、全息日志、异常发现与处理的开源基础设施,不论是在鹅厂内部还是在其他开发团队里,都有着比较广泛的应用。但是TSW还是有一些美中不足的地方,比如文档体验较差,造成在使用TSW、阅读TSW源码时存在一定的障碍。因此在这里开个坑,写一份TSW的源码阅读指南,帮助大家更好地了解TSW的设计思路和具体实现。
本指南采用TSW 2019.7 开源版本的master分支进行讲解。
项目GitHub地址:https://github.com/Tencent/TSW
TSW官方文档:https://tswjs.org/guide/index
在源代码中所有以“注:”开头的注释笔者自己添加的注释,通过阅读这些注释,会对整体代码结构有更深的理解。
TSW的主要功能的源代码都在/bin文件夹下,其主要功能(染色抓包、全息日志、异常处理)的源代码都集中在/bin/tsw文件夹下,所以在本文中,我们会结合TSW的文档,详细解读/bin/tsw下的源代码。
这部分主要先介绍一下tsw中plug、context、window的实现,首先放官方文档对于全局变量的链接:https://tswjs.org/doc/api/global
plug用于加载tsw的内部模块,实际上是对require的封装,实现如下:
/bin/tsw/plug.js
'use strict';
// 注:plug是调用tsw模块时的逻辑,实际上就是字符串拼接。
// 内置模块
// 注:使用plug(XXX)实际就是调用了这个方法,变为require(XXX)
function plug(id) {
return require(id);
}
if (!global.plug) {
global.plug = plug;
const path = require('path');
plug.__dirname = __dirname;
plug.parent = path.join(__dirname, '..');
plug.paths = [
path.join(__dirname, '../deps'),
path.join(__dirname, '../tencent'),
path.join(__dirname, '../tsw')
];
module.paths = plug.paths.concat(module.paths);
// 支持seajs模块
require('loader/seajs');
require('loader/extentions.js');
// 加固stringify
// 注:将"<"替换为\u003c,因为stringify有的字符不能转义,这样做可以防范xss
JSON.stringify = (function(stringify) {
return function(...args) {
let str = stringify.apply(this, args);
if (str && str.indexOf('<') > -1) {
str = str.replace(/</g, '\\u003C');
}
return str;
};
})(JSON.stringify);
}
module.exports = global.plug;
结合注释,阅读起来没太多的难点,主要是针对“<”字符进行的转义,这个细节在编程的时候一定要注意。
content是tsw提供的一种上下文对象。
window是tsw提供的另一种上下文对象
content对象中的window属性指向了window这个上下文对象。
而tsw针对window对象做了更多的操作,如:禁用/启用window对象、websocket对象、request/response请求对象等等,详情可直接参阅文档。
结合注释,我们来看看实现:
/bin/tsw/context.js
'use strict';
// 注:这个文件中包含了上下文对象,使用方法可以参考 https://tswjs.org/doc/api/global
const Context = require('runtime/Context');
const Window = require('runtime/Window');
// 注:获取当前的context对象
this.currentContext = function () {
return (process.domain && process.domain.currentContext) || new Context();
};
if (!global.context) {
// 注:这里使用Object.defineProperty来"设置global.context属性和global.window属性的get描述符"
// 注:也就是在获取global.context和global.window的过程中,执行get描述符中定义的函数。
// 注:关于Object.defineProperty,可以看一下:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
Object.defineProperty(global, 'context', {
get: function () {
return module.exports.currentContext();
}
});
Object.defineProperty(global, 'window', {
get: function () {
// 注:TSW中可以设置禁用window对象,如果window对象被禁用了,那么直接返回空。
if (global.windowHasDisabled) {
return undefined;
}
// 注:由于window是content对象的一个属性,所以我们先要执行currentContext方法获取到context对象,之后返回window对象。
const curr = module.exports.currentContext();
if (!curr.window) {
curr.window = new Window();
}
return curr.window;
}
});
}
context的实现依赖两个文件:
接下来我们详细看看这两个文件:
/bin/tsw/runtime/Context
'use strict';
// 注:这是tsw中context对象的具体定义,包含了构造方法和原型上的方法。
module.exports = Context;
// 注:空构造方法。
function Context() {
}
// 注:这个mod_act是一个映射,在config中使用。
Context.prototype.setModAct = function(mod_act) {
context.mod_act = mod_act;
return true;
};
Context.prototype.getModAct = function() {
return context.mod_act;
};
/bin/tsw/runtime/Window
'use strict';
// 注:这是tsw中window对象的具体定义,包含了构造方法和原型上的方法。
module.exports = Window;
// 注:空构造方法。
function Window() {
}
// 注:由于TSW是可以禁用window对象的,所以这里是禁用/启用window对象的方法。
// 注:至于为什么要禁用/启用window呢?个人的理解是这样的:
// 注:浏览器中的window对象广义上代表一个页面的生命周期,在TSW中window对象代表了一个请求的生命周期。
// 注:但在很多场合,比如vue-ssr,使用window对象会使vue对浏览器的环境判断失准(本来在node中没有window对象,但是我们却搞出来了个window对象)
// 注:所以在这种情况下就要禁用它
Window.prototype.disable = function() {
global.windowHasDisabled = true;
};
Window.prototype.enable = function() {
global.windowHasDisabled = false;
};
这里主要聊一下为什么要禁用window对象(个人理解):
浏览器中的window对象广义上代表一个页面的生命周期,在TSW中window对象代表了一个请求的生命周期。但在很多场合,比如vue-ssr,使用window对象会使vue对浏览器的环境判断失准(本来在node中没有window对象,但是我们却搞出来了个window对象),所以在这种情况下就要禁用它。
TSW的路由在官网上的介绍是“主要用于实现多级路由”。而阅读其源码后,实际上它是对TSW中的window对象里的req、res进行了一次封装,使在路由跳转的时候能够打印日志、实现webso柔性、黑名单过滤等功能。
首先放上官方API:https://tswjs.org/doc/api/router
下面让我们看看源码:
/bin/tsw/router.js
// 注:这是tsw的router配置,实际上是对tsw的window对象的req与res进行的一个封装,在路由跳转前使用logger打印跳转的日志,之后进行跳转,返回res。
this.route = function(url, name) {
const logger = require('logger');
const httpRoute = require('../proxy/http.route.js'); // 注:在http.route.js中实现了路由的逻辑,包含web柔性,http返回码重置,黑名单等功能。
const parseGet = require('util/http/parseGet.js'); // 注:在parseGet.js中对请求进行了一些额外的处理,如对cookie,query限制大小等。
const window = context.window || {};
const req = window.request;
const res = window.response;
if (!req) {
return;
}
res.removeAllListeners('afterFinish');
// 打印url和name
if (url) {
logger.debug(`route to : ${url}`);
req.url = url;
parseGet(req); // 解析get参数
}
if (name) {
logger.debug(`route to name: ${name}`);
context.mod_act = name;
} else {
context.mod_act = null;
}
httpRoute.doRoute(req, res);
};
router.js文件中主要依赖两个文件,分别是 …/proxy/http.route.js 与 util/http/parseGet.js,前者主要提供路由跳转的方法(doRoute方法)以及路由跳转相关操作。而后者主要是对get请求进行一些处理,如限制cookie、query的大小等。
接下来我会主要对http.route.js中的一些逻辑进行讲解(代码就不全贴出来了,太多了)。
http.route.js中提供的doRoute方法(路由跳转方法)主要做了以下几个事情:
1、首先获取其IP。
2、屏蔽扫描请求后上报log。
3、环境以及url深度的检查。
4、黑名单检查。
5、webso柔性。
6、xss防范
接下来贴出代码
module.exports.doRoute = doRoute;
// 注:这里导出了一个doRoute函数,此函数的作用是:
// 1、首先获取其IP。
// 2、屏蔽扫描请求后上报log。
// 3、环境以及url深度的检查。
// 4、黑名单检查。
// 5、webso柔性。
// 6、xss防范
function doRoute(req, res) {
const clientIp = httpUtil.getUserIp(req);
const userIp24 = httpUtil.getUserIp24(req);
logger.debug('${method} ${protocol}://${host}${path}', {
protocol: req.REQUEST.protocol,
path: req.REQUEST.path,
host: req.headers.host,
method: req.method
});
logger.debug('idc: ${idc}, server ip: ${intranetIp}, tcp: ${remoteAddress}:${remotePort} > ${localAddress}:${localPort}, client ip: ${clientIp}, cpuUsed: ${cpuUsed}', {
cpuUsed: global.cpuUsed,
idc: config.idc,
intranetIp: serverInfo.intranetIp,
clientIp: clientIp,
remoteAddress: (req.socket && req.socket.remoteAddress),
remotePort: (req.socket && req.socket.remotePort),
localAddress: (req.socket && req.socket.localAddress),
localPort: (req.socket && req.socket.localPort)
});
if (config.isTest) {
logger.debug('config.isTest is true');
if (isTST.isTST(req)) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=UTF-8' });
res.end();
return;
}
}
// 支持从配置中直接屏蔽扫描请求
if (isTST.isTST(req)) {
tnm2.Attr_API('SUM_TSW_HTTP_TST', 1);
if (config.ignoreTST) {
logger.debug('ignore TST request');
res.writeHead(200, { 'Content-Type': 'text/html; charset=UTF-8' });
res.end('200');
return;
}
}
if (config.devMode) {
logger.debug('config.devMode is true');
}
// log自动上报
logReport(req, res);
res.writeHead = (function(fn) {
return function(...args) {
if (alpha.isAlpha(req)) {
if (logger.getLog()) {
logger.getLog().showLineNumber = true;
logger.debug('showLineNumber on');
}
// 抓回包
httpUtil.captureServerResponseBody(this);
}
logger.debug('response ${statusCode}', {
statusCode: args[0]
});
if (this.__hasClosed) {
this.emit('done');
}
return fn.apply(this, args);
};
})(res.writeHead);
let mod_act = contextMod.currentContext().mod_act || httpModAct.getModAct(req);
if (typeof mod_act !== 'string' || !mod_act) {
mod_act = null;
}
contextMod.currentContext().mod_act = mod_act;
if (alpha.isAlpha(req)) {
if (logger.getLog()) {
logger.getLog().showLineNumber = true;
logger.debug('showLineNumber on');
}
httpUtil.captureIncomingMessageBody(req);
}
logger.debug('node-${version}, name: ${name}, appid: ${appid}', {
version: process.version,
name: mod_act || null,
appid: config.appid || null
});
// 检查测试环境
if (h5test.isTestUser(req, res)) {
return;
}
// 跟踪url调用深度
const steps = parseInt(req.headers['tsw-trace-steps'] || '0', 10) || 0;
// 深度超过5层,直接拒绝
if (steps >= 5) {
tnm2.Attr_API('SUM_TSW_ROUTE_EXCEED', 1);
try {
res.writeHead(503, { 'Content-Type': 'text/html; charset=UTF-8' });
} catch (e) {
logger.info(`response 503 fail ${e.message}`);
} finally {
res.end();
}
return;
}
// +1
req.headers['tsw-trace-steps'] = steps + 1;
originCheck(req, res);
if (res.headersSent || res.finished) {
return;
}
let modulePath = httpModMap.find(mod_act, req, res);
if (res.headersSent || res.finished) {
return;
}
if (modulePath && typeof modulePath.handle === 'function') {
const app = modulePath;
modulePath = function(req, res, plug) {
return app.handle(req, res);
};
}
if (modulePath && typeof modulePath.callback === 'function') {
const app = modulePath;
// if beforeStart exists
if (typeof app.beforeStart === 'function') {
modulePath = (req, res, plug) => {
return app.beforeStart(() => {
app.callback()(req, res);
});
};
} else {
modulePath = (req, res, plug) => {
return app.callback()(req, res);
};
}
}
if (typeof modulePath !== 'function') {
if (req.REQUEST.pathname === '/419') {
if (typeof config.page419 === 'string') {
modulePath = require(config.page419);
}
} else {
if (typeof config.page404 === 'string') {
modulePath = require(config.page404);
}
}
}
if (typeof modulePath !== 'function') {
try {
res.writeHead(404, { 'Content-Type': 'text/html; charset=UTF-8' });
} catch (e) {
logger.info(`response 404 fail ${e.message}`);
} finally {
res.end();
}
return;
}
const modulePathHandler = function() {
const maybePromise = modulePath(req, res, plug);
if (
typeof maybePromise === 'object'
&&
typeof maybePromise.catch === 'function'
) {
maybePromise.catch(function(err) {
logger.error(err);
process.domain && process.domain.emit('error', err);
});
}
};
const blackIpMap = TSW.getBlockIpMapSync() || {};
if (!clientIp) {
logger.debug('client ip has been empty');
tnm2.Attr_API('SUM_TSW_IP_EMPTY', 1);
res.writeHead(403, { 'Content-Type': 'text/plain; charset=UTF-8' });
res.end();
return;
}
if (blackIpMap[clientIp] || blackIpMap[userIp24]) {
logger.debug('命中黑名单IP');
dcapi.report({
key: 'EVENT_TSW_HTTP_IP_BLOCK',
toIp: clientIp,
code: 0,
isFail: 0,
delay: 100
});
tnm2.Attr_API('SUM_TSW_IP_BLOCK', 1);
res.writeHead(403, { 'Content-Type': 'text/plain; charset=UTF-8' });
res.end();
return;
}
// allowHost
if (CCFinder.checkHost(req, res) === false) {
return;
}
if (CCFinder.check(req, res) === false) {
return;
}
// webso柔性
// 注:如果cpu超载了,直接返回304使用协商缓存
if (global.cpuUsed > config.cpuLimit) {
if (httpUtil.isFromWns(req) && req.headers['if-none-match']) {
logger.debug(`webso limit 304, cpuUsed: ${global.cpuUsed}, cpuLimit: ${config.cpuLimit}`);
tnm2.Attr_API('SUM_TSW_WEBSO_LIMIT', 1);
res.writeHead(304, {
'Content-Type': 'text/html; charset=UTF-8',
'Etag': req.headers['if-none-match']
});
res.end();
return;
}
}
const contentType = req.headers['content-type'] || 'application/x-www-form-urlencoded';
if (req.method === 'GET' || req.method === 'HEAD') {
if (httpUtil.isFromWns(req)) {
// wns请求不过XSS检查
return modulePathHandler();
}
xssFilter.check().done(function() {
return modulePathHandler();
}).fail(function() {
res.writeHead(501, { 'Content-Type': 'text/plain; charset=UTF-8' });
res.end('501 by TSW');
});
} else if (context.autoParseBody === false) {
// stream handler
return modulePathHandler();
} else if (
contentType.indexOf('application/x-www-form-urlencoded') > -1
|| contentType.indexOf('text/plain') > -1
|| contentType.indexOf('application/json') > -1
) {
parseBody(req, res, function() {
return modulePathHandler();
});
} else {
return modulePathHandler();
}
}
在这里简要说下部分主要功能的实现方式:
tsw的日志模块也是一个很重要的模块,它给我们提供了丰富的全息日志、染色抓包的功能。但究其实现,不是很复杂。
首先还是放上官方API:https://tswjs.org/doc/api/logger
tsw的日志处理,简言之分成以下几部分:
1、染色抓包:
提供setKey/getKey方法,使用正则表达式鉴别key是否合法(只能是字母,数字,下划线组合),之后根据配置判断是否是需要抓包的uid(逻辑在/util/alpha.js)中,如果是,则自动上报,上报的逻辑在/util/alpha.js中。
/bin/tsw/util/logger/logger.js 中setKey/getKey部分
// 注:首先使用正则表达式鉴别key是否合法(只能是字母,数字,下划线组合),之后根据配置判断是否是需要抓包的uid(逻辑在/util/alpha.js)中
setKey: function(key) {
if (!canIuse.test(key)) {
this.debug('bad key: ${key}', { key: key });
return;
}
this.debug('setKey: ${key}', { key: key });
const log = this.getLog();
const alpha = require('util/alpha.js');
if (!log) {
return;
}
log.key = key;
if (alpha.isAlpha(key)) {
log.showLineNumber = true;
}
},
getKey: function() {
const log = this.getLog();
if (log) {
return log.key;
}
return null;
},
2、打印日志部分:
日志分成四个级别:DEBUG、INFO、WARN、ERROR,
首先判断日志的级别,只有符合日志级别的才会输出。
之后将日志写入日志缓冲区。
再然后依次写入原始的日志。
// 注:这里分别是四种不同的日志级别
debug: function(str, obj) {
this.writeLog('DBUG', str, obj);
},
info: function(str, obj) {
this.writeLog('INFO', str, obj);
},
warn: function(str, obj) {
this.writeLog('WARN', str, obj);
},
error: function(str, obj) {
// this.occurError();
this.writeLog('ERRO', str, obj);
},
// 注:这里是tsw写日志的逻辑,比较简单,首先判断日志级别,只有符合配置级别的日志才会输出,之后写入日志缓冲区,然后依次写入原始日志
writeLog: function(type, str, obj) {
const level = this.type2level(type);
const log = this.getLog();
const allow = filter(level, str, obj);
const useInspectFlag = process.execArgv.join().includes('inspect');
let logStr = null;
if (log || allow === true || level >= config.getLogLevel()) {
logStr = this._getLog(type, level, str, obj);
}
if (logStr === null) {
return this;
}
// 全息日志写入原始日志
this.fillBuffer(type, logStr);
if (allow === false || level < config.getLogLevel()) {
return this;
}
if (useInspectFlag) {
// Chrome写入原始日志
this.fillInspect(logStr, level);
// 控制台写入高亮日志
const logColor = this._getLog(type, level, str, obj, 'color');
this.fillStdout(logColor, level);
} else {
// 非调试模式写入原始日志
this.fillStdout(logStr, level);
}
return this;
},
// 注:这里有个log buffer,用于缓冲log的信息
fillBuffer: function(type, fn) {
const log = this.getLog();
if (log) {
if (!log.arr) {
log.arr = [];
}
if (fn) {
log.arr.push(fn);
}
if (type) {
if (log[type]) {
log[type]++;
} else {
log[type] = 1;
}
}
if (log.arr.length % 512 === 0 || (log.json && log.json.ajax && log.json.ajax.length % 10 === 0)) {
const beforeLogClean = contextMod.currentContext().beforeLogClean;
if (typeof beforeLogClean === 'function') {
beforeLogClean();
}
} else if (log.arr.length % 1024 === 0 || (log.json && log.json.ajax && log.json.ajax.length % 20 === 0)) {
process.emit('warning', new Error('too many log'));
this.clean();
}
}
},
这里简单介绍一下计算cpu使用率功能、获取IP地址功能的实现。
在tsw中,读取cpu的使用率实际上是读取/proc/stat文件来计算cpu使用率,主要步骤有:
1、读取文件,找到以CPU开头的一行
2、split空格,形成参数数组
3、根据参数数组计算CPU使用率
可参照博客:https://www.cnblogs.com/gavin11/p/10653785.html
代码实现:
/bin/tsw/util/cpu.js
this.getCpuUsed = function(cpu) {
const now = Date.now();
cpu = cpu || '';
if (isWin32Like) {
return cache.curr;
}
if (now - cache.time < 3000) {
return cache.curr;
}
cache.time = now;
if (typeof cpu !== 'number') {
cpu = '';
}
// 注:读取/proc/stat文件来计算cpu使用率,可参照博客:https://www.cnblogs.com/gavin11/p/10653785.html
fs.readFile('/proc/stat', function(err, buffer) {
if (err) {
logger.error(err.stack);
return;
}
const lines = buffer.toString('UTF-8').split('\n');
let str = '';
let arr = [];
// 注:找到开头是CPU的那一行
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith(`cpu${cpu} `)) {
str = lines[i];
break;
}
}
// 注:split成参数数组
if (str) {
arr = str.split(/\W+/);
} else {
return;
}
// 注:数组里每个数字代表一个含义
const user = parseInt(arr[1], 10) || 0;
const nice = parseInt(arr[2], 10) || 0;
const system = parseInt(arr[3], 10) || 0;
const idle = parseInt(arr[4], 10) || 0;
const iowait = parseInt(arr[5], 10) || 0;
const irq = parseInt(arr[6], 10) || 0;
const softirq = parseInt(arr[7], 10) || 0;
// var steal = parseInt(arr[8],10) || 0;
// var guest = parseInt(arr[9],10) || 0;
// 注:计算使用率
const total = user + nice + system + idle + iowait + irq + softirq;
const used = user + nice + system + irq + softirq;
const curr = Math.round((used - cache.used) / (total - cache.total) * 100);
cache.curr = curr;
cache.total = total;
cache.used = used;
});
return cache.curr;
};
这个功能实际上也不复杂,但是有一点是需要修正的,在windows系统下,如果非中国大陆地区的服务器,则会判断不出来ip。(不过看起来影响不大?)
// 注:本文件可以获取当前服务器信息(主要是IPV4的信息)。
const os = require('os');
const { isWin32Like } = require('util/isWindows'); // 注:判断操作系统
const isInnerIP = require('util/http.isInnerIP.js'); // 注:判断是否是inner ip,判断ip的类型(A类,B类,C类)
this.intranetIp = '127.0.0.1';
if (isWin32Like) {
this.intranetIp = getWinLocalIpv4();
} else {
this.intranetIp = getLinuxLocalIpv4();
}
// 注:获取linux系统下的IPV4地址
function getLinuxLocalIpv4() {
let intranetIp = '';
const networkInterfaces = os.networkInterfaces();
// 注:遍历networkInterface对象的所有属性
Object.keys(networkInterfaces).forEach(function(key) {
const eth = networkInterfaces[key];
const address = eth && eth[0] && eth[0].address;
if (!address) {
return;
}
const tmp = isInnerIP.isInnerIP(address);
if (!tmp) {
return;
}
// 注:如果127开头,是环回地址,直接不返回
if (tmp.startsWith('127.')) {
return;
}
intranetIp = address;
});
return intranetIp;
}
// 注:获取windows下的IPV4地址
function getWinLocalIpv4() {
const localNet = os.networkInterfaces();
let key,
item;
let v,
i;
let userIp = null;
for (key in localNet) {
item = localNet[key];
// 注:和上面获取linux的IP地址类似,如果是环回地址,则直接返回null,反之,返回IP地址。
// 注:但这种判断有一个问题,如果非中国大陆地区的服务器,则会判断不出来ip。
if (String(key).indexOf('本地连接') > -1) {
for (i = 0; i < item.length; i++) {
v = item[i];
if (v.family === 'IPv4') {
userIp = v.address;
return userIp;
}
}
}
}
}
基本就是这样:)