2025-09-01 00:00:00
描述自己的代码……是一种什么样的感觉?
前段时间我在折腾博客部署的时候,回顾起了好久以前写的部署脚本。对于全站打包的这个步骤,本来我打算利用这个压缩包结合Service Worker做离线浏览,但因为没有合适的方案所以放弃了。而现在对于这个压缩包,我又有了一个特别的想法。事实上在这个下载全站的压缩包中,里面的内容和实际的网站并不完全相同,因为在这个压缩包里缺少了压缩包本身。所以把这个压缩包解压之后直接当作网站打开,会发现下载压缩包的链接是无效的,除非在解压之后把压缩包移动到网站里才行……
于是我就在想有没有一种可能可以让压缩包解压之后里面又包含了这个压缩包本身?似乎是个不太可能的事情,但我以前听过类似的东西,也许并非不可能?所以这次就来探索一下吧。
在很久之前,我见到过一个很知名的自包含压缩包(又称为ZIP Quine),叫做droste.zip,是由Erling Ellingsen在2005年制作出来的。当时我只知道它很神奇,原理什么的并不清楚,另外在网上也基本上找不到类似的压缩包。现在再回看时发现介绍里包含了一些相关的链接,甚至还有一篇能自己制作类似压缩包的论文,所以接下来就可以看一下这些链接来理解这种压缩包是如何制作的了。
关于原理方面,先看Will Greenberg制作的一个示例,在这里面有一个谜题,使用“print M”(原样输出接下来的M行输入内容)和“repeat M N”(从倒数第N行的输出内容开始,重复M行)这两个指令让最终执行的结果和输入的指令完全相同。这正是对DEFLATE压缩算法所使用的LZ77编码的一种简化模拟,也就是说只要解决了这个问题,就可以让压缩包在解压时原样输出自己了。
这个问题看起来还挺复杂,不过在仓库的Issues就有人给出了几种解法(当然,这个题目解法不唯一),所以在理论上应该是可行的,那么接下来就需要研究压缩文件的格式来实现它了。
在Russ Cox写的Zip Files All The Way Down文章中,同样说明了这个原理,而且给出了一个方案,让上述这两个命令除了能够对命令本身的重复以外,还可以添加一些额外数据,这样才能做到构建一个压缩包文件。按照文章的描述,如果用之前谜题的规则来说,我们设头和尾的内容都是“print 0”,那么Cox给出的方案如下:
print 0
print 2
print 0
print 2
repeat 2 2
print 1
repeat 2 2
print 1
print 1
print 4
repeat 2 2
print 1
print 1
print 4
repeat 4 4
print 4
repeat 4 4
print 4
repeat 4 4
print 4
repeat 4 4
print 4
repeat 4 4
print 0
print 0
print 2
repeat 4 4
print 0
print 0
print 2
repeat 2 2
print 0
repeat 2 2
print 0
我们把这些指令粘贴到quine.zip这个谜题中,就会发现输出和输入完全相同,以此就能验证Cox方案的正确性。除此之外作者还给出了生成的源代码:rgzip.go,只是代码里面到处都是用来构建压缩包的十六进制数字,完全看不懂😂。
另外这个方案是针对使用基于LZ77与哈夫曼编码的DEFLATE压缩算法,所以格式不重要。因此无论是ZIP,还是GZIP,以及TGZ(GZIP压缩后的TAR),其实都是一样的,因为他们都使用的是DEFLATE压缩算法。顺便一提,Matthew Barber写了一篇很棒的文章,通过动画演示并详细讲解了如何实现一个简单的GZIP版ZIP Quine,很值得一看。
还有一点,普通的TAR文件能否实现类似功能呢?从原理来说估计不行,因为TAR文件本身并没有压缩,也不包含指令,就单纯是一堆文件和元数据的拼接,所以就做不到自包含了。
这么来看既然TGZ可以,那是不是在我博客网站的压缩包里放一份和自己一模一样的压缩包是可行的?很遗憾按照这个方法来看是做不到的,由于压缩格式和编码的限制,这个方案在实际实现时发现操作码需要是5个字节,最后发现最多只有类似repeat 64 64
这样的指令能够满足要求,因此头尾区最多只能放64-5=59个字节的数据,也就刚刚好能容纳压缩格式需要的内容,几乎没法塞更多东西进去……显然,这些限制导致这种方式对我来说意义就不大了,何况作者的代码我也看不懂……而且还要考虑压缩包还存在校验用的CRC32,需要找满足整个压缩包的CRC32正好在压缩包中的“不动点”。虽然从CRC32的原理来说应该有办法做到通过数学方式解决,但这篇文章的作者因为解决了自包含的问题之后累了,因此放弃继续研究,选择直接暴力破解,毕竟CRC32只有32位,估计思考的时间都要比爆破的时间长吧😂。但如果是这样,即使有方案能存下我博客的数据,也不能在每次网站构建的时候都制作一次了……
虽然Russ Cox写的文章看起来做不到包含更多内容了,但Erling Ellingsen制作的droste.zip却包含了一张图片,说明并不是没办法加入更多数据,只是没有找到正确的方法。在2024年Ruben Van Mello写了一篇论文A Generator for Recursive Zip Files,在这篇论文里他不仅解决了包含的额外数据过少的问题,还编写了一个通用工具,能让普通人也能生成这样的压缩包,而且他还创新性的做了一种像衔尾蛇一样的双层嵌套循环压缩包,非常的有意思,所以接下来我打算试试他的方案。
在这篇论文中,里面简述了之前Russ Cox写的内容,也提到了59字节的限制,于是作者对原有的结构进行了一些改动,让操作码可以超出5字节的限制,具体可以看论文的表6,从而解决了只能包含59字节额外数据的限制。但由于DEFLATE压缩格式本身的约束(16位存储块长度以及32KiB回溯窗口),即使能够添加文件,最多也只能额外容纳32763字节的数据(其中包括压缩包所需的文件头)……显然这点空间完全存不下我的博客😭,看来我只能打消这个想法了。但既然都研究了半天,也不一定要存我的博客嘛,可以看看还有没有别的东西可以存?在这之前先继续阅读论文,看完再说吧。
在实现了常规的ZIP Quine之后,接下来就是作者的创新点了(如果光是解决存储限制这点创新点估计还不够发论文吧😂)。作者在接下来制作了一种循环压缩文件,在压缩包内包含文件A和压缩包A,而压缩包A中则包含文件B和最初的压缩包,从而形成一个循环递归的结构。看论文的描述所说如果把外层的压缩包和内层的压缩包的开头和结尾按照一定的规则交替混合,就可以看作是一个整体,然后按照之前做ZIP Quine那样处理就可以……具体实现的细节得看论文的表10。只不过既然是把两个压缩包看作一个整体的话,按照上面的限制,自然每个压缩包能容纳的数据量就更小了,每个最多只能容纳16376字节的数据……
另外既然这里面有两个压缩包,那么每个压缩包还有自己的CRC32校验和,理论上如果要爆破的话计算难度得是原来的平方,这样难度就太大了。不过作者发现如果把数据的CRC32值取反(即与“0xFFFFFFFF”取异或)然后和原始数据拼到一起,整个数据的CRC32校验和就会被重置为一个固定的值“0xFFFFFFFF”,看起来挺有意思,正常的哈希算法可没有这种特性。因此原本计算难度很大的爆破计算现在就可以和之前一样了……话说为什么不让两层的CRC32都这样计算(包括之前单层的ZIP Quine)?这样就不需要爆破了……貌似是因为在普通的ZIP Quine中满足条件的CRC32需要出现两次,所以不能用这个方案吧?
现在所有的理论都足够了,我需要挑一个文件来做这样嵌套循环的ZIP Quine,既然博客的大小不可以……要不然我就用我写过的第一个大项目——Mabbs吧,这个项目的主程序是22KiB,看起来似乎超出了嵌套循环ZIP Quine的限制?其实没有,它的限制指的是压缩后的大小,我这个程序压缩之后是8KiB左右,所以完全没问题。
接下来就该使用论文中提到的生成工具:zip-quine-generator,这是一个Kotlin编写的程序,从发布中可以下载预构建的程序,接下来只要按照README中的描述使用“--loop
”参数就可以用这个程序创建嵌套循环的ZIP Quine了。不过它原本的代码不能修改里面生成的压缩包的名字,另外压缩后的文件属性是隐藏文件,还有生成的压缩包中文件的创建时间总是当前时间,以及给文件内填充额外数据的代码里面填的是作者的声明,表示文件是由他论文的所写的生成器生成的……这些情况让我感觉有点不爽,还是希望这些部分能自定义一下,所以我就小改了一下他的代码。顺便一说,Kotlin编译起来还挺简单,直接一句kotlinc src/main/kotlin -include-runtime -d output.jar
就可以了,也不需要折腾Maven之类乱七八糟的东西。最终我修改并编译完程序之后就把文件丢到服务器上开始给我爆破CRC32了,花了10个小时就算出来了,倒是比想象中快😂。
最终我给我的Mabbs项目创建了Infinite Mabbs这个发布,生成的文件也可以在这里下载,这也算是不枉我研究半天这个论文了😆。
说起来自包含压缩包为什么叫做ZIP Quine?其中的Quine是什么意思呢?其实这是一位美国哲学家的名字,他提出了“自指”的理论概念,所以为了纪念他,有类似概念的东西就被称作Quine,具体为什么也可以去看维基百科的说明。现在提到Quine一般代表的就是自产生程序,而自包含压缩包因为实现的原理和自产生程序的原理差不多,所以叫做ZIP Quine。因此接下来我打算探索一下自产生程序,更深入地了解Quine。
那么什么是自产生程序?简单来说就是程序的源代码和程序的输出完全相同的程序,而且通常来说不允许通过读取/输入源代码的方式实现。按照一般的想法,让程序输出自身就需要输出中有全部代码,整个代码就会变长,而更长的代码就要输出更多,然后代码就会越来越长……所以这么想来似乎成了个死胡同。但其实这种程序实现起来并不复杂,想想ZIP Quine的实现,关键在于指令还需要以数据的形式表现,并且能被引用,这样输出的时候就会连着指令一起输出了。比如用Python的Quine举例:
c = 'c = %r; print(c %% c)'; print(c % c)
这里的变量中就以数据的形式存储了程序的代码,而在输出的时候除了变量内的代码,又通过引用的方式又把变量的内容放回到赋值的地方,所以它的输出就和原本的代码一样了。
其实Quine的实现思路都差不多是这样,可以在Rosetta Code中找到各种语言实现的Quine,在这其中能够发现大多数高级语言的写法都是类似的,除了一些低级语言以及esolang……这些我也看不懂😂,主要是有些语言没有变量的概念,不知道是怎么区分代码和数据……除了那个网站,在这里还能找到更多由esolang编写的Quine,可以看出来基本上很难看懂,其中最令人望而生畏的还得是用Malbolge写的Quine,这个代码看起来不仅很长,而且像乱码一样。至于什么是Malbolge?这就是Malbolge程序:
D'<;_98=6Z43Wxx/.R?Pa
代码就像加了密似的,顺便一说这个执行结果是“Mayx”,关于Malbolge的具体细节可以看它的规范,另外虽然这个语言写起来很复杂,但还是有人能用它编出程序的,甚至还有人用Malbolge Unshackled(Malbolge不限内存的变种)写过Lisp解释器,实在是恐怖如斯😨。
其实想要做出Quine,还有一种更加无聊的方案,那就是设计一种只能Quine的语言🤣。根据Quine的定义,代码输出的结果就是它本身……所以我们可以把任何内容都看作代码,然后这种语言的行为就是输出所有代码……听起来是不是有点无聊?但是想想看如果把Linux中的cat命令当作解释器,就可以实现这种语言了,比如:
#!/bin/cat
Hello, world!
作为脚本执行的结果就是原样输出这段内容,不过把内容当作代码算不算作弊呢……如果看作是cat的输入显然是作弊,但如果是当作源代码的话应该就不算了吧😋……但这就不是能写出逻辑的语言了。所以说Quine的趣味并不在“能不能实现”,而在于如何在限制条件下实现。正是因为大多数语言不会直接“自我输出”,才会觉得那些精巧的Quine程序如此有意思。
还有一个更加复杂的Quine变种是“Quine接力”(Quine Relay),即一个程序输出另一个程序的源代码,另一个程序又输出下一个程序的源代码,最后回到原始程序,就和之前所说的的嵌套循环ZIP Quine有点类似。最著名的例子是Yusuke Endoh(这位还是IOCCC的冠军之一)创建的quine-relay项目,它包含了128种编程语言的循环。
这种程序写起来会更复杂一些,不过原理都差不多,通常除了当前运行的部分是可执行代码外,其他的代码都需要以额外包含的数据形式(如字符串)存储在变量中。如果想自己做个类似简单的Quine Relay,除了去看维基百科之外,前段时间我还看到过一个不错的文章,里面就讲了如何用“笨办法”编写Quine和Quine Relay,通过把变量中的内容编码为16进制来避免不同语言可能存在的特殊字符转译问题,思路不错,对于理解如何编写这类程序的问题很有帮助。当然这只是个简单的方案,仅适用于一些常规的编程语言,像上面那个quine-relay项目中甚至还包含Brainfuck之类的esolang,这种估计得要想办法让相对高级一些的语言通过“生成”的方式得到输出下一种代码的代码,而不是简单的赋值了,所以只靠这点知识想去完全理解大佬的作品还是想多了😆。
虽然这次探索最终没能完成让包含博客所有内容的压缩包自包含,但是在探索的过程中我还是收获了不少,尤其是Ruben Van Mello制作的ZIP Quine生成工具,实在是太棒了。很久以前我见到droste.zip这个压缩包的时候,就想整一个属于自己的ZIP Quine,现在我不仅用那个生成工具做了一个,还是对我来说很有意义的第一个项目——Mabbs,而且更关键的还是生成的是比普通的ZIP Quine更高级的嵌套循环ZIP Quine,也算是圆了小时候的心愿了。
另外在探索自产生程序的时候,也发现了一些很有意思的网站,比如Rosetta Code以及Esolang wiki (虽然这个网站里被好多小学生写了一堆无聊的东西😂) ,里面有不少有趣的东西,也算是让我大开眼界了。
所以有的时候探索不一定要完成目标,在这个过程中也会收获到很多不错的东西吧😊。
2025-08-10 00:00:00
Tilde社区,如“家”一般的感受😝
在上一篇文章里,我说到给我的博客增加了不少网站镜像,也在这个过程中发现了不少Git平台实例。顺便一提,我找到了个不错的仓库,可以全网搜索各种Git平台实例。在这探索的过程中,我发现了一种神奇的社区——Tilde社区,体验之后感觉非常有意思,所以来分享一下。
Tilde社区之所以叫Tilde,是因为在类Unix系统(如Linux、BSD)中,波浪号(Tilde)“~”代表家目录。因此,Tilde社区就是基于类Unix系统环境,并且可以公共登录的服务器,又被称为pubnixes。一般这些社区的管理员会预装很多软件、开发环境以及一些公共服务,比如聊天室、邮件、BBS论坛等,这些构成了社区互动的基础。不过并不是所有类似这样提供Shell访问的公共服务器都可以被称作社区,比如知名的免费网站托管商Serv00虽然也提供可以登录的FreeBSD服务器,并且在服务器上安装了非常多的工具和环境,从表面来看和Tilde社区提供的服务几乎一模一样,但是它少了一个很重要的东西,那就是社区,它的权限管理非常严格,不允许服务器的用户互相串门,也没有互相交流的平台,而且它的本质是商业服务(尽管是免费的),所以它不算Tilde社区。
至于Tilde社区的加入方式,一般可以通过填写在线申请表、私信或发送邮件申请,有些比较有特色的社区会用SSH交互等方式。审核通过后,管理员就会在服务器上为你创建账户,即可获得属于自己的“家”,一般的Tilde社区在这个过程中不需要付一分钱,因为他们通常都是反商业化的,如果遇到了需要付钱才能激活账户的公共服务器,那就不是Tilde社区,即使它历史悠久,可能是别的什么东西😆。
那么在哪里可以找到它们呢?有一个不错的网站,叫做tildeverse,这不仅是一个Tilde社区的集合,它自身也提供了很多服务。不过总的来说各个社区之间也是互相独立的,tildeverse只是提供了一个平台让大家可以互相沟通,所以这个网站叫做“loose association”,就相当于博客中的博客圈一样。
于是我在tildeverse的成员列表中随便挑选了几个Tilde社区提交了注册申请,过了一段时间申请通过了,那么接下来就来说说我在Tilde社区的体验吧。
虽然我加入了不少Tilde社区,不过各个社区提供的服务都差不多,首先最重要的就是个人主页,一般Tilde社区基本上都会提供一个像~/public_html
这样的目录存放个人主页的网页文件,并且可以通过类似example.com/~username
这样的地址访问,还有些社区会允许通过二级域名的方式访问,类似username.example.com
这样,像我博客好多地方写的都是从根路径开始,就很适合用二级域名的方式。这些主页大多也支持使用PHP之类的网页,不过不像虚拟主机那样有个面板可以轻松安装扩展和切换版本,有些可能要自己写配置文件,有些可能要管理员才可以操作,毕竟是社区,所以不太注重用户体验。
当然除了HTTP协议的个人主页,通常他们还可以创建一些Gemini协议和Gopher协议的个人主页,这些协议不支持普通浏览器访问,需要用ELinks之类的文本浏览器才能打开,这个浏览器甚至可以在终端里用鼠标操作😆。不过因为协议非常简单,所以内容也就只能整些文本内容了。
除了个人主页外,一般还会提供编写博客的程序,比如bashblog,用这个编写好之后就可以直接生成HTML网站,能直接发布到自己的主页上让别人访问。这个脚本还是纯Bash的,就和我当年的Mabbs一样,看起来还挺酷,当然功能上肯定比不上正经的静态博客生成器😆。
当然博客是一方面,还可以写微博,他们一般提供一款叫twtxt的软件,用这个软件可以使用命令发微博,还能关注其他人,查看时间线,而且这还是去中心化的,可以跨服务器进行关注,感觉就和Mastodon一样。
除此之外作为社区当然就会有聊天室和论坛了,不过这些聊天室和BBS论坛通常不会像大多数人使用的那种通过Web或者图形界面来查看,而是纯文本的那种,比如论坛通常会用Bulletin Butter & Jelly,聊天室会用IRC,可以使用WeeChat,只是我对IRC的印象不太好,在终端使用的IRC客户端没有一个使用体验好的😅,相比于其他在终端使用的软件,操作通常只需要一些快捷键,而且界面上通常会有提示,而IRC客户端就只能敲命令,而且还担心敲错了当成普通内容发出去……所以尽管我加入了Tilde社区,受限于聊天软件的使用体验以及我的英文水平,所以并不能和在服务器上的其他人聊天,没法参与到社区中,这么来看似乎我只能把Tilde社区当作普通的共享服务器来看待了😭。
在Tilde社区中既然都是用类Unix系统,自然大都是会写程序的人,所以托管代码也很重要,不过因为大多Tilde社区的主机性能很垃圾,所以很多都不会提供Git平台服务,即使有可能也只会提供Gitea,像GitLab这种对服务器要求比较高的基本上就不会有了。但很多人可能对Git有误解,其实绝大多数情况下都不需要Git平台来托管代码,之所以用Gitea、GitLab的工具是因为它们有比较完整的用户管理以及代码协作能力,比如Issue和Wiki之类的,但是大多数人其实根本没必要用到这些功能,有问题发邮件就好了,像Linux的开发就完全没有用Gitea、GitLab之类的平台。所以在Tilde社区中托管代码非常简单,直接新建个文件夹,执行git init --bare
,那就是个仓库,另外很多Tilde社区提供cgit方便让公众在网页上查看和克隆自己的仓库,一般只要放到~/public_git
目录下就可以。至于自己如果想要提交代码,可以用git remote add tilde ssh://example.com/~/public_git/repo.git
添加远程仓库,本地改完之后push上去就可以。
不过用那些Git平台还有一个地方可能会用到,那就是CI/CD,直接用命令创建的仓库它可以做到CI/CD吗?其实是可以的,Git有hooks功能,如果想要类似CI/CD的功能就可以直接用post-receive这个钩子,提交完成之后就会执行这个脚本,所以接下来就讲讲我是如何用Git hooks在服务器上自动部署我的博客吧。
我的博客使用的是Jekyll框架,这是一个使用Ruby编写的静态博客生成器。所以要想构建我的博客至少要有Ruby的环境,还好几乎所有的Tilde社区都预装了,不用担心环境的问题。
不过Tilde社区一般不提供root权限,所以Ruby的包需要放到自己的目录下,比如可以执行这样的命令:
bundle2.7 config set --local path '/home/mayx/blog-env'
然后再在我的仓库下执行bundle2.7 install
就可以了。
接下来就需要编写构建的脚本,这个倒是简单,直接用我的部署脚本改改就行:
#!/bin/bash
cd /home/mayx/
rm -rf public_html
git --work-tree=/home/mayx/blog --git-dir=/home/mayx/blog.git checkout -f
cd blog
mkdir Mabbs
curl -L -o Mabbs/README.md https://github.com/Mabbs/Mabbs/raw/main/README.md
bundle2.7 exec jekyll build -d ../public_html
tar czvf MayxBlog.tgz --exclude-vcs ../public_html/
mv MayxBlog.tgz ../public_html/
写完之后把这个脚本放到仓库的hooks/post-receive
下,然后加上执行权限就可以用了,以后每次push之后都会直接更新我在Tilde社区的主页,也就是我的镜像站。这样部署不像一般CI/CD还要额外装环境,直接使用提前装好的环境,构建速度会快不少。
不过既然有机会构建了,我就可以把一些不支持构建的Pages用起来了,有些Forgejo实例支持Pages功能,但是仓库里只能包含构建后的代码,还有Bitbucket Cloud也是一样的问题,所以我可以把构建后的文件夹转为仓库,然后推送到这些Git平台上。
考虑到我的网站每次构建基本上所有的页面都有改动,因此我不打算保留提交记录,所以我每次都会重新初始化git仓库,不过在我实际测试的时候,发现钩子触发的脚本执行git init
的时候创建的是裸仓库……查了一下貌似是环境变量的问题,只要把GIT_DIR
变量删掉就没问题了,以下是实际的代码:
cd ../public_html/
unset GIT_DIR
git init
git add .
git commit -m "update"
git remote add codeberg ssh://[email protected]/mayx/pages.git
git remote add gitgay ssh://[email protected]/mayx/pages.git
git remote add bitbucket ssh://[email protected]/unmayx/unmayx.bitbucket.io.git
git push -f codeberg master
git push -f gitgay master
git push -f bitbucket master
除了这些Pages之外,还有一些平台只支持使用他们自己的软件上传网站代码,比如surge,既然我可以在构建的时候执行命令,那就顺带一起上传吧,比如我可以这样执行:
/home/mayx/blog-env/node_modules/surge/bin/surge /home/mayx/public_html/ mayx.surge.sh
其实除了这个之外我还想上传到sourcehut pages,这个也需要用他们自己的软件上传,但是sourcehut pages的CSP太严格了,居然禁止脚本访问其他网站😭,这样我的文章点击计数、文章推荐、AI摘要之类乱七八糟的功能就全用不了了,所以只好作罢……
总的来说,这次在Tilde社区的各种体验还挺有意思,虽然没能和各个社区的成员进行对话,但是在探索的过程中,也了解到了不少新知识,而且也给我的博客增加了不少镜像。不知道会不会有哪个社区成员在闲逛的时候看到我的博客然后对里面的内容感兴趣😝……要是有哪个成员看到然后给我评论,那也算是社区互动吧😋。虽然我的文章内容都是中文,但现在翻译软件也足够强大了,应该不至于拦住外国人。只是在国内似乎没有见过类似的社区,在国内也有的话,那就可以用中文和大家对话了吧。
2025-08-01 00:00:00
现代浏览器真是强大,可以替代一些服务器的功能了!
前段时间在和群友聊天的时候,提到了我博客的分发方案,这么多年过去之后我已经在很多平台上分发了我的博客,不过这只是多重冗余,并不算去中心化(虽然我也有向IPFS同步,不过IPFS还得pin,也不太可靠)……所以这么看来,我的博客似乎还不算极其可靠😂?但其实不完全是这样。因为除了向不同平台的分发,我的博客还有一个全文搜索的功能。更重要的是,之前做文章推荐功能时,会把整个博客所有文章的文字存到访客浏览器的localStorage中。这么说来,只要有人访问了我博客的文章,他们的浏览器中就会保存一份我博客文章的完整文本副本。从这个角度看,可靠性应该算是相当高了吧?
不过我之前的分发方案里还记录了一点,在GitHub Pages以外的平台我还打包了一份全站生成后的代码,之所以要全站打包,也是希望我的博客能尽可能的分发,考虑到几乎所有的Linux发行版一定有tar,而不一定有zip,所以我最终打包成了tgz格式。如果能让访客下载这个全站打包好的副本,相比于浏览器里只存储了文章文字的全文数据,这应该是一个更好的备份方式吧?毕竟我的博客本身也是我的作品……所以这个压缩包到底有什么地方可以用到呢?
这时候我想起来,现代的浏览器功能已经非常强大了,甚至在浏览器里直接运行一个Web服务器也完全没问题。如果能让访客在浏览器里下载那个压缩包并运行一个Web服务器,那就相当于在他们本地设备上部署了一份我的博客副本。这样一来,除了我自己搭建的网站之外,这些访客的本地也运行着一个我的博客实例😆(当然,这份副本只有访客自己能看到)。
想要在浏览器上运行Web服务器其实很简单,那就是使用Service Worker,它可以完全离线在浏览器上工作。格式的话和以前写过的Cloudflare Worker非常相似,毕竟Cloudflare Worker就是模仿Service Worker的方式运行啊😂,所以我要是想写Service Worker应该很简单。
有了执行的东西之后就是存储,在Service Worker上存储可以用Cache Storage,用它的话不仅可以保存文件的内容,还可以保存响应头之类的东西,用来和Service Worker配合使用非常的方便,不过既然是Cache,它的可靠性就不能保证了,浏览器很可能在需要的时候清除缓存内容,所以相比之下用IndexedDB应该会更可靠一些。
那么接下来就该处理我的tgz文件了,tgz的本质是tar文件被gzip压缩之后的东西。浏览器解压gzip倒是简单,可以用Compression Stream API,但它也只能处理gzip了……对于tar的处理似乎就必须用第三方库。而tar的库在网上搜了搜似乎很少,网上找了个tarjs库,文档写的也看不懂,⭐️也很少,看来是有这个需求的人很少啊,而且还要用现代JS那种开发方式,要用什么npm之类的。在上一篇文章我就说过我不是专门写前端的,对在自己电脑上安装Node.js之类的东西很反感。后来问AI也完全写不出能用的代码,估计这个功能还是太小众了……另外又想到除了这个问题之外还要处理网站更新的时候该怎么通知Service Worker之类乱七八糟的事情……所以只好作罢😅。
这么看来离线运行我的博客似乎有点麻烦,不过既然都研究了一下Service Worker,不如想想其他能做的事情……比如当作反向代理?虽然在浏览器上搞反向代理好像意义不是很大……但值得一试。我之前见过一个项目叫做jsproxy,它是用Service Worker实现的正向代理,这给了我一些启发。我在之前研究分发方案的时候发现了一些模仿GeoCities的复古静态网站托管平台,比如Neocities和Nekoweb。它们需要通过网页或API才能上传网站,不太方便使用CI/CD的方式部署。但是我又觉得它们的社区很有意思,所以想用Service Worker的方式反代到我的网站,显得我的网站是部署在它们上面一样。
这个做起来非常简单,其实就和我以前用Cloudflare Worker搭建反代几乎完全一样,遇到请求之后直接通过Fetch获取内容然后再返回就行,唯一不同的就是浏览器存在跨域策略,在跨域时只有对应网站存在合适的响应头才可以成功请求,还好我用的Pages服务大多都允许跨域。但是在我实际测试的时候发现这个允许跨域的等级不太一样,比如GitHub Pages的响应头里包含Access-Control-Allow-Origin: *
,但是不允许OPTIONS方式请求,另外如果要修改请求头,在响应头里还要一一允许相应的请求头才行……当然对于这种问题解决起来很简单,就和我之前写的订阅源预览一样,用cloudflare-cors-anywhere搭建的CORS代理就可以,有了这个就可以轻松使用Service Worker反代其他网站了。
当然对我来说其实有Access-Control-Allow-Origin: *
就够了,我也不需要花里胡哨的请求方式,也不需要在请求头和请求体里加什么莫名其妙的东西,所以对我来说直接请求我的某一个镜像站就可以,于是代码如下:
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Mayx的博客</title>
</head>
<body>
<script>
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker 注册成功:', registration.scope);
// 刷新网页
location.reload();
})
.catch(error => {
console.error('Service Worker 注册失败:', error);
location="https://mabbs.github.io";
});
} else {
location="https://mabbs.github.io";
}
</script>
<h1>Redirecting…</h1>
<a href="https://mabbs.github.io">Click here if you are not redirected.</a>
</body>
</html>
sw.js
const TARGET_SITE = '被反代的网站'; //也可以用CORS代理
self.addEventListener('install', event => {
// 强制立即激活新 Service Worker
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
// 立即控制所有客户端
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', event => {
if (new URL(event.request.url).origin == self.location.origin) {
event.respondWith(handleProxyRequest(event.request));
}
});
async function handleProxyRequest(request) {
try {
// 构建目标 URL
const targetUrl = new URL(request.url);
const proxyUrl = TARGET_SITE + targetUrl.pathname + targetUrl.search;
// 创建新请求(复制原请求属性)
const proxyRequest = new Request(proxyUrl, {
method: request.method,
// headers: request.headers,
// body: request.body
});
// 发送代理请求
const response = await fetch(proxyRequest);
// 返回修改后的响应
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
} catch (error) {
console.error('Proxy error:', error);
return new Response('Proxy failed', { status: 500 });
}
}
最终的实际效果: https://mayx.nekoweb.org
虽然折腾了半天没能增强我博客的可靠性……但是体会到了现代浏览器的强大之处,难怪前几年会提出ChromeOS和PWA之类的东西,原来浏览器功能还是相当强大的,用了Service Worker以后即使是纯前端也可以有和使用服务器一样的体验,在过去的浏览器中要是想实现这样的功能……好像也不是不可能😂,用AJAX加服务器使用伪静态策略其实是可以做到的……其实Service Worker的功能更多还是在离线时使用的,我这个例子好像没体现它的优势😆。
但总的来说相比以前想要实现这种反代的功能代码还是更清晰,也更简单了,也许以后如果有机会我又有心思让博客在访客浏览器上离线运行,那就可以体现Service Worker真正的优势了🤣。
2025-07-24 00:00:00
Cloudflare的功能真是越来越多了,而且还免费!
前段时间我在登录Cloudflare的时候发现Workers上多了一个“浏览器呈现”的功能(可能已经出来一段时间了,不过之前一直没关注),看介绍,这个功能可以让Worker操作运行在Cloudflare服务器上的浏览器。这功能挺有意思,而且免费用户也能用,不如想个办法好好利用一下。
一般来说这个功能可以干什么呢?既然是在AI盛行的时候出现……估计是为了搞Agent之类的吧,不过看文档对免费用户来说一天也只有10分钟的使用时间,估计也没什么应用价值……那除了这个之外还能做些什么?我发现有好多博客主题喜欢给自己的README里添加一个能查看主题在多种设备上显示效果的预览图,以展示主题的自适应能力。那么既然现在能在Cloudflare上操作浏览器,那么我也可以做一个类似的,而且这个预览图还可以自动更新。
既然打算做预览图,那么我应该用什么方案?按照不同尺寸的视口截几张图再拼起来吗?这显然就太复杂了,况且在Cloudflare Workers中处理图片也相当困难。这时我想起来曾经见到过一个工具,只要输入网址,就可以在一个页面中同时展示网站在四种不同设备(手机、平板、笔记本电脑、台式机)上的显示效果,叫做“多合一网页缩略图”,实现原理是使用iframe和CSS缩放模拟多种设备视口。搜了一下发现这套代码被不少网站使用,所以就随便找了其中一个工具站把代码和素材扒了下来,稍微改了一下,然后放到GitHub上,方便等一会用Cloudflare访问这个部署在GitHub Pages上的页面来进行截图。
接下来截图就简单了,不过Cloudflare有两种截图的办法,用Workers的话可以直接用Puppeteer之类的库连接浏览器,但用这个库需要安装,要本地搭环境……我毕竟不是专门搞JS开发的,一点也不想在本地安装Node.js环境,所以就不想用这种方式。另外一种是通过调用Cloudflare的接口,这种非常简单,只需要填几个参数请求就行,唯一的问题就是要填一个Token……我一直觉得Worker调用Cloudflare自己的服务不应该需要Token之类的东西,毕竟内部就能验证了,没必要自己搞,但是我看了半天文档貌似无论如何只要想调接口就必须搞个Token……那没办法就搞吧,其实也很简单,只需要在“账户API令牌”里添加一个有浏览器呈现编辑权限的令牌就行。
至于展示……这个接口调用比较耗时,而且一天只能调用10分钟,截图的话估计也就够30次左右,还有每分钟3次的限制😓,所以实时更新肯定是不行了,图片肯定得缓存,一天更新一次感觉应该就够了。另外次数这么少的话写成接口给大伙用貌似也没啥意义,所以我就把地址写死了,于是以下就是最终实现的代码:
export default {
async fetch(request, env, ctx) {
const cache = caches.default;
const kv = env.SCREENSHOT;
const url = "https://mabbs.github.io/responsive/";
const date = new Date().toISOString().split("T")[0];
const cacheKey = url;
const datedKey = `${url}?${date}`;
// 工具函数:构建 Response 对象
const buildResponse = (buffer) =>
new Response(buffer, {
headers: {
"content-type": "image/png",
"cache-control": "public, max-age=86400, immutable",
},
});
// 工具函数:尝试从 KV 和 Cache 中加载已有截图
const tryGetCachedResponse = async (key) => {
let res = await cache.match(key);
if (res) return res;
const kvData = await kv.get(key, { type: "arrayBuffer" });
if (kvData) {
res = buildResponse(kvData);
ctx.waitUntil(cache.put(key, res.clone()));
return res;
}
return null;
};
// 1. 优先使用当日缓存
let res = await tryGetCachedResponse(datedKey);
if (res) return res;
// 2. 若缓存不存在,则请求 Cloudflare Screenshot API
try {
const payload = {
url: url,
viewport: { width: 1200, height: 800 },
gotoOptions: { waitUntil: "networkidle0" },
};
const apiRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/browser-rendering/screenshot?cacheTTL=86400`,
{
method: "POST",
headers: {
Authorization: `Bearer ${env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}
);
if (!apiRes.ok) throw new Error(`API returned ${apiRes.status}`);
const buffer = await apiRes.arrayBuffer();
res = buildResponse(buffer);
// 后台缓存更新
ctx.waitUntil(Promise.all([
kv.put(cacheKey, buffer),
kv.put(datedKey, buffer, { expirationTtl: 86400 }),
cache.put(cacheKey, res.clone()),
cache.put(datedKey, res.clone()),
]));
return res;
} catch (err) {
console.error("Screenshot generation failed:", err);
// 3. 回退到通用旧缓存
res = await tryGetCachedResponse(cacheKey);
if (res) return res;
return new Response("Screenshot generation failed", { status: 502 });
}
},
};
使用方法很简单,创建一个Worker,把以上代码粘进去,然后把从“账户API令牌”中生成的令牌填到Worker的密钥中,名称为CF_API_TOKEN
,另外再加一个名称为CF_ACCOUNT_ID
的密钥,内容是账户ID,就是打开仪表板时URL中的那串16进制数字,除此之外还需要创建一个KV数据库,绑定到这个Worker上,绑定的名称是SCREENSHOT
。如果想给自己的网站生成,可以Fork我的仓库,然后把里面首页文件中的网址替换成你的网站,然后再把Worker中的url替换成Fork后仓库的GitHub Pages地址就可以了。
最终的效果如下:
Cloudflare实在是太强了,虽然这个浏览器呈现免费用量并不多,但是有这么一个功能已经吊打很多Serverless服务了,毕竟浏览器对服务器资源的占用也不小,小内存的服务器甚至都不能运行,如果要自己搭的话成本可能也不小,而现在Cloudflare能免费提供,应该说不愧是赛博活佛吗🤣。
2025-07-13 00:00:00
即使是被入侵了也可以学到一些知识!
前几天,我闲来无事登录了一下一台之前一直闲置的服务器,登录上去后,乍一看似乎没有任何问题,然后习惯性的执行了一下top
命令看了一眼。从进程列表来看,似乎没有什么明显异常的地方,但是服务器的load值很高,cpu的us值也很高。
以前我倒也遇到过几次load值很高的情况,一般是硬盘或NFS等网络存储挂了但是依然有程序在读写挂载的目录会有这种问题,但那种情况一般高的是cpu的wa值,而不是us值,us值是软件正常用掉的……但是进程列表里根本没有占CPU的程序啊……看来服务器是被入侵了😰。
虽然说是要查,但其实我根本不知道进程隐藏的原理😂,虽然听说过有恶意软件会这样做,现在遇到了一时半会又想不出来怎么找。还好这是台闲置的服务器,上面什么东西都没有跑,所以正常来说除了ssh连接之外,这个服务器不该有任何其他的连接,于是我执行了一下netstat -tanp
看了一眼,发现有个奇怪的进程使用一个境外的IP和我的服务器建立了连接,用ps -ef
查了一下这个 PID,结果进程名显示为[kcached]
……这下给我整不会了。
后来查了些资料知道了可以用lsof -p
查看进程读取的文件,才看到木马的本体:/usr/bin/gs-dbus
。不过如果我只是杀掉这个进程然后删除文件,那攻击者肯定会重新回来,所以我得排除一下是不是还有别的木马文件。
一般来说攻击者权限维持的方式大多是crontab,不过我看了一下配置文件里似乎没有,root下的authorized_keys
倒是有个陌生的公钥于是顺手删掉了……也没有其他文件夹下有gs-dbus
文件……难道没有别的木马文件了吗?后来我仔细找了一下,发现有个很可疑的文件/usr/local/lib/libprocesshider.so
,一看就不是什么好东西🤣,后来在GitHub上搜了一下,是libprocesshider这个项目,就是它让我在top中什么也没找到的,看文档中应用是添加一个/etc/ld.so.preload
文件,所以解除隐藏效果我也只需要删掉这个文件就好啦。
不过感觉还是不够……所以我全盘搜索了一下libprocesshider.so
文件,果不其然还有,通过那个文件在/usr/games里找到了木马的大本营,里面有一堆这个入侵者的工具,于是就顺手保存了一份然后从服务器上删掉了。
另外还有自启动到底是怎么实现的?既然不是crontab……应该是systemd。看了一下果不其然有个服务在保持gs-dbus
的运行,不过程序我已经删了,所以它现在只会不停尝试重启,接下来只需要停止并禁用这个服务就行了。
至于为什么会被入侵……我也很清楚,其实并没有什么漏洞,单纯是设置的密码太简单了,被嘿客扫到啦!所以解决起来也很简单,把这些垃圾清除掉之后设置个稍微复杂一点的密码就行了。
既然这个嘿客都不删他的工具,留下来就是给我分析的吧?那么我就像上次一样分析一下他使用的工具吧~首先里面有个deploy-all.sh
文件,看起来应该是登录服务器之后最先执行的程序,在这里面有个压缩包,解压出来之后搜了一下里面的文件,发现是Global Socket项目,看起来应该是包含反弹Shell、伪装以及权限维持之类功能的一个小工具。看了下源代码才知道原来用exec -a
就可以伪装进程的名称,而且那个gs-dbus
就是这个项目里的程序……这么看来挖矿的操作应该是入侵者远程执行的代码,所以在查找进程的时候发现了它吧。
除此之外里面还有个logclean项目,看了一眼是mig-logcleaner-resurrected项目,看起来应该是清除日志用的,不过我根本没从日志找它🤣,即使入侵者用了对我来说也没起到什么作用。不过倒也是个挺有用的项目,也许在某些扫尾工作很有用。
最后就是libprocesshider这个项目,也许还有其他隐藏进程的方式,不过知道这个项目之后最起码以后再遇到类似的情况我就会优先去看/etc/ld.so.preload
文件了。
至于其他的就是一些爆破SSH的工具,估计是用来横向渗透的,看起来有点原始……也没啥用处,另外还有连接XMR矿池的一些配置文件,以及我也看不出来的玩意,应该就这么多有用的东西了。
虽然被入侵是没有预料的事情,但还好这个服务器是闲置的,装完系统之后上面什么有用的东西都没有,所以除了入侵者让它不太闲置赚了点小钱之外对我倒是没什么损失,另外还了解到了一些不错的小工具,这么看来入侵者赚的这点小钱就当是给他的学费吧🤣。
2025-07-01 00:00:00
虽然XML是机器读的内容……不过加上和主题一致的XSLT样式也算是一种细节吧~
在上一篇文章中,我提到在提高订阅源兼容性的时候给博客的订阅文件增加了一个XSLT样式。当时使用的样式是从About Feeds下的一个Issue中找的,里面有个基于Pretty Feed修改成能同时支持RSS和Atom格式的样式。虽然那个样式倒也说不上难看,但总觉得与我的博客整体风格有些割裂,所以这次打算制作一个和我博客主题完全一致的XSLT样式。
虽然想搞这么一个样式,但是我用的Jekyll引擎不能在引用的布局外添加额外内容……如果我要自己写,要么把我的默认布局拆成头和尾两部分然后用include引用,要么把默认布局的代码直接复制一份到XSLT样式中。这两个方案我都不太满意,第一种我以后在修改默认布局时需要同时从两个文件检查上下文,很不方便;而第二种方案违反了DRY原则,也会增加以后修改的难度。所以要怎么办呢?
后来我想了想,如果不能通过直接引用默认布局在外面增加XSLT的代码,那干脆让默认布局引用一个XSLT布局吧!这样我就能在不复制默认布局也不进行过多修改的情况下在外面套XSLT的代码了。于是我就在最外面写了个符合XSLT格式的XML布局,让默认布局引用它。然后再写一个布局引用默认布局,让最外面的布局根据这个布局的名字来判断是否需要使用XSLT的布局,具体的实现可以看我的layout目录。另外有一些地方需要注意一下,作为XML,内容中不能包含未闭合的标签,所有自闭合标签结尾必须添加斜杠,属性必须有值,以及所有标签和属性大小写要一致……还好我平时修改布局文件以及编写内容的时候基本上都遵循了这些规则,所以没什么太多需要改动的地方。
当时修改时,是模仿之前的那个样式进行的,原来那个样式在html
元素上加了XML命名空间,但是xsl:output
配置的输出却是按照HTML的方式输出,结果导致内容中用于换行的br
标签在实际转换中全部变成了两个标签……我猜应该是转换器看到XML命名空间后,先按照XHTML的规则把br
解析成了一开一闭的一对标签,然后又根据HTML的转换规则把这对标签当作两个单独的标签输出了吧……但奇怪的是,只有br
标签出现了这个问题,像hr
等其他自闭合标签则没有……既然如此,只要把XML命名空间删掉就OK了。
在改完之后虽然整体看上去和其他页面似乎已经很相似了,但总感觉还有些样式不太对劲……我猜应该是和文档类型声明有关系,我平时写的是HTML5,而XSLT默认转出来是HTML4.0……但是我不太清楚怎么解决这个问题,于是问了问AI,AI说在xsl:output
中加上doctype-system="about:legacy-compat"
就行。最终改完试了下确实有效😂,样式上也没有出现奇怪的偏移了。
最后把写好的布局应用到/feed.xslt.xml中就可以了,之所以是这个路径是因为我用的jekyll-feed只支持这个位置,至于我自己搞的RSS格式的订阅只需要在开头用xml-stylesheet
指令声明一下就行了。
在写好给订阅文件用的XSLT样式之后,我发现XSLT样式本身也是个XML文件……既然我给订阅文件做了样式,那么也得给XSLT样式文件本身做个样式才对,但如果我单独写一个给它的样式,那岂不是要给样式的样式再写一个样式😂,所以肯定不能这样做。不过仔细想一下,还有个办法,可以让XSLT样式文件自引用自身的样式,这样就能避免之前担心的套娃问题了。所以接下来我应该在XSLT中写一个检测应用样式的XML文件是不是XSLT样式文件的代码,方法很简单,既然XSLT样式中肯定包含xsl:stylesheet
这个元素,那么我可以判断如果存在这个元素,就可以确定这就是XSLT样式了,如果有人点开看了我就可以展示一个提示信息告诉访客这是一个样式文件,这样访客就不会看到那句“This XML file does not appear to have any style information associated with it. The document tree is shown below.”了😝。
既然给XSLT样式也加了样式……那我博客还有其他XML文件需要处理吗?似乎还有个Sitemap,我的Sitemap是jekyll-sitemap插件生成的……那它支持加样式吗?虽然文档上没有写,不过看了眼源代码发现可以通过创建/sitemap.xsl文件添加,所以就顺手套用之前的样式搞了一个(虽然应该没有访客去看Sitemap😂,毕竟这是给搜索引擎用的)。可惜这些地址都是插件硬编码的,如果可以自己修改位置我就只写一个XSLT样式文件就可以了……
折腾了这么多整体展示效果还不错,虽然这些文件也许根本没人看😂(本来就不是给人读的),但也算展现了一下博客的细节之处吧,而且在折腾的时候至少还了解了不少关于XML和XSLT的知识(尽管在现代这些好像没啥用了)。当然重要的也许不是了解这些知识,而是这个过程吧……总的来说还是挺有意思的。