你的Linux上有一个超酷的 TCP 代理!

TL;DR 本文只用 Linux 自带的工具实现了一个可以将 TCP 流量打印出来的中间人代理程序(透明的)。脚本来源于这里。本文是对其工作原理的解释。

 

一般我们想知道一个 TCP 连接收发的内容时,会用 wireshark/tcpdump 这种工具,来把包抓下来分析查看。我开发 IRedis 的时候,就经常有这种需求,需要看看我的客户端对 server 到底发送了什么和接收了什么。用抓包工具不太方便,信息比较杂乱,操作也比较繁琐。在服务器上部署抓包工具的时候,还要给这些工具特殊权限。

其实这种需求可以不抓包的。因为 Redis 这种程序(RESP3协议)是基于 TCP 的,我们可以做一个代理,将发往 redis-server 的流量打印出来(输出到 stdout),然后再发给 redis-server。将 redis-server 的 Response 打印出来,再返回给客户端。相当于是一个中间人。

实现正向代理和反向代理都可以,反向代理不需要客户端支持,只是开一个端口,将所有发往这个端口的流量转发到 redis-server,所以我们这里用反向代理来实现。

在这篇文章中,我们只用 Linux 自带的工具,nc 来实现这个代理。还要用到 named pipe(因为 Linux 的 pipe 只支持单项传递,这里我们需要一个双向的代理),sed(用来对输出稍作格式化),trap (负责在退出的时候清理 named pipe)。

工作原理

我可以用一个最小化的版本解释这个中间人代理的工作原理,然后我们再来处理格式化输出。

这个中间人代理只需要两个 nc ,一个负责接收客户端那边的输出,将这个输入传给另一个 nc;另一个 nc 接收前一个 nc 的输入,然后传给 Server(比如 redis-server)。Server 传回来的信息也一样,先进入后一个 nc (后面称呼它为 nc2 好了),然后传给前一个 nc (后面叫做 nc1)。在 nc1 -> nc2 和 nc2 -> nc1 的过程中,就可以把传递的东西给打印出来了。

我们先写一个简单的版本:

这一行是监听 8800 端口,把收到的内容通过管道发给 nc2,然后 nc2 将内容发给 kawabangga.com 的 80 端口。其实就是一个 HTTP 代理啦。

用 curl 来试一下:

注意这个 curl 是卡住了,并没有信息返回。

而两个 nc 那一行的输出是:

可以看到,第二个 nc 把服务器的返回直接输出到 stdout 了,而并没有返回给 curl。这样的代理是不合格的,因为我们不想夺走本该属于 curl 的 response。

一个优秀的代理应该完整的将服务器的回复还给客户端(curl),像下面这样。至于 stdout 的问题,我们先不管。

Linux 的 pipe 只支持从一边传给另一边,是单向的。那怎么做到后面的进程往前面的进程传呢?答案就是 namedpipe。Named pipe,嗯……顾名思义,就是有名字的管道。在本文中,理解成有了这个名字,我就可以控制重定向的方向,就好了。

我们用这一行,就完全可以实现图2那种传输。即 nc2 通过 named pipe 传给 nc1 ,nc 默认会将自己的 stdin 的内容传回给连接到 nc 的 TCP 另一头。

用 curl 再来试一下:

而 nc 那个命令行没有任何输出。

中间人打印TCP内容

OK,代理已经工作了,接下来要做的是将 TCP 内容打印出来。这里要做的是 nc 在将内容输出到 stdout (即管道)的同时,还要打印出来。

……当然是要用 tee 啦!

这样可以用 tee 将 TCP 流量分别保存到 in.txt 和 out.txt 里面。可以用上面用过的 curl 测试一下。

因为 stdout 会被管道重定向,所以我们这里无法直接输出到屏幕了。那咋办呢?一不做二不休,再用 named pipe 将 tee 写入到 named pipe 中。

curl 之后,用 cat logging 就能看到 TCP 的内容啦。(这里如果不使用 cat 读出来 pipe 的内容,curl 会被 block 住)。

最终的脚本

最后,加点细节。写个脚本自动生成 named pipe,使用 trap 在退出的时候把创建的 named pipe 删掉。用 sed 格式化一下输出,分别加上 => 和 <=

 

代码如下(BSD 的 nc,适用于 OS X):

 

nc 的 BSD 版本和 GNU 版本不一样。如果你用的不是 Mac,要改下最后一行,加一个 -p. GNU 版本的代码如下:

 

使用效果如下(还是用 curl 测试):

 

作为 redis-server 的代理效果:

Pretty cool uh? Now I am going to drink some tea!

 

Vim LSP 配置

用了好几年的 YouCompleteMe,体验还是不错的,最爽的一点是能基于 Token 补全在当前 buffer 中作补全,任何语言用 Vim 就有一个“基本可用”的体验,即使写 git commit message 的时候也很好用。

但是最近玩一些神奇的东西,YouCompleteMe 如果没有支持这种语言的话,就没办法做到一个比较好的体验了。

对于一种语言,YouCompleteMe 都要去实现这种语言的支持,才能起作用。每增加一种语言,YouCompleteMe 都要增加一种工作量。不光 YouCompleteMe,像 Emacs,VS Code,都要去实现每一个语言的自动补全。这就形成了很大的浪费:每一种编辑器要去自己实现每一种语言。如果有人写了一个新的语言想要其他编辑器支持,需要一个一个实现编辑器的补全工具。

这就形成了一种交叉关系,假如有三个语言,三个编辑器存在,就需要9个补全的实现。

所以 VS Code 就提出了 “Language Server” (简称 LSP)这种先进的概念。这是一种 Client-Server 架构,每个语言实现自己的 Language Server,每个编辑器去实现自己对接 Language Server 的前端。这样一个语言只需要实现一次,就可以支持所有的编辑器。上面那种情况,只要有3个语言的补全实现+3个编辑器自己的 Language Server 实现就可以了。

其实 YouCompleteMe 也支持 LSP 的,但是配置上看起来资料比较少,像是不是支持的特别好,所以我索性放弃 YCM 了,转向了 vim-lsp

配置 vim-lsp

这里以 Python 为例,介绍一下配置方法。

首先需要安装 Python 的 Language Server。推荐使用 pipx 安装。

然后修改 .vimrc 的配置修改,以下是一个最小化配置:

安装的插件需要4个,下面分别介绍一下这四个是干什么用的。

async.vim

因为补全是支持 Vim 和 Neovim 的,async.vim 封装了两个 Vim Async 相关的接口。

asyncomplete.vim

利用 async.vim 做的补全,相当于一个补全引擎。(到这里是和 lsp 还没有什么事儿,配置的都是补全插件。)

这个补全只是负责补全引擎,具体的补全还是要交给依赖此引擎的实现。比如我们想要对路径进行自动补全,需要先安装这个插件,再安装基于此插件实现的路径补全插件,最后对补全引擎注册这个插件,才能使用。安装路径补全插件和注册的配置如下。

还有很多挺有意思的补全,比如这个 tmux-completion,是基于 tmux 的窗口 buffer 来补全,在 vim 中抄写 tmux 里面的 git hash,URL 啥的挺有帮助。但是我没找到禁用它的方法,导致它会一直弹出来,太烦人了,就没用。

vim-lsp

是一个与 LSP 交互的插件,类似于一个 Vim 的 SDK。

asyncomplete-lsp

将 lsp 中的内容交给 asyncomplete 做补全。

 

后面的两个配置,一个是设置用 Tab 触发补全(判断补全窗口有没有打开,有打开的话就将tab键映射为补全选择键。

然后注册 Python 的 LSP。每一个 LSP 都需要注册。

效果如下:

配置好这些插件之后,再配置其他语言的 LSP 就简单多了。分成两步:

  1. 安装该语言的 LSP,这里有一个列表
  2. 在 Vim 中注册该 LSP;

以 Bash 为例,我们首先按照仓库的介绍,安装该语言的 LSP:

然后在 .vimrc 中用以下配置注册:

换成其他语言的话,只要将 cmd 换成 LSP 的启动命令,然后通过 whitelist 或者 blacklist 设置生效的文件类型就好了。

 

Snip 的支持

Language Server 支持补全一段代码模板,比如写 Elixir 的时候自动补全 defmodule 。但是 vim-lsp 本身是不支持的,snip 在 vim-lsp 中会补全成这个样子:

将这个片段正确的显示在 vim 中,并且支持跳转,需要通过插件来解析 Language Server 返回的片段。

这里又涉及到 N 多插件。首先你需要一个代码片段引擎,负责在 Vim 中生成片段,在片段中需要填写的字段中进行来回跳转,补全。之前在博客中介绍过 Ultisnips ,这里就以它为例。

除了 SirVer/ultisnips 本身,还要安装两个插件来将 LSP 中的 snip 补全到 vim 中来。总共需要安装的插件如下:

然后有一个小坑需要注意,Snip 必须确认选中才能展开,也就是菜单中将焦点移动到这个 Snip 上去之后,需要按 Ctrl + y 来展开。

但是这个快捷键可以更改,比如我换成了 Ctrl + o

 

 

单页应用的登陆验证方式

前端流行的单页应用(SPA),带来了很多体验上的优化,也带来了很多问题。以往我们每点击一次鼠标,页面就会在后台生成一次,很多事情都会很简单。但是到了单页应用,相当于访问页面的时候只请求了页面一次,后面的数据、表单等都是通过 AJAX 的,页面的渲染由前端完成,给后端带来了很多挑战。

比如说登陆验证,传统的页面每次请求都会经过后端判断一下 Cookie 中携带的信息,如果用户没有登陆,就将用户重定向到一个登陆页面。

但是单页应用一般是页面先渲染出来,然后通过 API 访问后端,虽然鉴权方式还是通过访问 API 的时候携带 Cookie,但是用户将会先看到页面加载出来,然后访问 API 得到 403,跳转到登陆页面,体验就不太友好了。(应该是页面都渲染不出来,直接跳到登陆)。

今天用了一个方法,Nginx 配合后端的应用,来解决了这个问题。这篇文章来分享一下原理。

Before:

  1. 用户访问页面;
  2. 用户访问 API;
  3. API 返回了 403;
  4. 用户跳转到登陆;

After:

  1. 用户访问页面;
  2. 页面判断用户没有登陆,返回302;
  3. 用户跳转到登陆;

之所以出现这个问题,因为采用了传统的单页应用部署方式,即前后端完全分离。Nginx 负责返回前端页面,应用只是一个 API 服务器。渲染页面这一步应用感知不到。

部署方式

要判断用户是否登录的话,就要让“返回前端HTML”这一步交给应用来做。(假如我们想保持 Nginx 不涉及业务逻辑的话。)有人可能认为这样会很慢,实际上返回HTML这一步是很快的,因为这个 HTML 很小,我们写的前端应用都作为 <link> <script> 等引用外部资源异步加载,而且这些静态资源一般都是有 CDN 的。

真正可能慢的地方是 URL 匹配。我们知道在单页应用中,你访问 /m/foo 和 /m/bar 都是完全相同的一个 HTML,只是前端应用通过 URL 的不同来帮你路由了。但是访问 /api/foo.json 的时候可能真正访问到了服务器。一般来说,什么时候是访问静态页面,什么时候是请求 API,我们是交给 Nginx 配置来处理的,因为 Nginx 是一个专业的 Web 服务器,URL 匹配的效率很高。虽然我没测试过,但是我觉得 Django/Spring 这种 Web 框架,匹配 URL 的速度肯定要比 Nginx 慢很多。

所以这里需要:

  1. 让应用判断用户是否登录,选择是返回 HTML 还是重定向到登录页面;
  2. URL 匹配还是在 Nginx 做,如果是前端页面,那么 Nginx 就去掉 Path,直接 proxy_pass / 就可以了。那么应用这边只需要对 / 这个 Path 来返回一个 HTML;

Nginx 相关的配置如下:

表示正则 match 到 / 开头的话,就用 rewrite 指令去掉 / 后面的内容,将 URL 改写成 / 。然后 break 参数告诉 Nginx 停止处理其他的 rewrite,传给后端的 appserver 。注意这里我们的 rewrite 只是改写了传给应用的 Path,此时 Chrome 浏览器的 Path 还是完整的,所以我们这么做并不影响前端的路由。

后端的应用只要对 / 这个 Path 返回一个 HTML 就好啦。比如说用 Spring 的话,就这么写:

这个配置其实很灵活。如果你要 /a 是一个 app,/b 是另一个 app 的话,只要将 Nginx 配置替换成西面这样:

然后应用只渲染 /a 这个 Path 就好啦。

总结下原理就是:Nginx 负责去掉单页应用的 Path,替换成根目录,然后 App 负责判断用户是否登录,如果登录就返回根目录的 HTML,浏览器渲染出来页面。

登陆跳转问题

这里有一个小小的问题,就是后端应用将用户重定向到登陆的回收,会带上一个用户登陆完成之后回到的URL。比如用户访问的是 /hello/bar,那么我们希望用户登陆完成之后,直接跳转到 /hello/bar 。

但是因为 Nginx 将用户访问的 Path 给去掉了,应用认为用户访问的是 / ,那么用户登陆之后就看到的是首页,这样体验就不太好了。

解决这个问题,一开始我想用 cookie 记录下用户去登陆之前想访问的 URL (也是在传统网页时代,我们经常用的方法)。这样一来就要区分用户什么时候是想访问的 cookie 的 URL(应该重定向),什么时候就是想访问用户输入的 URL (不需要重定向)。还要注意什么时候应该设置 Cookie,什么时候不要设置 Cookie,什么时候清除Cookie。所以这个方案比较复杂。

搜索了一通之后,我发现 Nginx 是可以对代理后面的服务器返回的 Location Header 进行修改的。

比如我们 proxy_pass 到 appserver,appserver 返回了 302,带有 Location: /abc/de 的 Header,Nginx 可以将 Header 修改成 /foo/bar ,再返回给浏览器。

这个功能就是 proxy_redirect 指令。

配置如下:

注意这里的第7行,如果应用返回了 Location Header,那么这里就会将应用返回的重定向目标,修改成第二个参数所表示的重定向目标。其中,$http_host 是用户请求的 URL 里面的 Host,$request_uri 是用户请求的原始的 Path。

 

这样,我们就有了和传统网页一致的登陆体验了。

 

杭州PyCon 2019 Slide: 《做Side Project的几个建议》

Hi!最近太忙了,好久不曾更新博客。今天熬了个通宵,下午要去参加 杭州 PyCon 做一个演讲。杭州的讲师比较难找,之前问敏哥能不能把我调整成快速演讲,因为最近没时间准备……大哥说不行。然后我就按照40分钟的演讲准备了。

然后最近看了杭州 PyCon 的官网,嗯,我是快速演讲。

你妹啊!

Anyway,来杭州的朋友,下午见!下面是我准备的 Slides。

Github:https://github.com/laixintao/side-project-slide

在线预览:https://gitpitch.com/laixintao/side-project-slide

 

GitPitch Presents: github/laixintao/side-project-slide

Modern Slide Decks for Developers on Linux, OSX, Windows 10. Present offline. Share online. Export to PPTX and PDF.

 

PS: IRedis 最近开发了很多令人激动的 Feature!感觉很快可以有一个能用(大部分命令能正常工作,还剩1/4左右的工作量)的版本了。

PPS: 最近和 David 录制了第7期捕蛇者说的博客,已经剪辑好了,很快会发布。其实上周就剪辑好了,laike9m 要求太高,花了一个周+1个通宵重剪。哭哭。

 

PyCon 2019演讲: Django Migration Under the Hood

这是在 PyCON 2019 的分享,内容是 Django Migration 的用法、原理(没有非常深入),常见问题等。

在线播放 Slide 的地址:https://gitpitch.com/laixintao/django-migrations-under-the-hood

Slide 的源代码在 Github 上:https://github.com/laixintao/django-migrations-under-the-hood

如果这方面有问题的听众或者读者欢迎留言或邮件交流~

GitPitch Presents: github/laixintao/django-migrations-under-the-hood

Modern Slide Decks for Developers on Linux, OSX, Windows 10. Present offline. Share online. Export to PPTX and PDF.