【云+社区年度征文】腾讯防疫健康码-远程协作环境优化

背景

由于年初新冠疫情爆发,我参与了腾讯防疫健康码的项目研发工作中。疫情健康码项目无疑是非常成功的,它覆盖9亿+人口和300+市县。但是项目的研发过程确实非常艰辛,该项目团队成员是在疫情期间临时组建起来的。疫情健康码项目研发团队由腾讯云同学主导+腾讯志愿者协助+合作伙伴公司的同学组成。大家都是远程在家办公,因此工作中也遇到了一系列的问题。还好有腾讯众多产品的保驾护航,才让项目能够高效成功落地,下面我从个人的研发视角剖析一下远程办公项的痛点,以及我们是怎么解决问题的。

跨公司远程研发团队的痛点

  1. 沟通低效,远程沟通以IM聊天工具为主,例如:微信;
  2. 项目协作困难,一般工作内部团队有内部的项目管理工作,但跨公司协时却不能用谁家内部的系统;
  3. 版本控制工具,代码版本控制工具不好选,要既可以远程团队共享权限,也可以保证代码安全;
  4. 开发调试环境,要解决远程开发调试代码、查询数据库、日志等问题。

上述的几个问题大部分通过腾讯的Saas产品很好的解决了。例如:

  • 企业微信,打通个人微信和企业微信,我们和外部合作时,不需要添加个人微信,使用企业微信即可沟通、拉群等;
  • TAPD,一站式敏捷研发协作云平台, 免费高效、很好的解决了敏捷开发的需求管理、任务管理、状态流转、项目文档共享沉淀等需求,支持企业微信登录;
  • 腾讯文档,腾讯文档支持在线多人同时编辑协作和多种文档格式,且可以设置安全权限,很好的项目资料共享和项目灵魂管理的问题,支持微信扫描等登录;
  • 腾讯工蜂,代码版本控制工具,完全兼容github,免费高效,权限可以自定义,支持微信扫描等登录。
  • 腾讯会议,腾讯会议支持远程多人语音、视频、共享桌面等,且免费高效、稳定,很好的解决了我们的远程会议需求。

上述的几个产品在我们项目中频繁使用,对我们的项目研发管理协作起到了非常积极的促进作用。

但是远程办公对开发同学还是不友好的,我们使用腾讯的云产品作为项目的开发环境,例如:mysql、redis、es等存储服务。很多开发同学习惯了本地调试代码,即本地起应用连腾讯云的存储服务,使用腾讯云产品作为开发环境时,需要解决公网用户连接腾讯云网络连通权限。例如用户开发的应用A,需要连接mysql 和 es 存储服务来开发调试,那么需要给用户开通他个人出口IP到mysql 和 es 的白名单和3306端口以及9200端口权限。那么可能存在这么几个问题:

  1. 个人家庭的网络出口IP大部分是动态分配的,每天都会变更,导致IP白名单每天要手动更新;
  2. 部分腾讯云产品只能支持开通有限数量的白名单IP,例如es最多只能同时配置10个IP地址,导致操作10人的团队不能同时使用ES,且经常要动态调整;
  3. 共享出口IP的网络会增加网络安全风险。

怎么解决开发的网络问题

问题核心在于远程办公大家都在公网环境,腾讯云服务不能对公网完全开通安全策略,这和裸奔没啥区别。怎么解决呢?因为我之前在研发网关产品,所有我首先想到的就是准入网关的方式来解决,也可以理解成安全网络代理,如下图:

image.png
  1. 找一台有固定出口IP的云服务器作为准入网关;
  2. 腾讯云服务对网关开放IP和指定端口权限;
  3. 客户端请求先到网关进行安全验证;
  4. 验证后转发到指定权限的IP和端口。

网关的转发安全策略可以用如下伪代码表示:

let urls = {
"msyql_host:3306": 1,
"es_host:9200": 1
}
let user = {
        "userA":"tokenA",
         "userB":"tokenB",
}
let ip = {
        "ipA": 1,
        "ipB":1
}
if (urls[req.url] && (authUser(req) || ip[req.remoteAddress])) {
        socket.connect(req.url); // 连接目标服务
        socket.pipe(req.socket); // 管道转发
}

function authUser(req) {
        // 可根据客户选择的算法支持,basic auth 或其他自定义算法判断请求是否合法,return 1 or 0
}

可以直接去找一些开源代理来实现网关功能。

上面网关完成部署之后,我们的代码怎么使用呢?

假设网关支持基本认证(Basic access authentication)和自定义ip白名单的方式(自定义白名单至少解决ip数量的限制), 由于参与的防疫小程序项目使用的是Java作为研发语言,我首先想到的是配置,配置jdk参数方式让应用程序请求远程网络时使用代理。

配置jvm启动时参数

指定使用代理通信: -DsocksProxyHost=xxx.xxx.xxx.xxx -DsocksProxyPort=1080 -Djava.net.socks.username=xxx -Djava.net.socks.password=xxx 详细可以参考:

jdk8: https://docs.oracle.com/javase/8/docs/technotes/guides/net/proxies.html

jdk11: https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/doc-files/net-properties.html

找到Jdk源码配置参数使用的类是:DefaultProxySelector.java,由于我们用的是jdk8,因此不支持配置代理基本认证即配置:用户名和密码设置无效,也可以自定义实现Authenticator类,但这种方式会侵代码,下面是JDK11中DefaultProxySelector.java 设置用户名和密码的代码片段。

Authenticator.setDefault (new Authenticator() {

   protected PasswordAuthentication getPasswordAuthentication() {

   return new PasswordAuthentication ("username", "password".toCharArray());

   }

});

由于上述原因就放弃基本认证方式,而是到网关验证客户端ip。

代理之后es 客户端还是有问题,我这边用的ES客户端版本如下:

<dependency>
    <groupId>org.elasticsearch.client</groupId>
	<artifactId>elasticsearch-rest-high-level-client</artifactId>
	<version>6.4.3</version>
</dependency>

这个版本的client请求es没有走代理,初次发现问题是es实例化http客户端时,没有用到系统属性,需要显示调用,代码片段如下:

builder.setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback() {
    @Override
    public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) {
        httpClientBuilder.useSystemProperties(); //显示调用使用系统属性。
        return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
    }
});

显示调用之后,由于es客户端是http请求,还需添加jvm情动参数 -Dhttp.ProxyHost=xxx.xxx.xxx.x xx-Dhttp.ProxyPort=8080 , es客户端才能正常使用代理。

使用第三方软件客户端代理

例如:proxifier代理软件,该方式最方便,直接使用网关作为http代理,配置客户端将需要连的中间件都走到代理,工程代码不需要任何配置和代理的改动。但是有两个问题:

  1. 软件可能收费软件,需要自己想办法激活;
  2. 只支持使用代理的basic auth方式验证,安全性一般。

自己开发客户端代理映射

该方式自己开发网关客户端,监控本地指定端口,转发到http代理上,这种方式需要将工程里的中间件配置修改指向本地127.0.0.1和映射的端口,如果是https的中间还需要配置hosts,但该方式可以自定义安全策略,灵活自己设计签名算法,较为安全。客户端实现代码片段如下:

const map = {
        {
  "3306": {
    "proxy": "gateway.com:443",
    "auth": "", //认证签名串
    "target": "mysql_host:3306"
  },
  "9200": {
    "proxy": "gateway.com:443",
    "auth": "", //认证签名串
    "target": "es_host:9200"
  }
}
for (const port in map) {
  const cfg = map[port];
  const pxyopt = (i => ({ host: i[0], port: i[1]}))(cfg.proxy.split(':'));
  const server = net.createServer(socket => {
    if (pxyopt['port'] !== '443') {
      const proxy = net.connect(pxyopt, () => {
      var timestamp = (Date.now() / 1000).toFixed();
      var random = Math.floor(Math.random() * 10000);
      var result = signature(cfg.auth, random, timestamp);
      var pxyauth = result ? `Proxy-Authorization: Basic ${result}\\r\\n` : '';
      proxy.write(`CONNECT ${cfg.target} HTTP/1.1\\r\\nHost: ${cfg.target}\\r\\n${pxyauth}\\r\\n`);
      proxy.once('data', d => {
        let s = d.toString();
        if (s.startsWith('HTTP/1.1 200 ')) {
          setImmediate(() => socket.pipe(proxy).pipe(socket));
        } else {
          proxy.destroy(new Error(s));
        }
      });
      });
      var onerr = err => {
        socket.destroy();
        proxy.destroy();
        log(err.message);
      };
      socket.on('error', onerr)
      socket.setTimeout(TIMEOUT, () => proxy.destroy(new Error('timeout')));
      proxy.on('error', onerr);
      proxy.setTimeout(TIMEOUT, () => proxy.destroy(new Error('timeout')))
    } else {
      var timestamp = (Date.now() / 1000).toFixed();
      var random = Math.floor(Math.random() * 10000);
      var result = signature(cfg.auth, random, timestamp);
      var options = {
        hostname : pxyopt['host'],
        port     : pxyopt['port'],
        path     : cfg.target,
        method     : 'CONNECT',
        headers: {
          'Proxy-Authorization': `Basic ${result}`
        }
      };
      var req = https.request(options);
      req.on('connect', function(res, skt) {
        setImmediate(() => socket.pipe(skt).pipe(socket));
        socket.on('end', function() {
          console.log('socket end.');
        });
        var onerr = err => {
          socket.destroy();
          skt.destroy();
          log(err.message);
        };
        socket.on('error', onerr)
        socket.setTimeout(TIMEOUT, () => skt.destroy(new Error('timeout')));
        skt.on('error', onerr);
        skt.setTimeout(TIMEOUT, () => skt.destroy(new Error('timeout')))
      });
      req.end();
    }
   
    
  }).listen(port, '127.0.0.1', () => console.log(`127.0.0.1:${port} => ${cfg.proxy} => ${cfg.target}`));
  server.timeout = TIMEOUT;
}

function signature(appkey, random, timestamp) {
// 自定义签名算法
}

最后我们研发团队使用了自研客户端的方式,nodejs实现,支持可以打包成mac、linux、windows 等多平台运行。使用起来也方便。

1、我们先给每个项目研发成员分配个人的auth签名;

2、将每个人的auth签名配置到网关上;

3、网关认证用户来源是否合法。

上述的实现只是简单的认证了签名的方式,我们还可以拓展自定义更多灵活的安全策略。到此开发环境可以比较方便且安全的连上腾讯云服务了。

总结

  1. 我的本意是希望http(s), socket 通信都通过我搭建的HTTP代理路由, 所以设置了这三种通信的代理都指向一个host&port, 虽然说其他socket通信业可以通过http隧道转发,但是一些客户端(mysql、redis)不会应用到system.setPropety, 导致设置的代理无效;
  2. es client的实现也是有些问题,跟进去看请求开始是选到了http做代理,可对代理创建socket的时候还会选一次代理因为又配置了socks proxy,所有代理又使用了代理来进行转发,而我搭建的代理是http的,所有最后看上去都用了socks proxy,都会失败;
  3. 我这边使用httpclient4 配置sockets 就直接生效了使用es client 不知道为何一只不生效,必须在配置一次http代理;
  4. 还有没有解决的问题就是jdk启动参数使用代理的用户名和密码一直设置无效,必须自定义入侵项目的方式实现;
  5. 使用自定义的方式监听本地端口的方式来实现最好用,不仅仅是程序可以使用网关,我们使用的任何msyql客户端、浏览器等都可以支持,用户无感知且安全。
本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
【云+社区年度征文】腾讯防疫健康码-远程协作环境优化
由于年初新冠疫情爆发,我参与了腾讯防疫健康码的项目研发工作中。疫情健康码项目无疑是非常成功的,它覆盖9亿+人口和300+市县。但是项目的研发过程确实非常艰辛,该...
<<上一篇
下一篇>>