【云+社区年度征文】腾讯防疫健康码-远程协作环境优化
背景
由于年初新冠疫情爆发,我参与了腾讯防疫健康码的项目研发工作中。疫情健康码项目无疑是非常成功的,它覆盖9亿+人口和300+市县。但是项目的研发过程确实非常艰辛,该项目团队成员是在疫情期间临时组建起来的。疫情健康码项目研发团队由腾讯云同学主导+腾讯志愿者协助+合作伙伴公司的同学组成。大家都是远程在家办公,因此工作中也遇到了一系列的问题。还好有腾讯众多产品的保驾护航,才让项目能够高效成功落地,下面我从个人的研发视角剖析一下远程办公项的痛点,以及我们是怎么解决问题的。
跨公司远程研发团队的痛点
- 沟通低效,远程沟通以IM聊天工具为主,例如:微信;
- 项目协作困难,一般工作内部团队有内部的项目管理工作,但跨公司协时却不能用谁家内部的系统;
- 版本控制工具,代码版本控制工具不好选,要既可以远程团队共享权限,也可以保证代码安全;
- 开发调试环境,要解决远程开发调试代码、查询数据库、日志等问题。
上述的几个问题大部分通过腾讯的Saas产品很好的解决了。例如:
- 企业微信,打通个人微信和企业微信,我们和外部合作时,不需要添加个人微信,使用企业微信即可沟通、拉群等;
- TAPD,一站式敏捷研发协作云平台, 免费高效、很好的解决了敏捷开发的需求管理、任务管理、状态流转、项目文档共享沉淀等需求,支持企业微信登录;
- 腾讯文档,腾讯文档支持在线多人同时编辑协作和多种文档格式,且可以设置安全权限,很好的项目资料共享和项目灵魂管理的问题,支持微信扫描等登录;
- 腾讯工蜂,代码版本控制工具,完全兼容github,免费高效,权限可以自定义,支持微信扫描等登录。
- 腾讯会议,腾讯会议支持远程多人语音、视频、共享桌面等,且免费高效、稳定,很好的解决了我们的远程会议需求。
上述的几个产品在我们项目中频繁使用,对我们的项目研发管理协作起到了非常积极的促进作用。
但是远程办公对开发同学还是不友好的,我们使用腾讯的云产品作为项目的开发环境,例如:mysql、redis、es等存储服务。很多开发同学习惯了本地调试代码,即本地起应用连腾讯云的存储服务,使用腾讯云产品作为开发环境时,需要解决公网用户连接腾讯云网络连通权限。例如用户开发的应用A,需要连接mysql 和 es 存储服务来开发调试,那么需要给用户开通他个人出口IP到mysql 和 es 的白名单和3306端口以及9200端口权限。那么可能存在这么几个问题:
- 个人家庭的网络出口IP大部分是动态分配的,每天都会变更,导致IP白名单每天要手动更新;
- 部分腾讯云产品只能支持开通有限数量的白名单IP,例如es最多只能同时配置10个IP地址,导致操作10人的团队不能同时使用ES,且经常要动态调整;
- 共享出口IP的网络会增加网络安全风险。
怎么解决开发的网络问题
问题核心在于远程办公大家都在公网环境,腾讯云服务不能对公网完全开通安全策略,这和裸奔没啥区别。怎么解决呢?因为我之前在研发网关产品,所有我首先想到的就是准入网关的方式来解决,也可以理解成安全网络代理,如下图:
- 找一台有固定出口IP的云服务器作为准入网关;
- 腾讯云服务对网关开放IP和指定端口权限;
- 客户端请求先到网关进行安全验证;
- 验证后转发到指定权限的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代理,配置客户端将需要连的中间件都走到代理,工程代码不需要任何配置和代理的改动。但是有两个问题:
- 软件可能收费软件,需要自己想办法激活;
- 只支持使用代理的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、网关认证用户来源是否合法。
上述的实现只是简单的认证了签名的方式,我们还可以拓展自定义更多灵活的安全策略。到此开发环境可以比较方便且安全的连上腾讯云服务了。
总结
- 我的本意是希望http(s), socket 通信都通过我搭建的HTTP代理路由, 所以设置了这三种通信的代理都指向一个host&port, 虽然说其他socket通信业可以通过http隧道转发,但是一些客户端(mysql、redis)不会应用到system.setPropety, 导致设置的代理无效;
- es client的实现也是有些问题,跟进去看请求开始是选到了http做代理,可对代理创建socket的时候还会选一次代理因为又配置了socks proxy,所有代理又使用了代理来进行转发,而我搭建的代理是http的,所有最后看上去都用了socks proxy,都会失败;
- 我这边使用httpclient4 配置sockets 就直接生效了使用es client 不知道为何一只不生效,必须在配置一次http代理;
- 还有没有解决的问题就是jdk启动参数使用代理的用户名和密码一直设置无效,必须自定义入侵项目的方式实现;
- 使用自定义的方式监听本地端口的方式来实现最好用,不仅仅是程序可以使用网关,我们使用的任何msyql客户端、浏览器等都可以支持,用户无感知且安全。