最近在用quic-go写个代理.
开始的想法是兼容socks5协议之后,透明地端对端.
只不过传输部分从tcp变为quic.
因为看接口是兼容io.ReadWrite的.
所以最简单的想法就是直接copy到服务端之后再考虑协议解析.
写了一部分之后发现socks5协议其实算是支持重定向的.
response部分带有bind的地址和端口信息.
理论上client端应该是利用这个信息重新连接到指定地址和端口做流量copy的.
这样的话从协议角度,client端还是会利用tcp去连接对应服务端口.
所以从协议角度来说,需要让这段response里的IP/port信息是本地的端口.
也就是一般实现的,socks5的client会在这个response后复用现有连接.
那么这里的一个问题是,服务端可能并不知道这个local listening的端口是什么.
当然,这里可以约定一个固定端口.
但是更flexible的是能够有一个机制适应这端口.
所以无非就是intercept一些协议,把本地端口信息给对端, 然后在原始协议里echo回来.
要么是local端在解析response的时候modify回本地信息.
但这样的话就意味着两端有一个协议上的握手阶段.
以为遵循的是原始socsk5里的握手协定.
当然,实现上可以把modified的response speculate地返回给发起方,然后再去连接对端.
因为实际上如果能正常连接的话,返回的响应内容是预先知道/固定的.
但这里但问题就是,其实就没有必要跟对端但协议也是走socks5了.
因为如果把socks5的整个流程放在本地,local完成之后再起一套新的协议走quic的话就简单很多.
所以目前的做法是在本地走完socks5的流程之后,再简单地把目标host和port通知quic,然后就直接互相copy后续的流量了.
因为目前这个协议是没有做校验/鉴权的.
所以理论上来说从协议角度,可以去连接比如内网的机器.
当然,这个对于socks5的非鉴权模式也是有这个问题.
而这类tunnel服务的另外一个可能更现实一点的问题就是,如果能把tunnel目标的信息部分更改/替换为特定的服务ip端口的话,实际是很容易定点发现的.
因为只要这个服务目标单一,凡是连接过来的都是susceptive的.
连接内容加密的话或者说明文部分特征不明显的话,相对来说问题没那么明显.
但问题可能就是加密协议的白名单机制了.
另外一个问题就是,想tcp这类有状态的协议,通过构造异常中断连接回比较轻松.
以为需要关注的包的数量不会太多.
但是udp的话因为本身无状态,所以如果要track的话成本会比较高.
因为像quic这类encapsulate可以无限做下去,只要还有允许的协议存在.'
所以最坏的情况可能就是udp整个被去掉.
当然,理论上来说也可以把类似udp的逻辑over tcp,同样embed在合法的协议里面.
如果是到这样一个地步的话,可能就是服务注册制了.
未经允许的服务不得运行,保证协议的可读性和可控豁免.
某种程度上目前的网站备案制就是了.
像云服务上未备案的对应http服务等.
这些其实没什么好说的.
毕竟也没有什么解决方案.
只是个纯粹问题.
另外一些就是关于go本身的一些体验或者说不太便利的地方吧.
原则上来说,go的设计会比较适合做服务端尤其是这类网络方面的应用.
因为netpoller和syscall的隐式coroutine调用会使得写的时候认知负担没那么重.
比如如果是java等写网络io,通常需要select配合线程调度去把io尽可能地并发处理起来.
而go则是把这类syscall的G调度suspend,等ready的时候再resume回来.
虽然这类行为在其他语言上也不是不能实现模仿.
但是也只是针对network的一个特殊场景.
以及局限在语言和库提供的表达能力上.
但是像http.get之类的就依赖于库的表现了.
而且从一般调用上来说,即使提供了异步接口,还是需要写一些逻辑去做简单调度.
比如,
// search something
result = http.search()
// audit log
audit.log(event)
其中search是blocking的.
也就是意味着当前线程是被block的.
而一般这个线程会是属于一个有限数量的线程池资源里的一个.
block的结果就意味着可能负载能力的一个限制.
变通的一种方式是
http.server()
.then(()->audit.log())
这类promise/future的回调方式.
但它本质上也是需要对应blocking的底层是提供了类似epoll这类有限blocking的实现的.
像如果里面涉及到文件io读写的话,又不是pollable的话,就是block谁/哪个线程池的问题了.
而这里的认知负担就在于需要比较清楚一个方法调用的开销和block状态.
以便是否需要做异步化.
这个就是go的syscall调度的隐式coroutine的一点优势了.
runtime层面就会做这些关键调用的suspend和resume.
所以可以比较不用太关心这类资源的使用问题.
但是goroutine的问题在于,它形态上是一样完全异步的过程.
一旦go出去之后是没什么控制权的.
而有时这种有时需要的.
比如
go rpc(endpoint1)
go rpc(endpoint2)
这种race访问同样服务的不同实例,以期减少/抹掉服务异常导致的tail latency的场景.
你需要得到早返回的一个,同时尽可能地cancel另外一个.
以标准库的方式的话,可能就需要
go rpc(endpoint1,chan1,ctx1)
go rpc(endpoint2,chan2,ctx2)
select {
case <-chan1: ctx2.cancel()
case <-chan2: ctx1.cancle()
}
而这种场景下,其他语言可能就是
promise.race(
promise.ask(()->rpc(endpoint1)),
promise.ask(()->rpc(endpoint2))
).join()
当然,后者这里可能只是库封装程度的问题.
但是考虑如果要在go里实现类似的风格的话.
你需要额外的ctx和chan对象,或者合并为一个struct.
所以最终形态可能是
ctx := ...
promise = Go(func(){rpc(endpoint)},ctx)
如果还需要又返回值和异常信息的话,ctx需要带的东西会更多.
目前标注库里的context接口是之后err和一个等价join/wait的Done() channel的.
所以如果要cover场景的话,至少还得扩展出cancel和value等部分.
而更麻烦的一点就在于,go没有范型 .
所以ctx对不同value要么需要单独适配,要么一个interface做type assert.
以及标准的error结构更多只是一个message的作用,你需要有其他机制/风格去带上堆栈信息.
比如直接堆栈成string带过去.
或者有在err的时候附带print stacktrace的风格.
当然,这里的范型问题只是个编译器问题.
就目前来说go的compiler生成的代码也不少.
做一个类型生成应该也不是太大的问题.
就像现在实际上对每个struct的value function都有生成一个对应的pointer value的版本的.
只要有用到的话.
由于没有运行期动态生成代码的方式,所以实际做类型擦除之类的应该也没什么问题.
毕竟像interface本身实现的机制就有点类似了.
其他的一些认知负担可能就是pass by value的一些点了.
因为如果pass by value,那么调用之间都是copy by value的.
像有深度调用链条,而每一层只需要一部分的就有可能逐步地下层少一些字段/小一些的结构.
所做的只是栈上/gstack上的动作.
理论上来说除了一些特殊的slice/map/chan之类的对象的话,其他都可以栈上解决.
没有/比较少gc的问题.
但是因为前面一些问题的存在,直观上写不逃逸的代码会比较麻烦或者说恶心.
尤其像匿名function,需要多一层参数传递.
而且有时候不看compile optimization decision也想不到.
而这么做的效果/代价其实也可能根本划不来.
一个是维护成本/可读性问题.
一个是拷贝的累计代价问题.
所以这个怎么说呢,也是一个问题和解决方案的事情.
大多数时候其实也不太现实.
只不够可能是确实有解决方案存在而已.
网友评论