默认情况下，想要启用Gitea的SSH代码拉取，必须要修改SSH端口或者禁用主机上的SSH。若想要直接使用宿主机上的SSHD，需要进行一些配置。Method A: 通过SSH透传建议使用在宿主机上创建git用户切换到新用户，使用id命令确定git用户的UID和GID，并将容器中运行的Gitea设置为相同的UID和GID（通过容器的USER_UID和USER_GID环境变量）将容器的SSH映射到主机上，检查端口是否可以正常访问，例如映射到2222端口: ssh git@127.0.0.1 -p2222，应当得到鉴权失败之类的信息，而不是等待许久之后报错无法连接使用git用户创建一对SSH密钥用于连接到Gitea：ssh-keygen将公钥写回到authorized_keys：cat ~/.ssh/id_*.pub >> ~/.ssh/authorized_keys将/home/git/.ssh 映射到容器中Gitea写入.ssh的位置，一般是/home/git/.ssh，可以通过Gitea app.ini的SSH_ROOT_PATH配置（配置此项请参考Gitea文档的Config Cheatsheet）。尝试在Gitea中添加一些密钥，检查宿主机目录中的/home/git/.ssh/authorized_keys中是否有内容，如果没有内容，则需要在Gitea后台中的“计划任务”页面手动执行“更新.ssh/authorized_keys”任务。然后找到要执行的命令是什么，即command=后的内容，一般是/usr/local/bin/gitea ...。在宿主机上同样的位置创建一个转发用的脚本并赋予执行权限，将SSH命令转发到容器中。#!/bin/bash ssh -p 2222 -o StrictHostKeyChecking=no git@127.0.0.1 "SSH_ORIGINAL_COMMAND=\"$SSH_ORIGINAL_COMMAND\" $0 $@"尝试拉取代码Method B: 通过容器命令透传在宿主机上创建git用户切换到新用户，使用id命令确定git用户的UID和GID，并将容器中运行的Gitea设置为相同的UID和GID（通过容器的USER_UID和USER_GID环境变量）允许新用户访问docker: usermod -G docker，针对k8s，请为其创建一份有效的kubeconfig，使其可以访问容器并执行命令将/home/git/.ssh 映射到容器中Gitea写入.ssh的位置，一般是/home/git/.ssh，可以通过Gitea app.ini的SSH_ROOT_PATH配置（配置此项请参考Gitea文档的Config Cheatsheet）。尝试在Gitea中添加一些密钥，检查宿主机目录中的/home/git/.ssh/authorized_keys中是否有内容，如果没有内容，则需要在Gitea后台中的“计划任务”页面手动执行“更新.ssh/authorized_keys”任务。然后找到要执行的命令是什么，即command=后的内容，一般是/usr/local/bin/gitea ...。在宿主机上同样的位置创建一个转发用的脚本并赋予执行权限，将SSH命令转发到容器中。#!/bin/bash docker exec -u git -i -e SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" gitea /usr/local/bin/gitea $@ # For K8s #_PARAMETERS=$@ #kubectl --kubeconfig /path/to/kubeconfig exec deployments/gitea -i -q -- su git -l -s /bin/sh -- -c "SSH_ORIGINAL_COMMAND=\"$SSH_ORIGINAL_COMMAND\" /usr/local/bin/gitea $_PARAMETERS""测试拉取推送代码
原文: OPAQUE: The Best Passwords Never Leave your Device作者: Tatiana Bradley更新于: 2022/01/19@2025/12/17 文章已经大体翻译完成，但是一些词句仍需斟酌密码很麻烦，相信大多数读者都有亲身体验。在Cloudflare，密码相关的问题深入并存在于我们的各种业务中。当下各个网站对密码的要求越来越复杂，相信大多数读者都会觉得密码非常难以记忆和管理。幸运的是，有一些很好用的软件和浏览器插件能帮助我们管理密码。然而，某些存在于更深层次的根本问题已经超出的这些软件的能力范围。密码本身的根本问题很容易阐明，却非常难解决：无论密码多复杂，多难以猜测，一旦离开了你的掌控，密码的安全就很难保证。换而言之，密码存在的本身就是一种不安全因素。你可能会说：“但密码都是加密储存的！”那再好不过了。更准确的说法是密码很可能以加盐的摘要形式储存（接下来会解释什么是“加盐的摘要形式”）。但很不幸，你压根没办法确认密码是以何种形式存储的，因为无法确认，我们可以猜测某些服务商可能正在以明文形式存储密码。但即使厂商负责任的存储了密码，密码仍然可能被泄漏和爆破，不过需要付出巨大的成本（至少这一点值得庆幸）。一个由密码本质而带来的问题正日益显现：当下任何需要使用密码的地方，都意味着需要明文处理密码。你说：“密码是通过HTTPS安全传输的！”这是事实。你说：“我知道服务器以摘要形式存储我的密码，非常安全，没有任何人能获取他！”好吧，也许你对服务商很有信心。忽略那些不负责任的服务商，这倒也是事实。即便如此，整个过程还遗留着一个重要的漏洞：密码的使用过程中存在一个安全真空。当服务器通过加密安全地传输并收到一个密码，并在安全地存储该密码之前，密码必须被读取和处理——没错，以明文的形式。更糟糕的是，人们更常从软件层面来考虑这些问题，很容易忽略硬件带来的漏洞。即使软件万无一失，密码也必定在某一时刻存在于内存中，也必须在某一时刻存在于共享总线以传输到CPU。这向旁路攻击者暴露了许多种形式的攻击面。当然，这些攻击面的存在可能性远远低于那些存在于传输和存储过程中的问题，但他们的严重性可一点不低（例如最近的一些CPU漏洞，幽灵（Spectre）和熔断（Meltdown），只能说警钟撅烂）。解决这些问题的唯一办法就是把所有密码全部干掉。这确实有可能！研究和私营部门正在努力尝试去达成这一目标。新标准正在如雨后春笋般冒出，并日渐成熟。理想很丰满，但密码太常见了，需要非常长的时间才能就新标准和新技术达成共识并用于取代密码。在Cloudflare，我们正在寻找一些能立刻、马上实现的手段。今天我们要探讨的OPAQUE是其中一种可能的解决方案。OPAQUE是一种在密码不离开你的掌控范围的情况下，确保密码有效的众多系统之一。没人喜欢密码，但密码仍然在被使用，就算如此，至少我们可以保证密码不被泄漏。如果要说有谁要承认基于密码的认证很恼人，那我就是第一个。密码——难以记住，难以输入，难以保证安全。减少密码使用或完全替代密码的倡议未来可期。例如，WebAuthn就是一种主要使用硬件（或软件）密钥来使用公钥进行Web身份认证的标准。即便如此，密码仍然非常不幸的以一种身份验证机制存在，大概是因为密码认证很容易实现，用户学习成本低，或者只是因为密码在互联网和其他地方很常见。我们只能让基于密码的认证方式在被替代之前尽可能的安全。我在Cloudflare的实习主要聚焦于OPAQUE，OPAQUE是一种加密协议，解决基于密码的认证系统里了一个显而易见的安全问题：尽管大多数情况下密码在传输过程中已经被HTTPS加密保护，服务器仍然需要以明文方式来校验密码的正确与否。明文处理密码很危险，若不小心被缓存，或被记录到日志中，都会导致灾难性的安全漏洞。这个项目的目标并不是推荐众多方案中的某一种，而是为了证明OPAQUE是其中可行的一种方案。我比较熟悉Web技术，而且基于Web的技术受众大概更广，接下来我会使用Web作为主要的例子。Web认证基础课：使用TLS加密传输密码（Password-over-TLS）当你在网页上输入密码时到底发生了什么？网站需要确认你输入的密码与你原本注册这个网站时使用的密码相同。但这一切是怎么运作的？通常来说，你的用户名和密码被发送到服务器，服务器会检查与用户名关联的密码是否与你提供的密码相同。当然，为了防止攻击者窃听你的网络流量来窃取你的密码，你与服务器之间的连接会使用HTTPS加密（HTTP-over-TLS）。就算使用了HTTPS，这个过程中仍然存在一个显而易见的问题：服务器必须在某处以某种形式保存你的密码。服务器很难保证完全安全，被黑入是常有的事情。泄漏保存的内容会导致灾难性的安全问题。（要想了解最近的服务器入侵事件，可以看看https://haveibeenpwned.com/。）为了降低泄漏事件的严重性，服务器通常会使用哈希(Hash)函数来保护用户的密码。哈希函数会将每个密码映射到独一无二的、看起来完全随机的值。对密码做哈希很简单，但想要反过来，从哈希后的值反推原始密码几乎是不可能的事情。（这也意味着，任何人都可以猜测一个密码，对其使用哈希函数，然后检查结果是否相同。）在密码被哈希后，服务器上不再存储明文密码。即使攻击者窃取了密码数据库，他也不能直接获取密码。攻击者需要对海量的可能使用的密码做哈希，然后再于泄露的哈希值做比较。但很不幸，如果服务器只将密码输入哈希函数中，攻击者可以下载一份已经计算好的彩虹表，表中包含数十亿个可能的密码的哈希值，几乎可以瞬间查出原始的密码。（可以看看https://project-rainbowcrack.com/table.htm，里面有包含一些彩虹表的列表）。考虑到这一点，使用加盐哈希是一种更进一步的防御方式，服务器在密码后增加一段每个用户都不同的随机值然后再进行哈希，这个随机值称为盐。这个盐与用户名一同存储，所以用户不会看见，也不需要提交盐。当用户提交一个密码时，服务器将密码与盐一同输入哈希函数中进行计算。即便攻击者窃取到密码数据（加盐哈希后的密码和盐本身），也需要猜测常见的密码，然后一个个输入加盐后的哈希函数中。现有的彩虹表没有任何作用，因为彩虹表并没有考虑盐的加入，所以攻击者需要为每个用户创建一个新的彩虹表！这能有效的减缓攻击者（也许吧），让厂商有足够的时间通知用户网站遭到入侵，以便用户修改密码。此外，应该多次应用哈希函数使得加盐的哈希更难被破解，从而进一步减缓攻击速度。（可以参考https://blog.cloudflare.com/keeping-passwords-safe-by-staying-up-to-date/，里面有更详细的讨论）。这两个缓解策略：传输过程加密和加盐反复哈希存储，是当下最佳的做法。不过，有个很大安全漏洞仍然没有被封堵。使用TLS加密传输密码仍然需要用户在登录时以明文形式将密码发送到服务器，因为服务器必须要读取这个密码，以便与文件中保存的密码进行匹配。即使是非恶意的服务器，也可能会意外的缓存或日志记录你尝试登录时用的密码，或者在检查密码时发生意外。（例如，Facebook检测到2019年时他们意外的保存了数亿个明文用户密码）。理想状况下，服务器不应该读取到任何明文的密码。但这听起来有点扯：如何在完全没见过密码的情况下检查密码是否正确？那就得请OPAQUE出场了，OPAQUE是一种密码认证的密钥交换（Password-Authenticated Key Exchange，PAKE）协议，能通过双方同时证明“我知道密码”来交换密钥。在详细讲解OPAQUE之前，我们先总结一下PAKE的通常的实现方法。使用密码认证密钥交换协议证明“我知道密码”基于密码认证的密钥交换（Password-Authenticated Key Exchange，PAKE）最初由Bellovin和Merrit[1]于1992年提出，最初的目的是在不安全的信道上传输数据时，保护密码认证不被字典爆破攻击。本质上，普通或对称的PAKE是一种加密协议，使知道同样密码的两个人建立一个复杂的共享密钥。PAKE的目标是：密码相同时，密钥就会相同，否则密钥看起来完全随机。参与通信的人不需要可信的第三方担保，也就是说不需要公钥设施。就算知道密码，只要没有参与通信，任何人都不知道最终的密钥。协议不需要向协议中的任意一方透露密码（除非他们的密码相同），也不会向窃听者透露任何一方的密码。（译注：当通信中的双方密码相同时会得到同样的密钥，借此可以得知对方的密码就是自己的密码。）总的来说，唯一能破解该协议的方法是在参与通信时成功猜测到正确的密码。（幸运的是，这些攻击者非常可能直接被请求限速之类的东西拒之门外。）根据这些要求来看，password-over-TLS显然不是一种PAKE，因为：他依赖WebPKI，需要信任第三者，也就是信任证书颁发机构（Certificate Authorities，CA）。（参考https://blog.cloudflare.com/introducing-certificate-transparency-and-nimbus/，深入了解WebPKI和他的不足之处。）服务器需要知道用户的密码是什么Password-over-TLS不能向用户保证服务器知道他的密码或密码的衍生值，服务器能接受用户的任何输入，而不需要进行任何检查。就算如此，纯PAKE也还是比Password-over-TLS更糟糕，因为它要求服务器存储明文密码。为了解决这个问题，我们需要一种新的PAKE，允许服务器存储加盐的哈希。非对称密码认证密钥交换（asymmetric PAKE，aPAKE）是一种比纯PAKE更好的协议，因为只有客户端才知道密码，而服务端只知道经过哈希的密码。aPAKE拥有PAKE的4个基本特性，同时还额外拥有一个特性：即使攻击者窃取了服务器上的密码数据，他仍然需要进行字典攻击才能获得原密码。但现有的aPAKE协议也并非完美无缺，它们不能使用加盐的哈希（如果想使用加盐的哈希，就需要将盐告诉用户，这使得攻击者在窃取用户数据前就可以提前获取到盐，然后提前计算彩虹表）。因此，我们还需要一些额外的安全特性：即使攻击者窃取到了保存在服务器上的密码数据，也需要对每一个用户的密码都做字典攻击才能获取到原本的密码。OPAQUE是第一个具有形式化安全证明的aPAKE协议，使用完全保密的盐值。OPAQUE - 服务器在不知道机密数据的情况下保护机密！OPAQUE是一种强aPAKE，这个协议通过使用一个保密的加盐哈希值来防御预先计算攻击。OPAQUE由Stanislaw Jarecki，Hugo Krawcyzk和Jiayu Xu于2018年提出并进行了形式分析。（利益相关：Stanislaw Jarecki是我的学术顾问。）OPAQUE这个名字是由OPRF和PAKE两个加密协议的名字组合起来而得到的。OPRF是什么？OPRF全称是Oblivious Pseudo-Random Function，无意识伪随机函数，该协议中的双方会计算一个输出结果确定的函数F(key, x)，得到一个看起来完全随机的结果。其中一方输入x，另一方输入key，输入x的一方知道F(key,x)的结果但是不知道key是什么，输入key的一方则完全无法得到任何信息。（要详细了解OPRF的数学原理，可以参考https://blog.cloudflare.com/privacy-pass-the-math/。）OPAQUE的核心是一个在服务器不知道这些机密的情况下安全保存用户机密的方法。服务器上不再保存密码的加盐哈希，而是保存由两份信息“锁定”的机密信封：只有你才知道的密码和只有服务器才知道的随机密钥（就像盐）。登录时，客户端会发起一次加密交换，以获取信封的钥匙，而这一过程中最重要的是，服务器完全无法获取这个密钥。接着服务器会将信封发送给用户，用户可以获取这个加密密钥。（信封里的密钥是一个客户端用的公私钥密钥对，和服务器的公钥。）这些密钥解锁后会被输入进一个认证密钥交换（Authenticated Key Exchange，AKE）协议，使客户端和服务器能协商一个用于加密之后通信的密钥。OPAQUE有两个阶段，凭据登记和密钥交换登录。OPAQUE：凭据注册阶段在登记凭据前，用户会先选择用户名和密码用于注册服务。接下来登记会遵循之前所说的OPRF步骤：Alice（用户）和Bob（服务器）进行一次OPRF交换。交换完成后，Alice会有一个从OPRF结果_F(key, pwd)_派生出来的随机密钥rwd。key由服务器持有，是服务器为Alice生成的密钥，独一无二；而pwd就是Alice的密码。在有了这个OPRF消息后，Bob发送OPAQUE标识的公钥。Alice生成一个新的公私钥密钥对，这个密钥对将作为他在Bob的服务上的永久OPAQUE标识，并使用Bob的公钥和rwd加密她的私钥（结果被称为_加密信封_）。她将这个加密信封与她的（没有加密的）公钥一同发送给Bob，Bob将她提供的数据与她专属的OPRF密钥存储到数据库中，并使用用户名作为这些数据的索引。OPAQUE：登录阶段登录阶段非常相似。登录阶段如同注册阶段，从一次OPRF流程开始。但不同之处在于，Bob（服务器）不再生成新的OPRF密钥，而是从数据库中查找在注册阶段保存的专属于Alice的密钥。Bob通过使用Alice在第一条信息中提供的用户名来完成查找，并获取对应的记录。记录包括Alice的公钥，她的加密信封，和Bob与Alice通信时的专用OPRF密钥。Bob同样发送通过加密信封发送，Alice能使用OPRF输出的结果来解密这些数据。（解密失败时，Alice就会终止协议，这通常意味着Alice输入了错误的密码，或者对方并不是Bob。）如果解密成功，她就拥有了自己的公私钥密钥对和Bob的公钥。Alice将这些输入AKE协议，接着Bob就会输入他自己的私钥和Alice的公钥，这样双方就会都有了一个新的共享密钥。将OPAQUE与AKE集成有个重要的问题还有待解决：哪种AKE适合OPAQUE？新兴的CFRG规范概述了几种选择，例如3DH和SIGMA-I。而在网页上，我们早就有了一种选择：TLS。TLS是一种AKE，因为它提供了单边和双向的认证，并且采用共享密钥派生机制。TLS的核心是Diffie-Hellman密钥交换，这个交换是不需要认证的，也就是说参与者无法验证彼此的身份。（这会导致一些问题，假如你要登录银行，或者什么其他存储了你的机密数据的网站，你肯定需要确保他的身份跟他声明的一致。）认证主要使用证书，证书由可信机构通过公钥基础设施（Public Key Infrastructure，PKI）颁发。每个证书都与一个私钥关联。为了表明自己的身份，服务器向客户端提供自己的证书，并使用私钥对TLS握手进行签名。直接修改这个被广泛使用的基于证书的认证可能不大现实。我们可以退一步，在TLS握手完成后，使用OPAQUE对TLS共享密钥进行认证。换而言之，当服务器使用传统的WebPKI证书完成认证后，客户端可以接着向服务器进行身份认证。这个认证可以使用OPAQUE在TLS连接握手后完成。Exported Authenticators是TLS握手后认证的一种机制。它们允许服务器或者客户端提供身份证明，而不用建立一个新的TLS连接。在标准的Web场景下，服务器通过证书建立它们的身份证明（例如，证明它自己是“cloudflare.com”）。但如果服务器有一个其他的身份，它必须重新建立一个TLS连接来证明他的另一个身份。（译注：Exported Authenticator 没有标准译名，按字面意翻译后容易被误解为“从验证器应用导出的TOTP验证器”，故保留原文以表明其是一个专有名词。）最基础的Exported Authenticators流程类似于传统的“质询-回复（Challenge-Response）”协议，其工作原理如下（我们只考虑服务器验证的情况，客户端验证与其相同）：在TLS连接建立后的任意时刻，Alice（客户端）发送一个验证请求，表示他希望Bob（服务器）证明一个额外的身份。这个请求包括一个上下文（一个不可预测的字符串，可以将这个视为一个质询）和一个拓展，拓展内包括希望提供哪个身份证明的信息。例如，客户端可以包含一个SNI拓展，以在TLS连接中要求服务器提供一个不同于最初使用的域名的证书。收到来自客户端的消息后，如果服务器有一个有效的证书来响应这个请求，服务器就会返回一个Exported Authenticator，证明服务器拥有这个证书的私钥。（这个消息与TLS 1.3 握手时使用的Auth消息的格式相同，里面包括一个Certificate，一个CertificateVerify和一个Finished消息。）如果服务器不能或者不想提供所请求的证书的认证，服务器就会返回一个空的验证器，只包括一个Finished消息。然后客户端检查收到的Exported Authenticator的格式是否正确，接着验证其提供的证书是否有效，如果有效，就认可服务器提供的新身份。总的来说，Exported Authenticator 利用 TLS 中经过充分验证的加密方法和消息格式，提供了一种能在七层模型中的高层（例如在应用层）安全使用的验证方法。此外，它与TLS会话绑定，验证消息无法从一个TLS会话里复制粘贴到另一个会话中。换而言之，Exported Authenticator提供了一个绝妙的接口，使得基于OPAQUE的认证能添加到TLS中。基于Exported Authenticators的OPAQUE （OPAQUE-EA）OPAQUE-EA使得OPAQUE在TLS连接建立后的任何时刻都可以运行。Bob（服务器）将存储他自己的OPAQUE身份信息（在OPAQUE-EA中是签名密钥和验证密钥），Alice（客户端）会她自己的身份信息加密存储在Bob的服务器上。（在OPAQUE-EA的注册流程中，Alice存储自己的加密密钥的步骤与普通的OPAQUE大致相同，唯一的不同点在于她保存的是一个签名密钥，所以在此之后会直接跳到登录流程。）Alice和Bob会执行两个“请求-验证”EA（Exported Authenticator）流程，每个人各一次，OPAQUE协议的信息通过EA的拓展部分传递。让我们看看其中的细节。首先，Alice基于她的密码生成她的OPRF消息。她创建了一个验证请求（Authenticator Request）来获取Bob的OPAQUE身份信息，并在EA拓展部分中填入自己的用户名和OPRF消息，通过已经建立的TLS连接发送给Bob。Bob收到消息后，在他的数据库中根据Alice的用户名查找对应的记录。Bob找到了Alice的OPAQUE记录，里面包括Alice的验证密钥和加密信封，以及Bob自己的OPRF密钥。他将OPRF密钥用于OPRF信息，创建了一个在拓展部分包括他自己的OPRF信息和加密信封的EA来证明自己拥有他的OPAQUE签名密钥。此外，他还发送了一个新的Authenticator Request，要求Alice证明她拥有她自己的OPRF签名密钥的所有权。Alice解析了信息。并使用Bob的信息完成OPRF评估，以获得输出rwd。接着Alice使用rwd解密信封，得到她自己的签名密钥和Bob的公钥。他使用Bob的公钥去验证他的Authenticator Response证明，如果验证成功，她就会创建一个Exported Authenticator证明她持有刚刚解密的签名密钥。Bob检查她发回的Exported Authenticator的有效性，如果有效，他就会允许Alice登录。我的项目：基于HTTPS的OPAQUE-EA以上所有东西都有大量的理论支持，但仍缺乏实践。我的项目把理论变成了现实。我首先撰写了Exported Authenticator和OPAQUE的基本定义，和OPAQUE-in-TLS的基础草案。我的目标是把他们变成一个基本能用的原型。我的示例项目展示了在Web上实现OPAQUE-EA的可行性，将密码（即使是加密的）彻底从传输过程中抹除。这提供了一种用于替代当前常用的password-over-TLS流程的方案，拥有更好的安全性，对用户没有可感知的变化。一些实现细节很值得注意。在计算机科学中，抽象是一个很有用的工具。他意味着能依赖现有的工具和API以避免重复工作。在我的项目中重度依赖于mint，一个开源的Go语言TLS1.3 实现，非常适合做原型。我也使用了CIRCL的OPRF API。我构建了这些库用于Exported Authenticator，也就是OPAQUE和OPAQUE-EA的核心，用于将两者联系起来。我制作了Web示例，通过将OPAQUE-EA的功能包装进一个简单的HTTP服务器和客户端，双方可以通过HTTPS传递消息。由于浏览器不能直接运行Go，我将Go编译成了WebAssembly（WASM）让Go在浏览器里工作起来，并写了一个简单的JavaScript脚本去调用需要的WASM函数。由于浏览器不允许在客户端侧访问底层的TLS连接，我不得不实现一个workaround以允许客户端访问exporter密钥，具体来说，就是服务器计算密钥并通过HTTPS发送给客户端。这个workaround降低了示例的安全性，因为这意味着安全性要靠服务器提供了正确的密钥来保证。即便如此，即便恶意的服务器提供了错误的密钥，用户的密码仍然是安全的，它们无法确定它们是否之前真的在这个服务器上注册过。在未来，浏览器可以提供一个基础设施，以支持exported密钥，并允许OPAQUE-EA以完整的安全特性运行。你可以在GitHub上浏览我的实现，甚至可以根据说明搭建你自己的OPAQUE-EA的测试服务器和客户端。不过我还是要强调，这个实现只是一个概念验证的原型，不要在经过进一步的深入审查之前用于生产环境系统。OPAQUE-EA的局限性尽管OPAQUE-EA有很多优点，但想要将它从一个PoC变成完善的认证机制，仍然存在一些挑战。浏览器需要支持TLS exporter keys。在之前简短的提到过，想要在浏览器里运行OPAQUE-EA，你需要能够访问TLS连接中称为exporter keys的密钥。这在常用的浏览器中完全没有办法做到，这需要它们添加这项功能的支持。需要对密码数据库进行重大的修改。为了适配OPAQUE-EA，服务器不仅需要更新他的密码检查逻辑，也需要对数据库进行大量的修改。因为OPAQUE并不直接保存密码，而是基于一种特殊的密码表示，只能在用户交互下生成，现有的加盐哈希密码不能自动升级为OPAQUE记录。服务器大概率需要对每个用户都运行一次特殊的OPAQUE注册流程。因为OPAQUE依赖于服务端与客户端双方的认可，故服务器需要在客户端完全支持OPAQUE只签名保留原有鉴权方法的支持。依赖新兴标准。OPAQUE-EA依赖于正在标准化进程中的OPRF和还处于草案阶段的Exported Authenticators。这意味着这些依赖并未在大多数现有的加密库中受到支持，若想提前使用OPAQUE-EA，用户可能需要自己实现这些依赖。PAKE，特别是aPAKE，允许服务器在不知道密码的前提下实现安全登录。总结只要人们还在使用密码，我们就需要让密码使用的过程变得尽可能的安全。现有的方法依赖于在服务端以明文的形式检查密码的正确性，而这存在风险。其他公司也在探索OPAQUE的使用。基于Facebook的Novi Research小组成员Kevin Lewi的说法，他们“对OPAQUE提供的强力加密保障感到兴奋，而且正在积极研究使用OPAQUE来进一步保护存储在服务器上的凭据保护的字段”。OPAQUE是aPAKE中最出色的一种，可以完全整合进入TLS。你可以在这里查看OPAQUE的核心实现，也可以在这里查看将其整合进TLS的示例。一个可以使用的示例也可以在这里试用。一个OPAQUE的TypeScript客户端实现即将完成。如果你对实现这个协议感兴趣，或者在当前的实现中发现了任何bug，可以通过mailto:ask-research@cloudflare.com联系我们！也请订阅IRTF CFRG邮件列表跟踪OPAQUE的规范化和标准化讨论。[1] Bellovin, S. M., and Merritt, M. “Encrypted key exchange: Password-based protocols secure against dictionary attacks.” In Proc. IEEE Computer Society Symposium on Research in Security and Privacy (Oakland, May 1992), pp. 72–84.
较新的Electron APP在Adreno 640 + Freedreno驱动下渲染花屏
2026.01.23VSCode 貌似又更新Electron版本，新的libGLESv2.so不会导致花屏。但实测Vulkan会显著提升渲染效率，降低CPU占用，考虑开启Vulkan仍是一个好选择。0.问题表现很多Electron App，例如VSCode，QQ，在更新到较新的版本时（大约在2025/07后）会发生花屏：有两种解决方法。1. 启用Vulkan可以通过命令行参数或者修改启动入口JS文件，一般直接修改命令行参数最为方便，直接在命令后添加--enable-features=Vulkan即可。例如VSCode，需要修改几个文件:/usr/share/code/bin/code, /usr/bin/code, /usr/share/applications/code.desktop, /usr/share/applications/code-url-handler.desktop。2. 替换libGLESv2.so为旧版本部分App不支持通过命令行参数启用Vulkan，这时可以通过替换libGLESv2.so为旧版本解决，这个方案可能在未来导致应用崩溃，若能通过Vulkan解决尽量不要使用此方案。大部分Electron App都有通用的一些类库，我们可以直接从VSCode的旧版本提取。VSCode在2025.07升级了Electron版本，我们使用2025.05（1.101）版：https://update.code.visualstudio.com/1.101/linux-arm64/stable下载解压后找到libGLESv2.so，直接替换目标应用的libGLESv2.so，例如QQ的位于/opt/QQ/，重启应用即可解决。
Linux Wireguard + nftable 实现内网穿透并保留源IP (DNAT)
两台机器, 一台内网机器N, 一台有公网G.仅能保留TCP的连接的IP, UDP连接配置过于繁琐, 故直接使用动态伪装以简化操作, 此时看到的IP为公网机器G的IP.一些配置中的可变量, 可以按需更改:变量名用途wg0Wireguard的接口名称, 文章同时用作了Nftable转发规则的表名称172.16.0.1/24Wireguard接口IP, 组网后网络中的IP, 可以任意修改为任何你喜欢的IP段Table = 1Wireguard使用的路由表, 在内网机器N配置时的PostUp和PostDown中有引用, 路由表ID范围为0~32768, 0为系统默认占用, 任意修改首先开启公网机器G的转发:echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf sysctl -p在两台机器上都安装Wireguard, 并都生成一对公私钥.cd /etc/wireguard wg genkey > wg0.key cat wg0.key | wg pubkey > wg0.pub输出:# wg genkey > wg0.key Warning: writing to world accessible file. Consider setting the umask to 077 and trying again. # cat wg0.key | wg pubkey > wg0.pub # cat wg0.pub LTzVnao892Iifn835NNdi398jNNIO39dj53MczezRH0= # cat wg0.key GHjJFZ7K8diUdk23MidPc991VOll+ZsiVdnMa8aiWlI=该操作需要在两台机器上都完成一次.配置公网机器G:/etc/wireguard/wg0.conf[Interface] # 监听端口, 任何你想要的端口都行 ListenPort = 1145 # 公网机器G在Wireguard中的IP, 建议设置成内网IP段 Address = 172.16.0.1/24 # 生成密钥时, 在公网机器G中/etc/wireguard/wg0.key的内容 PrivateKey = GHjJFZ7K8diUdk23MidPc991VOll+ZsiVdnMa8aiWlI= # 启动时加载转发规则 PostUp = nft -f /etc/wireguard/wg0.nft # 关闭时清除转发规则 PostDown = nft delete table wg0 [Peer] # 生成密钥时, 在内网机器N中/etc/wireguard/wg0.pub的内容 PublicKey = LTzVnao892Iifn835NNdi398jNNIO39dj53MczezRH0= # 需要设置成同一网段 AllowedIPs = 172.16.0.0/24配置内网机器N:/etc/wireguard/wg0.conf:[Interface] # 内网机器N在Wireguard中的IP, 需要与公网机器同一网段 Address = 172.16.0.2/24 # 生成密钥时, 在内网机器N中/etc/wireguard/wg0.key的内容 PrivateKey = QE6m/gMDgJ2LQA7lj3+5kz7fouX3awNzQy2ruXV/KX0= # 使用另外的路由表处理转发, 可以是任何小于32768的数字 Table = 1 # 配置路由使流量发回到Wireguard中 PostUp = ip rule add from 172.16.0.2 table 1; # 关闭时清除路由 PostDown = ip rule del from 172.16.0.2 table 1; [Peer] # 生成密钥时, 在公网机器G中/etc/wireguard/wg0.pub的内容 PublicKey = LTzVnao892Iifn835NNdi398jNNIO39dj53MczezRH0= # 公网机器G的IP与你设置的端口 Endpoint = 1.2.3.4:1145 # 特别的, 需要设置成接受所有流量, 以处理转发 AllowedIPs = 0.0.0.0/0 # 保持隧道开启, 否则公网机器在一段时间后无法访问内网机器 PersistentKeepalive = 20配置转发, 只需要在公网机器G上操作:/etc/wireguard/wg0.nft:table ip wg0 { # 内网机器N在Wireguard中的IP define dest = 172.16.0.2 # 需要转发的TCP端口 define tcp_ports = { 22, 25565 } # 需要转发的UDP端口 # define udp_ports = { 53 } # 公网机器的出口接口, 可以用ip addr命令查看 define infc = eth0 chain prerouting { type nat hook prerouting priority dstnat; policy accept; # DNAT转发TCP iif $infc tcp dport $tcp_ports dnat to $dest # DNAT转发UDP, 有转发需求可去掉井号注释 # iif $infc udp dport $udp_ports dnat to $dest } chain postrouting { type nat hook postrouting priority srcnat; policy accept; # 对UDP使用动态伪装, 能大幅简化配置, 但会丢失源IP iif $infc meta l4proto udp masquerade } }在两台机器上都启动Wireguard并配置开机自启:wg-quick up wg0 systemctl enable wg-quick@wg0References:妖月. (2023, January 17). 通过Wireguard实现内网穿透. 妖の森. https://fjwr.xyz/archives/174/
升级系统到Ubuntu 24.04之后发现之前的longhorn卷全部无法挂载，包括新创建的卷，虽然能正常附加到服务器上，但是无法mount，mount就在dmesg里提示Can't open blockdev，通过fsck检查提示卷被占用，进行只读式的检查未发现问题。可以通过dd导出卷镜像到文件，导出到文件后正常挂载。此时判断Longhorn出现问题，经过多方查阅，发现其与Multipath冲突，虽然不知道为什么这东西现在才冲突。解决方法是在multipath的配置中排除掉Longhorn创建的磁盘：/etc/multipathd.conf:添加到末尾:blacklist { device { vendor "IET" product "VIRTUAL-DISK" } }
超次元游戏 海王星：重生1/2 (Neptunia Rebirth 1/2) Xbox 手柄图标MOD
简介:本人用Xbox手柄玩nep，玩了一段时间被强行带偏使用习惯，变成B确认A取消，玩其他游戏的时候受到重大打击。故自己整合了网上的成果，本人只做了一点小小的工作，感谢互联网的大佬无私分享。* 只交换了图标，实际按键未改变，需要手动在设置里重新映射，使用游戏内设置将Button 1分配到A键，Button 2分配到B键即可。Mod效果:下载：适用于 超次元游戏 海王星: 重生1的Mod文件包适用于 超次元游戏 海王星: 重生2的Mod文件包使用教程：下载mod解压到游戏根目录，steam用户可以通过浏览游戏文件打开游戏根目录，其他情况根目录即为NeptuniaReBirth(1/2).exe文件所在的文件夹。解压后NeptuniaReBirth(1/2).exe文件同目录下应该有dinput8.dll。 直接启动游戏。在游戏设置中交换A/B键的绑定，建议使用鼠标进行选择和确定，有点点绕: 爽玩引用资源:neptards/neptoolsXbox Series X Button Prompts for Re;Birth1
很早之前就想要一个比轻薄本还便携的设备了，最好能像iPad一样优雅就好了。然后就锚定了一块小米平板5，买了个12+512的终极扩容版本，再加一块Doqo的磁吸悬浮支架键盘，性能够用，这玩意带上键盘也足够轻薄，长得像iPad但是不装iPad OS，加一起1700RMB拿下，简直薄纱iPad。 本来计划是用Windows的，因为Windows下驱动比较完善，而且部署方便，常用软件都有，而且还符合我长期的操作习惯。但是，Windows下没法驱动这块键盘的触摸板...我反反复复找了好几块键盘，发现都做得很屎，还很丑，果然还是放不下这块键盘，思来想去直接用Linux，虽然驱动不是特别完善，但是至少能发挥这块键盘的全部实力，实现完整形态。软件的问题都不是问题，硬件才让人头大，头铁刷入Linux。 故作次踩坑笔记，方便后来之人。安装参考教程: https://github.com/nabu-alarm/installation-guide/blob/main/guide/English/install.md可以使用他的UEFI Installer和nabu-alarm/images提供的rEFInd，可以dualboot到安卓和Linux。已过时的步骤，timoxa0将托管在他服务器上的数据全部删掉了，文件失传，仅供参考。使用lon-tool一键安装，非常方便，会设置好rootfs和UEFI启动，之后可以方便的把自己编译的内核替换上去。参考教程: https://github.com/nabu-alarm/installation-guide/blob/main/guide/English/install.md简述安装工具: powershell.exe -C "irm https://s.tx0.su/ltw | iex"下载镜像: https://timoxa0.su/?dir=share/nabu/images/v3/latest刷入: lon-tool deploy /path/to/image.lni编译6.12内核上一步镜像使用的是6.1的镜像，通常来说也够用，不必自行编译内核。实际上可以使用6.12的内核，记录在Ubuntu 24.04 x64上的编译步骤。思路参考: Nabu merge request in postmarketOS安装依赖: sudo apt install build-essential gcc-aarch64-linux-gnu bc flex bison 7zip kmod bash cpio binutils tar git wget dpkg libssl-dev (参考map220v/ubuntu-xiaomi-nabu的workflow)拉取6.12内核的代码: git clone https://gitlab.postmarketos.org/panpanpanpan/sm8150-mainline.git --branch 6.12-wip --depth 1修补内核: 请参考在小米平板 5 上安装 Arch Linux 只需要参考修补的内容，编译步骤在Ubuntu上完全不同针对patch文件，使用git apply /path/to/patch，其他文件找到相应的文件并直接替换。修改后编译出来的内核会有dirty标识，可以使用git commit -am 'patch'去除。准备编译，指定编译器: export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-加载配置: make defconfig sm8150.config按需修改配置: make -j16 nconfig建议启用framebuffer console的rotate功能，因为一般横着用，但是默认是竖起来显示启动日志的。一些自用额外的配置# put these to arch/arm64/configs/extra.config # make defconfig sm8150.config extra.config CONFIG_LOCALVERSION="-nabu" # Xbox controller supporting CONFIG_INPUT_JOYSTICK=y CONFIG_JOYSTICK_XPAD=y CONFIG_JOYSTICK_XPAD_FF=y CONFIG_JOYSTICK_XPAD_LEDS=y # Wacom stylus supporting CONFIG_INPUT_TABLET=y CONFIG_TABLET_SERIAL_WACOM4=y # Framebuffer console CONFIG_FONTS=y CONFIG_FONT_TER16x32=y CONFIG_FRAMEBUFFER_CONSOLE_ROTATION=y # Network Tunneling & iPhone USB network sharing CONFIG_NET_IPIP=y CONFIG_USB_IPHETH=y # F2FS CONFIG_F2FS_FS_SECURITY=y CONFIG_F2FS_FS_COMPRESSION=y # ZRAM CONFIG_ZRAM=m CONFIG_ZRAM_BACKEND_LZ4=y CONFIG_ZRAM_BACKEND_LZ4HC=y CONFIG_ZRAM_BACKEND_ZSTD=y CONFIG_ZRAM_BACKEND_DEFLATE=y CONFIG_ZRAM_BACKEND_842=y CONFIG_ZRAM_BACKEND_FORCE_LZO=n CONFIG_ZRAM_BACKEND_LZO=y CONFIG_ZRAM_DEF_COMP_LZORLE=n CONFIG_ZRAM_DEF_COMP_ZSTD=y CONFIG_ZRAM_DEF_COMP="zstd" # ZSwap CONFIG_ZSWAP=y CONFIG_ZSWAP_DEFAULT_ON=y CONFIG_ZSWAP_COMPRESSOR_DEFAULT_ZSTD=y CONFIG_ZSWAP_COMPRESSOR_DEFAULT="zstd" # EFI CONFIG_RTC_DRV_EFI=n CONFIG_DRM_PRIVACY_SCREEN=y # Compressed firmware support CONFIG_FW_LOADER_COMPRESS=y CONFIG_FW_LOADER_COMPRESS_XZ=y CONFIG_FW_LOADER_COMPRESS_ZSTD=y编译: make -j16使用ukify将镜像打包为efiexport _kernel_version="$(make kernelrelease -s)" mkdir ../linux-xiaomi-nabu-kernel # 创建SecureBoot测试密钥，密钥可以重复使用，后面编译时直接复用先前创建的密钥 openssl genrsa -out sb.key 2048 openssl req -new -x509 -key sb.key -out sb.crt -days 365 -subj "/C=CN/ST=Test/L=Test/O=Test/CN=Test SB Certificate" export cmdline_quiet="quiet loglevel=3 systemd.show_status=auto rd.udev.log_level=3 vt.global_cursor_default=0" export cmdline_root="root=PARTLABEL=linux rw" export cmdline_console="console=tty0 fbcon=rotate:1,font:TER16x32" export cmdline_other="systemd.gpt_auto=no cryptomgr.notests" # 打包 ukify build --linux ./arch/arm64/boot/Image --cmdline "${cmdline_console} ${cmdline_root} ${cmdline_quiet} ${cmdline_other}" --devicetree ./arch/arm64/boot/dts/qcom/sm8150-xiaomi-nabu.dtb --secureboot-private-key sb.key --secureboot-certificate sb.crt --output ../linux-xiaomi-nabu-kernel/${_kernel_version}.efi过时的步骤，不再使用simpleinit拷贝内核和dtb:mkdir ../linux-xiaomi-nabu-kernel export _kernel_version="$(make kernelrelease -s)" cp arch/arm64/boot/Image ../linux-xiaomi-nabu-kernel/vmlinux-$_kernel_version cp arch/arm64/boot/dts/qcom/sm8150-xiaomi-nabu.dtb ../linux-xiaomi-nabu-kernel/dtb-$_kernel_version拷贝内核模块 make modules_install INSTALL_MOD_PATH=../linux-xiaomi-nabu-kernel删除不必要的文件:rm -rf ../linux-xiaomi-nabu-kernel/lib/modules/*/build rm -rf ../linux-xiaomi-nabu-kernel/lib/modules/*/source打包文件夹推送到设备，仅需要打包lib和*.efi将内核模块文件夹放在/usr/lib/modules下，文件夹在vmlinuz旁边的/lib/modules目录下，以内核版本号命名。将vmlinuz-xxx和dtb-xxx放置在/boot/efi下.修改simpleinit配置:以root权限编辑/boot/simpleinit/simpleinit.uefi.cfg：会看到一大串boot.configs.linux.xxx=yyy将这些全数复制粘贴到文件结尾，并把boot.configs.linux.改成boot.configs.linux2.，重点是configs后面跟着的id，simpleinit使用id表示不同的启动项。修改.desc，这会决定他在启动菜单中显示的文字说明，我建议的是修改为Linux ${KernelVersion}，例如Linux 6.12.0-nabu-g2e1ac76b。修改.extra.kernel和.extra.dtb，指向编译好的内核文件。修改extra.cmdline: pd_ignore_unused clk_ignore_unused root=PARTLABEL=linux loglevel=6 fbcon=rotate:1，重点是添加pd_ignore_unused clk_ignore_unused，否则UFS可能无法正常驱动。删除旧的固件并添加新的固件，6.12内核无法加载6.1使用的旧固件。删除/usr/lib/firmware (考虑备份)从KawaiiZapic/ubuntu-xiaomi-nabu下载xiaomi-nabu-debs_6.7-working.zip，解压后得到firmware和alsa的deb文件，可以直接安装，如果无法安装可以尝试根据apt显示的包名称卸载旧的包。重启设备，通过音量键选择新的内核启动可能会遇到跑日志后直接黑屏，尝试用simpleinit的启动菜单完全关闭设备，然后等待一会，再重试。如果重试了五六次还不行，则可能是内核的问题，检查编译步骤。
在装饰器, HTTP请求与Typescript中, 我们使用装饰器实现了声明式的API描述与调用, 但问题在于ESLint并不认为这是好的.为了让ESLint能够识别这个新的用法, 我们需要写一些新的ESLint规则.1. 这是好的首先我们需要让ESLint停止将这些变量标记为未使用, 由于我能力有限, 实在没有找到合适的办法, 只能hook原来的no-unused-vars检查函数, 并检查每个被报告为未使用的函数参数是否被装饰器装饰:import { plugin } from "typescript-eslint"; const OrigNoUnUsedVars = plugin.rules["no-unused-vars"]; const rules = { "no-unused-vars": { meta: OrigNoUnUsedVars.meta, defaultOptions: OrigNoUnUsedVars.defaultOptions, create(ctx) { const hookedCtx = new Proxy({}, { get(target, name, receiver) { if (name === "report") { return function report(data) { const node = ctx.sourceCode.getNodeByRangeIndex(ctx.sourceCode.getIndexFromLoc(data.loc.start)); if (node.type === "Identifier") { // 普通参数, e.g. function a (@Deco p: string) {} // ^ if (node.decorators?.length > 0 && node.parent.type === "FunctionExpression") { return; // 带默认值的参数, e.g. function a (@Deco p = "default") {} // ^ } else if (node.parent?.type === "AssignmentPattern" && node.parent.decorators?.length > 0) { return; } } return ctx.report(data); }; // 啥也不是 } else { return Reflect.get(ctx, name, receiver); } } }); return OrigNoUnUsedVars.create(hookedCtx); } } }然后再将其添加回ESLint的ruleset中, 覆盖掉原来的no-unused-vars, 就会发现被装饰器装饰的函数参数不再被报未使用了.但是这样显然太简单了, 既然都开始写了, 那我们也可以写一些更详细的检查.2. 人能有多坏?在一个方法/参数上同时使用多个装饰器@Get("/") @Post("/") // 这是坏的! public a () {}@Get("/") public a ( @Param("a") @Param("b") a: string // ^^^^^^^^^^^ 这是坏的! ) {}@Get等装饰器的URL参数变量未被声明@Get("/{id}") // ^^^^ 这是坏的! public a () {}@Path装饰器声明的参数在URL中没有对应的变量@Get("/") public a ( @Path("a") a: string //^^^^^^^^^^ 这是坏的! ) {}参数名与装饰器对应的字段的不匹配由于入参时并不知道每个参数会映射到哪个字段, 所以需要进行一定的约束, 否则会出现@Param("pageNum") pageSize之类的不易被发现的问题.@Get("/") public a ( @Path("a") b: string // ^ 这是坏的! ) {}我们需要识别这些问题并给出提示.3. 找出坏人由于装饰器只能被应用到class上, 考虑只分析MethodExpression./** @type { import("eslint").ESLint.Plugin } */ const plugin = { rules: { "bad-decorator-usage": { create(ctx) { return { MethodExpression(node) { // 准备分析... } } } } } }先使用typescript-eslint Playground分析我们的错误用例, 将代码贴入编辑框后查看ESTree选项卡(忽略红波浪线, 我们的目的是了解ESLint是如何理解我们的代码的):接下来的分析方式也如上所示, 不再贴图.可以发现ESLint发现我们的方法a上有俩装饰器, 并使用一个数组列出.故可以直接遍历decorators数组筛选出上面名为Get Post Patch之类的装饰器, 并在数量超过一个的时候发出警告:const DecoratorNames = ["Get", "Post", "Delete", "Patch", "Put"]; /** @type { import("eslint").ESLint.Plugin } */ const plugin = { rules: { "only-1-decorator-allowed": { create(ctx) { return { MethodExpression(node) { const decorators = node.decorators.filter(d => { return d.expression.type === "CallExpression" && DecoratorNames.includes(d.expression.callee.name); }); if (decorators.length > 1) { decorators.forEach((d, i) => { if (i === 0) return; // 除了第一个其他都警告 ctx.report({ loc: d.loc, message: `方法 ${node.key.name} 上最多允许同时存在 1 个 HTTP 装饰器` }); }); } } } } } } }// TO BE CONTINUE...
始因我的博客一直都使用基于PHP的Typecho作为后端, 在PHP日渐式微的今天, 人们的目光更多的放在的Rust和Go之类的新兴语言上, Typecho社区也再也少见令人眼前一亮的新主题. 倒也不是说之前的那些主题就不能用, 只是都不对我的口味. 我想要的是非常纯粹的阅读体验, 当人读文章的时候, 他就只能看到文章. 这也就是我想自己做一个主题的原因, 做一个极致简陋, 逼人只能看文章的主题. 5年前这个计划就开始了, 但是由于时间和技术能力受限, 实在是做不出心里想要的效果, 只得搁置.后来, 在本篇文章码下的半年前, 突然心血来潮, 直接开干. 恰逢MDUI发布了新的版本, 用上了新的Material Design, 在短短一星期之内就写出了最初的版本.Make things interesting在这过程中, 我觉得最有意思的部分正是: 将PHP与Vite集成, 使用最新的Web开发技术栈和构建工具链大幅提升开发体验.听着好像就是1+1的问题, 实际上有很多难点:如何使Vite处理PHP文件? PHP通过内嵌在HTML的特殊标记来向HTML中注入内容, Vite显然不能识别.自动动态导入, JS和Web Component对于PHP都是透明的, 如何从PHP文件中分析需要导入的组件?Typecho的主题结构显然与普通的SPA/MPA网页不同, 如何集成?前期调研在Github上已经有很多将PHP与Vite集成的插件/方法.donnikitos/vite-plugin-phpandrefelipe/vite-php-setup表现出两种思路:使用Vite的Build Hooks在transform过程中调用PHP, 将内容注入进HTML中后再交由Vite继续处理.将PHP与Vite主动集成, PHP主动适配并完成部分Vite的工作(注入link和script src标签), 使浏览器能够从Vite拉取内容.第二种方案对PHP侵入性太高, 需要修改PHP代码, 而且随着Vite更新, 注入方式可能会不同, 需要再次编写集成Vite的代码, 导致了PHP与Vite非常强的耦合, 故不选用.而第一种方案听起来非常美好, 对于简单的项目足够使用了. 但是Typecho并不简单, 通过模板引入的方式要求整个Typecho的所有代码必须经过Vite处理才能正常使用, 而且PHP中有些功能依赖于Apache/Nginx的预处理, 比如伪静态, 这种不使用HTTP服务器的方案与实际情况存在巨大差异, 很可能导致问题.大体架构目前能找到的两种方案都被抛弃了, 我们需要思考自己的解决方案:让PHP尽可能与Vite解耦, PHP中不应该存在针对Vite的特殊处理.让Vite能够处理PHP中的HTML代码, 并正确注入Vite入口(link和script src标签).故PHP代码应该让Apache处理, Vite可以将处理好的HTML代码交给Apache, 或者让Apache处理好PHP之后再对HTML进行transform.在build之后PHP代码不应该与Vite继续交互, 但dev时Vite是必然存在的. 所以考虑分开处理dev和build:在dev时:flowchart LR 原始代码 --> PHP --> Vite --> 浏览器这样可以在dev时获得Vite的一些特性, 例如HMR和按需编译.当然可以build --watch, 但是随着项目增大, build的时间在关掉所有优化压缩之后也变得越来越不可接受(~10s).在build时:flowchart LR 原始代码 --> Vite --> PHP --> 浏览器这样在发布代码时可以直接分发编译后的代码, 不需要使用者自行编译.实现接下来就着手解决两个问题: Vite+PHP集成, 自动导入Vite+PHP集成dev和build需求不同, dev环境存在Vite, 而且需要快速返回修改; 而build要求对代码进行优化, 并打包JS模块提高兼容性. 故可以分类考虑两种情况的解决方案.dev在dev期间, PHP代码被完全处理后才交由Vite处理, 不需要处理PHP代码.考虑实现一个proxy, 使其内容经过Vite处理管线.首先就需要将文件交给Apache处理, 我们的文件都在自己的repo下面, 难道要每改一次就复制一次到Apache那边吗? 显然不是.我们可以用软链接来实现这一切, 直接将src/链接到typecho/usr/themes/Matecho/.不过为了覆盖更多的情况, 比如测试build后的结果, 我们应当考虑链接dist/.ln -s /home/zapic/dist/ /var/www/typecho/usr/themes/Matecho然后再在buildStart中复制这些文件并监听更改:async buildStart(options) { if (env.command === "serve") { const files = await fg("src/**/*.php"); try { await rm("dist/", { recursive: true }); } catch (_) { // } await mkdir("dist/"); await Promise.all( files.forEach(file => { this.addWatchFile(file); copyFile(file, file.replace("src/", "dist/")); }) ); await cp("public/", "dist/", { recursive: true }); // 我们的代码中需要扫描cover文件夹获得文章封面列表, 复制public到dist } }, watchChange(id) { if (id.endsWith(".php")) { if (existsSync(id)) { // 如果文件还存在, 没被删除 copyFile(id, id.replace("src/", "dist/")); } else { // 文件被删除 rm(id.replace("src/", "dist/")); } } },这样就解决了第一步, 把文件交给Apache处理.第二步是将Apache处理后的HTML交给Vite处理, 官方推荐的方法是自己用node起个HTTP服务器做Proxy, 但是有点点麻烦.故在configureServer钩子中注入中间件, 并保证其优先级低于Vite所有内置的中间件, 这样便会在请求的URL无法找到内容的时候轮到我们, 然后就可以修改响应内容.为了保证其优先级低于其他中间件, 需要返回一个回调:// createTransformProxy impl & other code: https://github.com/KawaiiZapic/Matecho/blob/28680cab0e33920109e5d8c9b0688c782a93771f/plugins/Matecho.ts#L23 configureServer(server) { return () => { server.middlewares.use((req, res) => { const proxy = createTransformProxy( async (html, req) => { if ( html .substring(0, 15) .toUpperCase() .startsWith(" ") // Vite只能处理HTML5内容 ) { try { return await server.transformIndexHtml(req.url, html); // 尝试让Vite对PHP处理后的内容进行转换 } catch (e) { console.error(e); if (e instanceof Error) { setTimeout(() => { server.hot.send({ type: "error", err: { message: e.message, stack: e.stack } }); }, 100); } // 炸了, 返回一个空的内容, 并注入Vite的代码, 以便显示错误 return await server.transformIndexHtml(req.url, ""); } } else { return html; } }, { target: "http://" + (server.config.server.host || "localhost") } ); proxy.web(req, res); }); }; }这样便完成了基本的transform, 我们的middleware中调用了transformIndexHtml, Vite就会像处理一般的SPA项目一样处理这段HTML, 我们就可以使用任意Vite插件方便我们的开发.通过这小一百行代码, 我们一口气解决了dev环境中的两个问题: 如何让Vite处理PHP/如何将Vite集成到Typecho, 我们通过代理Apache响应直接绕过了这两个问题.但是在build呢, build后不可能带上Vite, 该怎么办呢?buildbuild时, 我们就需要自行处理PHP代码了.首先我们需要使Vite处理我们的PHP文件, 注意到我们的项目与传统的MPA项目比较相近, 故可以考虑将项目类型设置成MPA并将所有PHP文件作为入口.export default { appType: "mpa", build: { rollupOptions: { input: [...(await fg("src/**/*.php"))] } } }显然, Vite不支持PHP, 会抛出错误"Unknown file extension .php". 我们需要接管.php的加载过程:resolveId(id) { if (id.endsWith(".php")) { return id.replace(".php", "_actual_php.html"); } }, async load(id) { if (id.endsWith("_actual_php.html")) { return ( await readFile(id.replace("_actual_php.html", ".php")) ).toString(); } }我们将其的后缀转换为.html后, Vite的HTML插件会参与处理这些PHP文件. 但是含有<?php块的HTML并不能被正常解析, 会导致Vite报错. 参考上面那些项目的解决方案后, 我们可以通过暂时剥离这些代码块并在Vite处理之后再注入回去.const codeTokens: Record<string, string> = {}; // ... async load(id) { if (id.endsWith("_actual_php.html")) { return ( await readFile(id.replace("_actual_php.html", ".php")) ).toString() .replace(/<\?(?:php|).+?(\?>|$)/gis, match => { const token = "PHPCode" + hash(match) + Date.now(); codeTokens[token] = match; return token; }); } }, transformIndexHtml(html, ctx) { let r = html; if (env.command !== "serve") { Object.entries(codeTokens).forEach(([token, code]) => { r = r.replaceAll(token, code); }); } return r; }, generateBundle: { order: "post", handler(opt, bundle, isWrite) { if (!isWrite) return; for (const key of Object.keys(bundle)) { const OutputBundle = bundle[key]; if (key.endsWith("_actual_php.html")) { bundle[key].fileName = bundle[key].fileName .replace("_actual_php.html", ".php") .replace("src/", ""); } } }在load钩子内手动实现读取文件的逻辑, 在读取后直接剥离所有<?php, 并替换成占位符, 稍后在transformIndexHtml还原代码块.占位符要适应绝大多数可能出现PHP代码块的地方, 实验证明, 一串由字母和数字组成的字符串就能适应绝大多数情况, 只要求PHP不破坏完整的HTML标签:<span class="<?php if($active){ ?> active <?php } ?>"> <span class="<?php if($active){ ?> active"> <?php } else { ?>"><?php } ?> 最后在generateBundle钩子中将输出文件名还原为.php.至此, 我们就完成了Vite针对PHP中HTML的transform, 而且支持CSS的HMR, HTML由于受到PHP的影响, 必须完全重载页面.通过一个合适的方案, 我们自然而然的同时解决了如何让Vite处理PHP/如何将Vite集成到Typecho的问题, 在开发过程中我的体验也是丝般顺滑, 就像在写普通的PHP代码一样.自动导入Matecho所使用的MDUI是Web Component, 要求导入相应的文件才能正常显示. 当然, 可以一股脑的全部导入进项目里, 但是为了实现极快的加载速度, 还是需要分析每一页所需的组件再实现动态引入.基本设计为了确定分析哪些文件并与JS逻辑结合处理导入, Matecho中使用了Virtual Module实现这一切.virtual:components/xxx决定分析哪个文件的导入情况后, 便可无缝的使用JS的import语法将其在任何地方导入. 这给了JS侧极高的自由性, 使得整个导入逻辑都是可以控制的.// main.ts import "virtual:components/header"; import "virtual:components/sidebar"; import "virtual:components/footer"; //... // page/post.ts import "virtual:components/post"; if (canComment) { import("virtual:components/comment"); }实现首先需要确保自己的Virtual Module被解析:resolveId(id) { if (id.startsWith("virtual:components")) { return id; } }接着在load中分析并生成内容, 为了避免重复生成导入语句并考虑到项目的实际情况, 一个页面必然会引入header`sidebarfooterfunctions`, 故将这些文件引入的组件视为全局组件, 之后的页面会跳过引入这些组件.避免重复事实上是强迫症, 实际上JS会处理重复导入, 并没有太大问题.const AutoComponents = { preloaded: [] as string[], from: ["header", "sidebar", "footer", "functions"] }; async load(id) { if (id.startsWith("virtual:components")) { const src = ( await readFile( "src/" + id.replace("virtual:components/", "") + ".php" ) ).toString(); const isFromPreloaded = AutoComponents.from.includes( id.replace("virtual:components/", "") ); return Array.from( new Set(Array.from(src.matchAll(/<mdui-([^<> ]+)/g)).map(v => v[1])) ) .filter(v => !AutoComponents.preloaded.includes(v)) .map(v => { if (v.includes("$")) return ""; // php dynamic tags if (isFromPreloaded) AutoComponents.preloaded.push(v); if (v.startsWith("icon-")) { return `import "@mdui/icons/${v.substring(5)}";`; } else { return `import "mdui/components/${v}";`; } }) .join("\n"); } },直接使用正则表达式提取出所有的<mdui-标签, 并分析其中的内容后, 生成import语句.这样便可以实现自动分析导入了, 接着再在合适的地方导入这些Virtual Module:// main.ts import "virtual:components/header"; import "virtual:components/sidebar"; import "virtual:components/footer"; //... // page/post.ts import "virtual:components/post"; if (canComment) { import("virtual:components/comment"); }就可以实现按需加载.收工至此, 我们已经实现了所有核心需求: Vite+PHP集成和自动导入. 这项工作极大的简化了Matecho的构建过程, 也让开发过程被现代前端工具链赋能, 整体体验如丝般顺滑. 而且我认为这种架构具有非常强的通用性, 稍加改造便能适应各种不同的PHP框架, 可以应用在Discuz之类的平台上. 虽然我早晚会换掉Typecho这个平台, 但是这次开发让我获得了不少的经验, 我已经变成Vite大手子了.
在/lib/hdparm/hdparm-functions中的约63行位置有: # Only activate APM on disks that support it. if [ -z "$ID_ATA_FEATURE_SET_APM" ]; then local ID_ATA_FEATURE_SET_APM="$(udevadm info -n "$1" -q property 2>/dev/null | sed -n 's/^ID_ATA_FEATURE_SET_APM=//p')" || true fi在其下方追加一段: # Only activate APM on disks that support it. if [ -z "$ID_ATA_FEATURE_SET_APM" ]; then local ID_ATA_FEATURE_SET_APM="$(udevadm info -n "$1" -q property 2>/dev/null | sed -n 's/^ID_ATA_FEATURE_SET_APM=//p')" || true fi # udevadm 不一定支持获取新硬盘的APM支持状况, 在结果为空的时候采用替代方案 if [ -z "$ID_ATA_FEATURE_SET_APM" ]; then local ID_ATA_FEATURE_SET_APM="$(( ! $(hdparm -B "$1" | grep -c "not supported") ))" || true fi