TSW(Tencent Server Web)源码阅读指南

公羊灿
2023-12-01

写在前面:

TSW作为一款提供染色抓包、全息日志、异常发现与处理的开源基础设施,不论是在鹅厂内部还是在其他开发团队里,都有着比较广泛的应用。但是TSW还是有一些美中不足的地方,比如文档体验较差,造成在使用TSW、阅读TSW源码时存在一定的障碍。因此在这里开个坑,写一份TSW的源码阅读指南,帮助大家更好地了解TSW的设计思路和具体实现。

本指南采用TSW 2019.7 开源版本的master分支进行讲解。
项目GitHub地址:https://github.com/Tencent/TSW
TSW官方文档:https://tswjs.org/guide/index

在源代码中所有以“注:”开头的注释笔者自己添加的注释,通过阅读这些注释,会对整体代码结构有更深的理解。

TSW目录结构

  • bin
    • plug
    • proxy
    • tsw
    • wwwroot
  • conf
  • examples
  • locales
  • static
  • test

TSW的主要功能的源代码都在/bin文件夹下,其主要功能(染色抓包、全息日志、异常处理)的源代码都集中在/bin/tsw文件夹下,所以在本文中,我们会结合TSW的文档,详细解读/bin/tsw下的源代码。

一、TSW全局变量相关(plug,context,window)

这部分主要先介绍一下tsw中plug、context、window的实现,首先放官方文档对于全局变量的链接:https://tswjs.org/doc/api/global

1.1、plug

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;

结合注释,阅读起来没太多的难点,主要是针对“<”字符进行的转义,这个细节在编程的时候一定要注意。

1.2、content与window

content是tsw提供的一种上下文对象。

  • 特性:
  • 供TSW内部使用,生命周期与request绑定
  • context 等价与 global.context
  • 同一个request生命周期内只能访问自己的context对象

window是tsw提供的另一种上下文对象

  • 特性
  • 供业务使用,生命周期与request绑定
  • window 等价与 global.window
  • 同一个request生命周期内只能访问自己的window对象

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的实现依赖两个文件:

  • runtime/Context context对象的具体定义
  • runtime/Window window对象的具体定义

接下来我们详细看看这两个文件:
/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路由相关(router)

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();
    }

}

在这里简要说下部分主要功能的实现方式:

  • webso柔性部分:检查cpu使用率,超过限制直接返回304使用协商缓存
  • XSS防范部分:对reg,&,",<,>等字符做了一个转码 -> 再转回来的操作(这部分逻辑在/bin/tsw/util/xss.js里)

三、TSW日志相关(logger)

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();
            }
        }
    },

四、TSW工具箱

这里简单介绍一下计算cpu使用率功能、获取IP地址功能的实现。

4.1 CPU使用率

在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;
};

4.2 获取IP

这个功能实际上也不复杂,但是有一点是需要修正的,在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;
                }
            }
        }
    }
}

基本就是这样:)

 类似资料: