2020-08-23

问题与问题

最近在用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也想不到.

而这么做的效果/代价其实也可能根本划不来.
一个是维护成本/可读性问题.
一个是拷贝的累计代价问题.

所以这个怎么说呢,也是一个问题和解决方案的事情.
大多数时候其实也不太现实.

只不够可能是确实有解决方案存在而已.


聊聊卡布里尼

最近看了部片叫卡布里尼,算是可能这段时间来比较有意思的一部电影. 故事也不算复杂,就是一个意大利修女去美国传教,建立慈善性质医院的故事. 某种程度上来说,也很一般的西方普世价值主旋律. 但是如果换一套叙事手法,比如共产国际的社会主义革命建立无产阶级广厦千万间的角度来看的话,也不是...