Write the Code. Change the World.

9月 10
精选文章

该后台使用 vite + ts + pnpm + vue3 + element-plus + tailwindcss 等技术栈构成。没有添加任意可视化图标等插件。以最小功能,最基础功能展现。用户可以额外添加可使用的插件逻辑。

该后台后端使用 php8.2 + laravel 10 + mysql

该后台后端 go 语言版本开发中。将使用 gframe2.5.2

源码: https://github.com/vini123/simpleAdmin

在线体验: https://www.zeipan.com/admin

权限以及密码一键复位: https://v3test.yuepaibao.com/admin/api/reset

测试账号以及密码: zhoulin@xiangrong.pro、 111111 (如果发现登录不了,可一键复位谢谢)

阅读全文 >>

11月 06

RustFS 平替 MinIO:S3 对象存储部署与选型对比

替换背景与结论

MinIO 社区版已从 Docker Hub、quay.io、dl.min.io 全面下架:dl.min.io 二进制地址返回 410,Docker Hub 上 minio/minio 任意 tag 均为 not found,quay.io/minio/minio 返回 401。逐个试 tag 只会浪费时间,应先确认上游已整体下架。

因此把对象存储换成 RustFS:同为标准 S3 API,端口布局与 MinIO 一致(9000 / 9001),鉴权走标准 SigV4,可以做到业务侧零改动迁移。

保留历史命名的原因很直接——容器名仍是 **milvus-minio,服务名仍是 minio**,它与 Milvus 已无关系;它现在是 OSS 的唯一文件实体存储,挂在 app 网络供两个后端访问。这样 Milvus 的 MINIO_ADDRESS=minio:9000、应用侧 MINIO_* 系列配置、密钥、端口全部不用改。

RustFS 是什么

名称与项目归属

不是 MinIO 改名,也不是 MinIO 的 Fork,而是一个独立的全新项目:

  • Rust:从零用 Rust 编写,底层存储引擎、元数据、内存模型、GC 全部自研,没有复用 MinIO 的 Go 源码。

  • FS:File System,做对象存储,对外提供 S3 文件语义。

  • 许可:Apache 2.0,修改与分发没有 AGPLv3 那样的强制开源义务。

  • 归属:独立开源项目,与 MinIO Inc 无隶属、赞助或商标关系;官方也明确澄清过这一点。

它为什么和 MinIO 长得这么像

因为它刻意对齐了 MinIO 的运维习惯,就是为了让存量系统平滑迁移:

  • 端口对齐:9000 为 S3,9001 为 Console。

  • 环境变量与凭据习惯对齐,RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY 对应原来的 accessKey / secretKey。

  • 单盘、多盘、分布式纠删码三种模式的用法模仿 MinIO。

  • mc 工具可以直接拿来操作,绝大多数子命令正常。

它只兼容标准 S3 API,MinIO 的私有扩展(桶通知、ILM 私有接口、S3 Select、部分管理接口)并不存在,选型与测试时必须把这一点纳入验证范围。

Docker Compose 部署配置

下面这段直接替换原来 minio 那一段即可,服务名、容器名、端口、网络与历史配置保持一致。

services:
  minio:
    image: rustfs/rustfs:1.0.0
    container_name: milvus-minio
    hostname: minio
    restart: unless-stopped
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      - RUSTFS_VOLUMES=/data
      - RUSTFS_ADDRESS=0.0.0.0:9000
      - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
      - RUSTFS_CONSOLE_ENABLE=true
      - RUSTFS_ACCESS_KEY=minioadmin
      - RUSTFS_SECRET_KEY=minioadmin
      - RUSTFS_OBS_LOGGER_LEVEL=warn
    volumes:
      - ./rustfs-data:/data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app

networks:
  app:
    # 与现有 compose 的 app 网络配置保持一致

关键配置说明

  • 数据目录:RUSTFS_VOLUMES=/data 声明数据卷;早期写法用 command: /data,两者等价,二选一即可,不要重复声明。

  • 端口映射:集群内部通信走 minio:9000,若只在 compose 内部互通,ports 可以注释掉;但需要浏览器直连预签名地址时,必须把 9000 映射到宿主机。

  • 版本锁定:不要长期用 latest。锁定具体 tag,并避开发布早期存在漏洞的 beta 版本;实测 rustfs/rustfs:1.0.4 可正常启动、建桶、上传下载。

  • 探活:GET http://127.0.0.1:9000/health 返回 200 即就绪,可直接作为 healthcheck。

启动后的可用性验证

# 1. 探活
curl -fsS http://127.0.0.1:9000/health

# 2. 配置 mc 别名并建桶
mc alias set rustfs http://127.0.0.1:9000 minioadmin minioadmin
mc mb rustfs/oss            # RustFS 不会自动建桶,必须先建

# 3. 上传后从宿主机直接下载,确认 200 且内容一致
mc cp ./test.txt rustfs/oss/test.txt

RustFS / MinIO 社区版 / SeaweedFS 完整对比

对比项 RustFS MinIO(社区版) SeaweedFS
开发语言 Rust Go Go
许可证 Apache 2.0,商业友好,无强制开源义务 AGPLv3,网络分发与托管服务的衍生代码需开源 Apache 2.0,商业友好
项目归属 独立新项目,不是 MinIO Fork MinIO Inc 官方项目,社区版已冻结,重心转向商业版 AIStor 独立开源项目,长期活跃
核心架构 对等节点 + Set 纠删码,无中心元数据节点,S3 与控制台单进程一体化 同上,最成熟稳定,池化扩容 三层:Master + Volume Server + Filer,存在中心元数据角色
S3 兼容范围 标准 S3 SigV4、桶与对象、分片上传、版本、生命周期;无 MinIO 私有扩展 标准 S3 + 大量私有扩展:桶通知、站点复制、ILM、S3 Select、内部运维接口 S3 网关模式,支持 IAM、STS、对象锁、服务端加密;底层是 Filer 网关包装
默认端口 9000(S3)/ 9001(Console),与 MinIO 完全一致 9000 / 9001 无固定 9000 / 9001 约定,控制台独立
mc 工具兼容 可直接使用,大部分命令正常 原生支持 可用,部分 MinIO 专属子命令不支持
纠删码 EC 原生内置,按 Set 划分,启动时固定 Set 布局 原生内置,成熟,按 Pool 扩展 社区版固定比例 EC,企业版可自定义;读放大为 O(1)
单节点部署 支持单盘 / 多盘单机,适合 Milvus 附属存储 非常成熟 一键启动,但 Master 仍是单点
K8s 生态 官方 Helm Chart,支持 StatefulSet 官方 Operator,生态最强 Helm,社区维护
控制台 UI 自研,简洁;桶管理、密钥、日志等基础功能为主 功能极强,运维、监控、策略、复制、告警齐备 基础 WebUI,重心在 S3 与 FUSE
小文件场景 一般,对象存储思路,与 MinIO 接近 一般 强项,原始设计即解决海量小文件,亿级规模优势明显
大文件 / AI 数据集 Rust 无 GC 抖动,尾延迟稳定,NVMe 下吞吐表现好 成熟稳定,高并发下 Go GC 有延迟抖动 可用,但不是为大对象流式场景设计
元数据瓶颈 分散在各 Set,无中心瓶颈 分散在各 Pool,无中心瓶颈 瓶颈在 Filer / Master,海量文件时是上限
扩容逻辑 新增 Set;Set 创建后磁盘数量不可改 新增 Pool;Pool 内规模固定,不能在线扩盘 可直接新增 Volume 节点,扩容更灵活
站点跨集群复制 支持,逐步完善 成熟稳定,企业级核心能力 支持,机制不同
S3 Select 暂不支持 原生支持 不支持
桶通知 / Webhook 标准 S3 事件部分支持,无 MinIO 专属通知 完善,Kafka / Redis / Webhook 等 支持标准 S3 事件
POSIX / FUSE 不支持,纯对象存储 FUSE 为额外扩展 原生 FUSE,同一份数据同时支持 POSIX、S3、HDFS
Iceberg 内置 Iceberg REST Catalog(特色) 需外部组件 支持 S3 Tables
成熟度 已 GA,新项目迭代快,社区规模中等,踩坑文档偏少 社区版停止维护,镜像下架,存量可用、新部署不建议 多年稳定项目,生产案例多
是否适配 Milvus 场景 最合适:零改配置、端口对齐、mc 直接可用、Apache 2.0 不适合新部署:镜像拿不到、许可风险、停更 能用,但要改 endpoint 与端口、部署更重,没有必要

选型建议

  • 只要能平替 MinIO、不改代码与配置:选 RustFS。代价是放弃 MinIO 私有扩展(桶通知、S3 Select 等),只依赖标准 S3;项目较新,运维案例不如老牌方案多。

  • 海量图片 / 小附件、需要 FUSE 挂载、要 POSIX 文件访问:选 SeaweedFS,但它的 Master 是单点,生产必须做 Master 集群。

  • MinIO 社区版:只适合存量老集群继续运行。许可(AGPLv3)+ 停更 + 镜像下架三重问题,新项目不要再上;如果产品是对外托管的 SaaS,AGPLv3 风险尤其高。

迁移踩坑清单

部署与运维

  • 不自动建桶:RustFS 与老 MinIO 一样不会自动创建 bucket,Milvus 启动前必须预建,否则 Milvus 侧报桶不存在。最好用 init 容器一次性建好。

  • Set 规模一次性定死:和 MinIO 相同,Set 创建后不能增减磁盘,规划集群时就要定好每个 Set 的盘数。

  • 控制台不一样:UI 是 RustFS 自研,基础上传与桶管理够用,但和 MinIO Console 的操作路径不同,交接文档要重写一遍。

  • 混合配置里别留 MinIO 残留:加密、站点复制、ILM 的私有配置项搬到 RustFS 会直接失效,迁移时清理掉。

SigV4 与预签名地址(手写签名时的高频故障)

  • 建桶请求的规范 URI 不带尾斜杠:真实请求是 PUT /bucket,按 /bucket/ 计算签名会返回 SignatureDoesNotMatch 403,而上传对象却正常,很容易误判成凭据错误。

  • 附加 query 参数必须参与签名:如 response-content-disposition 要放进 canonical query 一起算,签完再拼会表现为「地址能生成但下载 401 / 403」。

  • 对外端点与内部端点分开配置:签名只与 host 有关。服务端调用走 minio:9000,预签名地址必须用宿主机可访问的对外端点,否则签出来的链接浏览器打不开。

  • 验证方式:上传后从宿主机 curl 下载,确认 HTTP 200 且内容与上传一致;只看上传返回 200 不足以证明签名正确。

(注:部分内容由豆包工作 AI 生成)

阅读全文 >>

11月 04

shiftwidth

shiftwidth 缩进时一次移动多少空格。一般设置为 4 比较好。

怎么查看。

vim xxx
:set shiftwidth?

当然,还有简写。

vim xxx
:set sw?

临时设置 shiftwidth 值。

vim xxx
:set sw=4

永久设置 shiftwidth 值。

vim ~/.vimrc

# 在里边写入
set shiftwidth=4

# 使生效
source ~/.vimrc

多行操作

vim 显示行号设置。

:set number

如果想永久生效也是加在 ~/.vimrc 中即可。

将第 10,20行的数据往左移动

# 往左移动一个空格
:10,20<

# 往左移动两个空格
:10,20<<

将第 10,20行的数据往右移动

# 往右移动一个空格
:10,20>

# 往右移动两个空格
:10,20>>

上边是命令行模式操作。有时候用可视模式更好用。

vim xxx
# 普通模式下,按大写的 V 进入可视化模式
V
# 按 shift + j 向下移动, shift + k 向上移动 进行选择(有的 j 或 k 即可)
shift + j
# 按 < 左移, > 右移
<

通常,移动一次,所选的区域就会丢失。需要按 gv 重新选中区域,再左移或右移。(gv = reselect visual)

如果想移动一次后,可视化选区还在就修改 ~/.vimrc 文件,增加以下配置。 然后 source 一下。

vnoremap > >gv
vnoremap < <gv

多行注释和多行解除注释

# 先进入可视化
ctrl + V

# shift + k 或 shift + j 选中多行
shift +k

# 按大写的 I 进入输入模式
I

# 输入注释符 #,其他字符也可以
#

# 按 esc 退出模式,这个时候,多行就自动加上了。输入的时候虽然只有一行,退出后每行都有
esc

阅读全文 >>

10月 29

PHP2Shell 漏洞可以使用官方镜像进行修复。也可以用已经修复过的其他镜像。

官方: https://www.php.net/

像 php7.4 这种官方不更新维护的版本,可以使用 https://gallery.ecr.aws/ 镜像作为父进行来进行构建升级。

FROM public.ecr.aws/aikido-dev/php:7.4.33-fpm
WORKDIR /www
……

相关参考

https://www.21cto.com/article/1455806603546724

https://www.fanyamin.com/blog/2026-10-22-php2shell-rce.html

https://news.qq.com/rain/a/20261022A03R1A00

https://www.msn.cn/zh-cn/news/other/php%E6%9B%9D%E5%87%BAphp2shell%E9%AB%98%E5%8D%B1%E6%BC%8F%E6%B4%9E%E8%85%BE%E8%AE%AF%E4%BA%91%E7%B4%A7%E6%80%A5%E6%8F%90%E9%86%92%E4%BF%AE%E5%A4%8D/ar-AA2fKBkf?ocid=socialshare

阅读全文 >>

9月 24

副本镜像地址:https://quay.io/repository/coreos/etcd

这里会列出 etcd 的最新版本。当前最新版本 3.7.2

注意:放弃使用 bitnami/etcd 镜像。

基于环境变量的单节点 etcd 部署

services:
  etcd:
    image: quay.io/coreos/etcd:v3.7.2
    container_name: etcd-server
    environment:
      - TZ=${TZ:-Asia/Shanghai}
      - ETCD_NAME=etcd-server
      - ETCD_DATA_DIR=/var/etcd
      # listen:容器内部监听全部网卡,不要改0.0.0.0
      - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
      - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
      # 对外客户端广播地址,同docker网络业务容器使用 etcd‑server:2379
      - ETCD_ADVERTISE_CLIENT_URLS=http://etcd-server:2379
      # peer广播地址,单节点这里就用域名
      - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://etcd-server:2380
      - ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster
      # initial‑cluster 和 advertise peer保持一致,全部域名
      - ETCD_INITIAL_CLUSTER=etcd-server=http://etcd-server:2380
      # 单节点docker环境,全新初始化打开 force‑new‑cluster,规避启动瞬间DNS时序问题
      - ETCD_FORCE_NEW_CLUSTER=${ETCD_FORCE_NEW_CLUSTER:-false}
      # 生产常态使用 existing;清空数据首次初始化改成 new
      - ETCD_INITIAL_CLUSTER_STATE=${ETCD_INITIAL_CLUSTER_STATE:-existing}
      - ETCD_LOGGER=zap
      - ETCD_LOG_LEVEL=${ETCD_LOG_LEVEL:-info}
      # 调优参数
      - ETCD_HEARTBEAT_INTERVAL=250
      - ETCD_ELECTION_TIMEOUT=2000
      - ETCD_MAX_REQUEST_BYTES=104857600
      - ETCD_SNAPSHOT_COUNT=50000
      # 设置 root 账号密码
      # - ETCD_ROOT_PASSWORD=xxxxxx
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-./etcd-data}:/var/etcd
    ports:
      - "127.0.0.1:2379:2379"
      - "127.0.0.1:2380:2380"
    restart: always
    networks:
      - xr-net

networks:
  xr-net:
    name: xr-net
    driver: bridge

下边是 .env 文件

TZ=Asia/Shanghai

# 数据卷
DOCKER_VOLUME_DIRECTORY=./etcd-data

# 全新初始化:new;日常运行:existing
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_LOG_LEVEL=info

运行起来: docker compose up -d

查看:docker compose ps

# docker compose ps
NAME          IMAGE                        COMMAND                 SERVICE   CREATED          STATUS          PORTS
etcd-server   quay.io/coreos/etcd:v3.7.2   "/usr/local/bin/etcd"   etcd      10 minutes ago   Up 10 minutes   127.0.0.1:2379-2380->2379-2380/tcp

在浏览器中打开:http://127.0.0.1:2379/version

会看到输出: {"etcdserver":"3.7.2","etcdcluster":"3.7.0","storage":"3.7.0"}

相关参考

https://oldme.net/article/32
https://cloud.tencent.com/developer/article/2392231

阅读全文 >>

9月 18

https://golang.google.cn/

安装 gRPC

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

安装 protobuf 编译器

官网下载链接: https://protobuf.dev/downloads/

Github 下载链接: https://github.com/protocolbuffers/protobuf/releases

  1. 下载 ProtoBuf 编译器

解压后,有下边这些目录。

# 只需把 bin 目录下的 exe 文件放在环境变量目录下就可以用了。
├─bin
└─include
    └─google
        └─protobuf
            └─compiler
  1. 当前用户使用,模拟 Linux 新建 ~/.local/bin 目录。

    New-Item -ItemType Directory -Force "$env:USERPROFILE\.local\bin"
  2. 将这个目录添加到环境变量中。

    # 获取现有用户PATH
    $userPath = [Environment]::GetEnvironmentVariable("PATH","User")
    if(-not $userPath.Contains("$env:USERPROFILE\.local\bin")){ [Environment]::SetEnvironmentVariable("PATH","$userPath;$env:USERPROFILE\.local\bin","User")
    }
  3. 将下载解压后的 bin 目录下的 protoc.exe 放到 ~/.local/bin 目录下。(如果 2,3步曾经做过,直接从第一步到这步)

  4. 重新打开终端测试。

protoc --version

where.exe protoc

安装 protoc‑gen‑go 和 protoc-gen-go-grpc

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

安装 etcd

系统中,安装了 docker。服务端也会是 docker 部署,就用 docker 来构建 etcd 服务。

https://blog.vini123.com/1429

阅读全文 >>

8月 28

国密 sm2,sm3,sm3 在 php 中使用。国密 sm2,sm3,sm3 在 laravel 中使用。

Github

先准备 php docker 镜像

FROM xxxx

# 其他代码

WORKDIR /www

# 编译工具链(独立成层,成功后即缓存)
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends git ca-certificates build-essential cmake pkg-config; \
    rm -rf /var/lib/apt/lists/*

# GmSSL底层库:源码编译安装(独立成层)
RUN set -eux; \
    cd /tmp; \
    git clone https://github.com/guanzhi/GmSSL --depth=1; \
    cd GmSSL; \
    mkdir build && cd build; \
    cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local/gmssl; \
    make -j$(nproc); \
    make install; \
    echo "/usr/local/gmssl/lib" > /etc/ld.so.conf.d/gmssl.conf; \
    ldconfig; \
    rm -rf /tmp/GmSSL

# GmSSL-PHP国密扩展:编译安装并启用(独立成层)
RUN set -eux; \
    cd /tmp; \
    git clone https://github.com/GmSSL/GmSSL-PHP.git --depth=1; \
    cd GmSSL-PHP; \
    phpize; \
    ./configure --with-gmssl=/usr/local/gmssl; \
    make -j$(nproc); \
    make install; \
    docker-php-ext-enable gmssl; \
    rm -rf /tmp/GmSSL-PHP

# 创建软链接,让系统可以直接找到 gmssl
RUN ln -sf /usr/local/gmssl/bin/gmssl /usr/local/bin/gmssl

# 验证安装
RUN gmssl version

环境准备好了,就可以在 laravel 项目中使用了。

使用

  1. 生成 sm4 需要的 key,iv 以及 sm2 需要的公钥私钥口令这些
#!/bin/bash

# 用来生成 GM SM4、SM2 的密钥(在已安装 GmSSL 的 Linux / docker 容器中执行)
# 使用方法: ./gmssl.sh
# 可选环境变量:
#   GMSSL_BIN  gmssl 命令路径(默认从 PATH 查找)
#   SM2_PASS   SM2 口令私钥的密码(默认随机生成24位hex,生产环境务必自行修改保管)

set -e  # 遇到错误立即退出

# ========== 配置区域 ==========
# gmssl 命令(默认安装到 /usr/local/bin/gmssl,从 PATH 查找即可)
GMSSL_BIN="${GMSSL_BIN:-gmssl}"

# 密钥存储目录(位于 storage/app 下,.env 中填相对路径即可)
KEY_DIR="./storage/app/gmssl"

# SM2 口令私钥的密码:未指定时随机生成
SM2_PASS="${SM2_PASS:-$(openssl rand -hex 12)}"

# ========== 开始生成 ==========

# 0. 检查 gmssl 命令是否可用
if ! command -v "$GMSSL_BIN" >/dev/null 2>&1; then
    echo "错误: 找不到 gmssl 命令,请确认容器内已安装 GmSSL,或通过 GMSSL_BIN 环境变量指定路径"
    exit 1
fi

# 1. 创建密钥目录
echo "创建密钥目录: $KEY_DIR"
mkdir -p "$KEY_DIR"

# 2. 生成 SM4 密钥和 IV (16字节 -> 32位十六进制)
echo "生成 SM4 密钥和 IV..."
GM_SM4_KEY=$(openssl rand -hex 16)
GM_SM4_IV=$(openssl rand -hex 16)

# 3. 生成 SM2 密钥对(口令加密私钥,公钥不加密)
echo "生成 SM2 密钥对..."
"$GMSSL_BIN" sm2keygen \
    -pass "$SM2_PASS" \
    -out "$KEY_DIR/sm2_private.pem" \
    -pubout "$KEY_DIR/sm2_public.pem"

# 4. 验证文件是否生成成功
if [ ! -f "$KEY_DIR/sm2_private.pem" ] || [ ! -f "$KEY_DIR/sm2_public.pem" ]; then
    echo "错误: SM2 密钥文件生成失败"
    exit 1
fi

# 5. 设置文件权限(安全:私钥仅属主可读写)
chmod 600 "$KEY_DIR/sm2_private.pem"
chmod 644 "$KEY_DIR/sm2_public.pem"

# 6. 输出结果(.env 中填相对于 storage/app 的路径,由 PHP 端 storage_path() 解析,避免依赖运行时工作目录)
echo ""
echo "=========================================="
echo "✅ 密钥生成完成!"
echo "=========================================="
echo ""
echo "📁 密钥文件位置:"
echo "  加密私钥: $KEY_DIR/sm2_private.pem"
echo "  公钥:     $KEY_DIR/sm2_public.pem"
echo ""
echo "📝 请将以下内容添加到 .env 文件:"
echo "----------------------------------------"
echo "GM_SM4_KEY=$GM_SM4_KEY"
echo "GM_SM4_IV=$GM_SM4_IV"
echo "GM_SM2_PRIV_KEY_FILE=gmssl/sm2_private.pem"
echo "GM_SM2_PUB_KEY_FILE=gmssl/sm2_public.pem"
echo "GM_SM2_PRIV_PASS=$SM2_PASS"
echo "----------------------------------------"
echo ""
echo "⚠️  安全提示:"
echo "  1. 请妥善保管 sm2_private.pem 及 GM_SM2_PRIV_PASS 口令,丢失后已加密数据无法恢复"
echo "  2. 不要将密钥文件和 .env 提交到 Git 仓库"
echo "  3. 生产环境请使用更强的口令,并通过密钥管理服务分发"
echo "=========================================="

# 7. 确保密钥目录被 Git 忽略
if [ -f ".gitignore" ]; then
    if ! grep -q "storage/app/gmssl" .gitignore 2>/dev/null; then
        echo "" >> .gitignore
        echo "# GMSSL 密钥文件" >> .gitignore
        echo "/storage/app/gmssl/" >> .gitignore
        echo "已添加 /storage/app/gmssl/ 到 .gitignore"
    fi
fi

echo ""
echo "🎉 完成!"

在容器内,执行 sh ./gmssl.sh 来生成上边说的这些。将上边对应的结果添加的 .env 文件中。记得在 .env.example 中增加对应的配置项。

  1. 增加 config/gm.php,填入下边的内容。
<?php

return [
    // SM4-CBC 密钥与IV:16字节原始密钥的32位hex字符串(由 gmssl.sh 生成)
    'sm4_key' => env('GM_SM4_KEY'),
    'sm4_iv'  => env('GM_SM4_IV'),

    // SM2 密钥文件路径:支持绝对路径;相对路径基于 storage/app 解析
    // 例如填 gmssl/sm2_private.pem 即 storage/app/gmssl/sm2_private.pem
    'sm2_priv_key_file' => env('GM_SM2_PRIV_KEY_FILE'),
    'sm2_pub_key_file'  => env('GM_SM2_PUB_KEY_FILE'),

    // SM2 加密私钥的口令(gmssl sm2keygen -pass 所用密码)
    'sm2_priv_pass' => env('GM_SM2_PRIV_PASS'),

    // SM2 签名/验签使用的签名者ID(与对接方约定一致,国密默认值为 1234567812345678)
    'sm2_id' => env('GM_SM2_ID', '1234567812345678'),
];
  1. 增加工具类
<?php

namespace App\Utils;

use RuntimeException;

/**
 * 国密算法工具类(SM2 / SM3 / SM4)
 *
 * 基于 gmssl PHP 扩展(https://github.com/GmSSL/GmSSL-PHP,依赖 GmSSL C 库 >= 3.1.0)
 *
 * 注意:官方扩展的函数签名与本类早期写法差异较大,以下为官方 API:
 * - gmssl_sm4_cbc_encrypt($key, $iv, $data) / gmssl_sm4_cbc_decrypt($key, $iv, $ciphertext)
 * - gmssl_sm2_sign($keypair, $id, $message),签名固定为 ASN.1 DER 编码
 * - gmssl_sm2_verify($public_key, $id, $message, $signature)
 * - gmssl_sm2_encrypt($public_key, $data) / gmssl_sm2_decrypt($keypair, $ciphertext)
 * - SM2 密钥对象必须通过文件路径加载:
 *   gmssl_sm2_private_key_info_decrypt_from_pem($file, $passphrase)
 *   gmssl_sm2_public_key_info_from_pem($file)
 */
class GmUtils
{
    /** SM4 二进制密钥(16字节),进程内缓存 */
    protected static ?string $sm4Key = null;

    /** SM4 二进制IV(16字节),进程内缓存 */
    protected static ?string $sm4Iv = null;

    /** SM2 私钥对象(96字节,含口令解密结果),进程内缓存 */
    protected static ?string $sm2PrivKey = null;

    /** SM2 公钥对象(96字节),进程内缓存 */
    protected static ?string $sm2PubKey = null;

    /**
     * SM3 摘要
     * @param string $data 原始数据
     * @return string 小写hex摘要(64位)
     */
    public static function sm3(string $data): string
    {
        return bin2hex(gmssl_sm3($data));
    }

    /**
     * SM4‑CBC 加密(带PKCS填充)
     * @param string $plainText 明文
     * @param string|null $keyHex 自定义密钥(32位hex),不传则用 GM_SM4_KEY
     * @param string|null $ivHex 自定义IV(32位hex),不传则用 GM_SM4_IV
     * @return string base64密文(适合http/json传输)
     */
    public static function sm4Encrypt(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
    {
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return base64_encode(gmssl_sm4_cbc_encrypt($key, $iv, $plainText));
    }

    /**
     * SM4‑CBC 解密
     * @param string $base64Cipher base64密文
     * @param string|null $keyHex 自定义密钥(32位hex),不传则用 GM_SM4_KEY
     * @param string|null $ivHex 自定义IV(32位hex),不传则用 GM_SM4_IV
     * @return string 原始明文
     */
    public static function sm4Decrypt(string $base64Cipher, ?string $keyHex = null, ?string $ivHex = null): string
    {
        $cipher = base64_decode($base64Cipher, true);
        if ($cipher === false) {
            throw new RuntimeException('SM4解密失败: 密文不是合法的base64');
        }
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return gmssl_sm4_cbc_decrypt($key, $iv, $cipher);
    }

    /**
     * SM4‑CBC 加密,返回hex密文(与小程序/前端 JS 库如 sm-crypto 对接用)
     */
    public static function sm4EncryptHex(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
    {
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return bin2hex(gmssl_sm4_cbc_encrypt($key, $iv, $plainText));
    }

    /**
     * SM4‑CBC 解密,入参为hex密文(与小程序/前端 JS 库如 sm-crypto 对接用)
     */
    public static function sm4DecryptHex(string $hexCipher, ?string $keyHex = null, ?string $ivHex = null): string
    {
        $hex = trim($hexCipher);
        if ($hex === '' || strlen($hex) % 2 !== 0 || !ctype_xdigit($hex)) {
            throw new RuntimeException('SM4解密失败: 密文不是合法的hex字符串');
        }
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return gmssl_sm4_cbc_decrypt($key, $iv, hex2bin($hex));
    }

    /**
     * SM2签名(SM3withSM2),返回 base64 的 ASN.1 DER 签名
     * @param string $data 待签名原文
     * @param string|null $id 签名者ID,默认取 GM_SM2_ID 配置(1234567812345678)
     * @return string base64签名
     */
    public static function sm2Sign(string $data, ?string $id = null): string
    {
        $sig = gmssl_sm2_sign(self::getSm2PrivKey(), self::sm2Id($id), $data);
        return base64_encode($sig);
    }

    /**
     * SM2验签
     * @param string $data 原文
     * @param string $base64Sign base64编码的DER签名
     * @param string|null $id 签名者ID,需与签名时一致
     * @return bool
     */
    public static function sm2Verify(string $data, string $base64Sign, ?string $id = null): bool
    {
        $sig = base64_decode($base64Sign, true);
        if ($sig === false || $sig === '') {
            return false;
        }
        return gmssl_sm2_verify(self::getSm2PubKey(), self::sm2Id($id), $data, $sig);
    }

    /**
     * SM2公钥加密,返回base64(密文为ASN.1 DER,内部C1C3C2)
     * @param string $plain 明文(SM2加密适用于短数据/密钥材料保护)
     * @return string base64密文
     */
    public static function sm2Encrypt(string $plain): string
    {
        $cipher = gmssl_sm2_encrypt(self::getSm2PubKey(), $plain);
        return base64_encode($cipher);
    }

    /**
     * SM2私钥解密
     * @param string $base64Cipher base64密文
     * @return string 明文
     */
    public static function sm2Decrypt(string $base64Cipher): string
    {
        $cipher = base64_decode($base64Cipher, true);
        if ($cipher === false || $cipher === '') {
            throw new RuntimeException('SM2解密失败: 密文不是合法的base64');
        }
        return gmssl_sm2_decrypt(self::getSm2PrivKey(), $cipher);
    }

    /**
     * SM2解密兼容方法:解密第三方客户端(小程序/前端 sm-crypto 等)传来的裸 C1C3C2 hex 密文
     * 密文布局:[04] || C1(X 32字节 || Y 32字节) || C3(32字节SM3摘要) || C2
     * @param string $hexCipher hex密文,带不带 04 前缀均可,要求客户端使用 C1C3C2 顺序
     * @return string 明文
     */
    public static function sm2DecryptHex(string $hexCipher): string
    {
        $hex = trim($hexCipher);
        if ($hex === '' || strlen($hex) % 2 !== 0 || !ctype_xdigit($hex)) {
            throw new RuntimeException('SM2解密失败: 密文不是合法的hex字符串');
        }
        $bin = hex2bin($hex);
        // 兼容带 04 非压缩点前缀的写法
        if ($bin[0] === "\x04") {
            $bin = substr($bin, 1);
        }
        if (strlen($bin) < 97) {
            throw new RuntimeException('SM2解密失败: 密文长度不足,请确认客户端使用 C1C3C2 顺序');
        }
        $x  = substr($bin, 0, 32);
        $y  = substr($bin, 32, 32);
        $c3 = substr($bin, 64, 32);
        $c2 = substr($bin, 96);

        // gmssl 扩展只接受 ASN.1 DER 密文,需把裸密文组装成:
        // SEQUENCE { xCoordinate INTEGER, yCoordinate INTEGER, hash OCTET STRING, cipherText OCTET STRING }
        $body = self::derInt($x) . self::derInt($y) . self::derOctetString($c3) . self::derOctetString($c2);
        $der  = "\x30" . self::derLength(strlen($body)) . $body;
        return gmssl_sm2_decrypt(self::getSm2PrivKey(), $der);
    }

    /**
     * 导出SM2公钥为非压缩点hex(04||X||Y,130位字符),用于下发给客户端 JS 库(如 sm-crypto)
     * @return string
     */
    public static function sm2PubKeyHex(): string
    {
        $pub = self::getSm2PubKey();
        // 公钥对象为96字节,前64字节即 X||Y,后32字节为零填充的私钥位,不取
        return '04' . bin2hex(substr($pub, 0, 64));
    }

    /**
     * 从SM2私钥导出公钥PEM(公钥丢失时可随时由私钥重新导出,密钥对不变)
     * @param string $targetFile 目标公钥PEM路径
     * @return string 导出的公钥非压缩点hex(04||X||Y,130位)
     */
    public static function exportSm2PubPem(string $targetFile): string
    {
        $keypair = self::getSm2PrivKey();
        if (gmssl_sm2_public_key_info_to_pem($keypair, $targetFile) !== true) {
            throw new RuntimeException('SM2公钥导出失败: 请检查目标路径是否可写');
        }
        // 私钥对象前64字节即公钥点 X||Y,直接推导,无需再读公钥文件
        return '04' . bin2hex(substr($keypair, 0, 64));
    }

    /**
     * 解析 SM4 密钥/IV:未传自定义值时用配置默认值,传了则现场 hex 解码(不走缓存)
     * @return array [二进制key, 二进制iv]
     */
    protected static function sm4Keys(?string $keyHex, ?string $ivHex): array
    {
        $key = $keyHex === null ? self::getSm4Key() : self::hexToBin($keyHex, 'SM4自定义密钥');
        $iv  = $ivHex === null ? self::getSm4Iv() : self::hexToBin($ivHex, 'SM4自定义IV');
        return [$key, $iv];
    }

    /**
     * 获取SM4二进制key(16字节)
     */
    protected static function getSm4Key(): string
    {
        if (self::$sm4Key === null) {
            self::$sm4Key = self::hexToBin((string) config('gm.sm4_key'), 'GM_SM4_KEY');
        }
        return self::$sm4Key;
    }

    /**
     * 获取SM4二进制iv(16字节)
     */
    protected static function getSm4Iv(): string
    {
        if (self::$sm4Iv === null) {
            self::$sm4Iv = self::hexToBin((string) config('gm.sm4_iv'), 'GM_SM4_IV');
        }
        return self::$sm4Iv;
    }

    /**
     * 加载SM2私钥对象(从口令加密的PEM文件解密导入)
     */
    protected static function getSm2PrivKey(): string
    {
        if (self::$sm2PrivKey === null) {
            $file = self::resolveKeyPath((string) config('gm.sm2_priv_key_file'), 'GM_SM2_PRIV_KEY_FILE');
            $pass = (string) config('gm.sm2_priv_pass');
            if ($pass === '') {
                throw new RuntimeException('国密配置错误: 未配置 GM_SM2_PRIV_PASS(SM2私钥口令)');
            }
            self::$sm2PrivKey = gmssl_sm2_private_key_info_decrypt_from_pem($file, $pass);
        }
        return self::$sm2PrivKey;
    }

    /**
     * 加载SM2公钥对象
     */
    protected static function getSm2PubKey(): string
    {
        if (self::$sm2PubKey === null) {
            $file = self::resolveKeyPath((string) config('gm.sm2_pub_key_file'), 'GM_SM2_PUB_KEY_FILE');
            self::$sm2PubKey = gmssl_sm2_public_key_info_from_pem($file);
        }
        return self::$sm2PubKey;
    }

    /**
     * 解析密钥文件路径:绝对路径直接使用,相对路径基于 storage/app 解析
     */
    protected static function resolveKeyPath(string $path, string $envName): string
    {
        $path = trim($path);
        if ($path === '') {
            throw new RuntimeException("国密配置错误: 未配置 {$envName}");
        }
        if (!str_starts_with($path, '/') && !preg_match('/^[A-Za-z]:[\\\\\/]/', $path)) {
            $path = storage_path('app/' . ltrim($path, '/'));
        }
        if (!is_file($path)) {
            throw new RuntimeException("国密配置错误: {$envName} 指向的文件不存在: {$path}");
        }
        return $path;
    }

    /**
     * hex字符串转二进制,并校验长度为16字节
     */
    protected static function hexToBin(string $hex, string $envName): string
    {
        $hex = trim($hex);
        if ($hex === '' || !ctype_xdigit($hex) || strlen($hex) % 2 !== 0) {
            throw new RuntimeException("国密配置错误: {$envName} 必须是合法的hex字符串");
        }
        $bin = hex2bin($hex);
        if (strlen($bin) !== 16) {
            throw new RuntimeException("国密配置错误: {$envName} 必须是16字节(32位hex)");
        }
        return $bin;
    }

    /**
     * SM2签名/验签使用的签名者ID
     */
    protected static function sm2Id(?string $id): string
    {
        if ($id !== null && $id !== '') {
            return $id;
        }
        $configId = (string) config('gm.sm2_id', '');
        return $configId !== '' ? $configId : GMSSL_SM2_DEFAULT_ID;
    }

    /**
     * DER 长度编码
     */
    protected static function derLength(int $len): string
    {
        if ($len < 0x80) {
            return chr($len);
        }
        $bytes = '';
        while ($len > 0) {
            $bytes = chr($len & 0xFF) . $bytes;
            $len >>= 8;
        }
        return chr(0x80 | strlen($bytes)) . $bytes;
    }

    /**
     * DER INTEGER 编码(去前导零,高位为1时补 0x00)
     */
    protected static function derInt(string $bin): string
    {
        $bin = ltrim($bin, "\x00");
        if ($bin === '') {
            $bin = "\x00";
        }
        if (ord($bin[0]) & 0x80) {
            $bin = "\x00" . $bin;
        }
        return "\x02" . self::derLength(strlen($bin)) . $bin;
    }

    /**
     * DER OCTET STRING 编码
     */
    protected static function derOctetString(string $bin): string
    {
        return "\x04" . self::derLength(strlen($bin)) . $bin;
    }
}
  1. 在全局辅助函数 helpers.php 中增加函数。
use App\Utils\GmUtils;

/**
 * SM2签名,返回base64 der签名
 */
function sm2_sign(string $data): string
{
    return GmUtils::sm2Sign($data);
}

/**
 * SM2验签
 */
function sm2_verify(string $data, string $base64Sign): bool
{
    return GmUtils::sm2Verify($data, $base64Sign);
}

/**
 * SM2公钥加密 返回base64密文
 */
function sm2_encrypt(string $plain): string
{
    return GmUtils::sm2Encrypt($plain);
}

/**
 * SM2私钥解密
 */
function sm2_decrypt(string $base64Cipher): string
{
    return GmUtils::sm2Decrypt($base64Cipher);
}

/**
 * SM2解密:客户端(小程序/前端 sm-crypto)传来的裸 C1C3C2 hex 密文
 */
function sm2_decrypt_hex(string $hexCipher): string
{
    return GmUtils::sm2DecryptHex($hexCipher);
}

/**
 * SM3哈希摘要 返回小写hex
 */
function sm3_hash(string $data): string
{
    return GmUtils::sm3($data);
}

/**
 * SM4‑CBC加密 返回base64密文;可选自定义密钥/IV(32位hex),不传用配置默认值
 */
function sm4_encrypt(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4Encrypt($plainText, $keyHex, $ivHex);
}

/**
 * SM4‑CBC解密;可选自定义密钥/IV(32位hex),不传用配置默认值
 */
function sm4_decrypt(string $base64Cipher, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4Decrypt($base64Cipher, $keyHex, $ivHex);
}

/**
 * SM4‑CBC加密 返回hex密文(与小程序/前端对接用)
 */
function sm4_encrypt_hex(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4EncryptHex($plainText, $keyHex, $ivHex);
}

/**
 * SM4‑CBC解密 入参为hex密文(与小程序/前端对接用)
 */
function sm4_decrypt_hex(string $hexCipher, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4DecryptHex($hexCipher, $keyHex, $ivHex);
}
  1. 到此,算是都准备好了。进行测试和具体的业务使用了。
# 在容器中,laravel 项目根目录,执行下边的命令,测试一下

php artisan tinker --execute="var_dump(sm4_decrypt(sm4_encrypt('国密测试')), sm2_verify('hello', sm2_sign('hello')), sm2_decrypt(sm2_encrypt('hello')), sm3_hash('abc'));"

# 输出
string(12) "国密测试"
bool(true)
string(5) "hello"
string(64) "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"
  1. 其他端,要使用非对称加密 sm2 的时候,可以把公钥给过去,签名者ID 约定好就可以。其他的东西不要给过去。

sm2 只能前端加密,后端加密。没办法反着来。如果想要双向的,只能结合 sm4。

  • 前端随机生成一个 16 字节 SM4 密钥(hex)
  • 用 SM2 公钥加密这个 SM4 密钥,随请求上传(服务端 sm2_decrypt_hex 解开)
  • 之后双方用这个 SM4 密钥走 GM_SM4_KEY/IV 相同模式的 CBC 加解密(服务端已有 sm4_encrypt/sm4_decrypt)

阅读全文 >>

8月 24

有些时候,删除和修改的文件都很多。但是只想提交删除的文件,就得只把删除的文件 add 进去。

# win powershell
git ls-files --deleted | ForEach-Object { git add $_ }

# linux
git ls-files --deleted -z | xargs -0 git add
  • git ls-files --deleted:列出所有已删除但未暂存的文件。

  • -z 和 xargs -0:处理文件名中的空格问题。

  • 这条命令会把所有删除状态的文件一次性加入暂存区。

阅读全文 >>

8月 19

为了让 git 版本控制和 goframe 的配置文件能和谐相处,引入了 gomplate。 git 只对模板进行控制,敏感数据只在本地。

https://github.com/hairyhenderson/gomplate

创建

  1. 初始化项目
gf init -u test

cd test 

gf up -a
  1. 创建 manifest/config/config.template, 填入以下配置
# https://goframe.org/docs/web/server-config-file-template
server:
  address: "{{(ds "env").SERVER_PORT}}"
  openapiPath: "/api.json"
  swaggerPath: "/swagger"

# https://goframe.org/docs/core/glog-config
logger:
  path: "storage/logs/daily" # 日志文件路径。默认为空,表示关闭,仅输出到终端
  file: "{Y-m-d}.log" # 日志文件格式。默认为"{Y-m-d}.log"
  prefix: "" # 日志内容输出前缀。默认为空
  level: "all" # 日志输出级别
  timeFormat: "2006-01-02T15:04:05" # 自定义日志输出的时间格式,使用Golang标准的时间格式配置
  ctxKeys: [] # 自定义Context上下文变量名称,自动打印Context的变量到日志中。默认为空
  header: true # 是否打印日志的头信息。默认true
  stdout: true # 日志是否同时输出到终端。默认true
  rotateSize: 0 # 按照日志文件大小对文件进行滚动切分。默认为0,表示关闭滚动切分特性
  rotateExpire: 0 # 按照日志文件时间间隔对文件滚动切分。默认为0,表示关闭滚动切分特性
  rotateBackupLimit: 0 # 按照切分的文件数量清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
  rotateBackupExpire: 0 # 按照切分的文件有效期清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
  rotateBackupCompress: 0 # 滚动切分文件的压缩比(0-9)。默认为0,表示不压缩
  rotateCheckInterval: "1h" # 滚动切分的时间检测间隔,一般不需要设置。默认为1小时
  stdoutColorDisabled: false # 关闭终端的颜色打印。默认开启
  writerColorEnable: false # 日志文件是否带上颜色。默认false,表示不带颜色
  error:
    path: "storage/logs/error/"
    file: "{Y-m-d}.log"
    stdout: true
  test:
    path: "storage/logs/test"
    file: "{Y-m-d}.log"
    stdout: true

plantform:
  tencent:
    secretId: {{(ds "env").TENCENT_SECRETID}}
    secretKey: {{(ds "env").TENCENT_SECRETKEY}}
  1. 在项目根目录下创建 .env.dev 、 .env.prod 、 .env.example 这三个文件。仅 .env.example 添加到版本控制中。内容如下。其他文件根据示例文件,按场景需要配置。
# 端口号
SERVER_PORT=:8090

# 平台信息
TENCENT_SECRETID=
TENCENT_SECRETKEY=
  1. 创建 hack/config.mk 文件。内容如下:
# 环境: dev / prod
# https://docs.gomplate.ca/
# https://github.com/hairyhenderson/gomplate
ENV ?= dev
ENV_FILE := .env.$(ENV)
TPL_SRC  := manifest/config/config.template
TPL_OUT  := manifest/config/config.yaml

.PHONY: config
config:
    @echo ----------------------------------------
    @echo create config template using $(ENV_FILE)
    @echo ----------------------------------------
    @echo. 
    gomplate -d env=$(ENV_FILE)?type=application/x-env -f $(TPL_SRC) -o $(TPL_OUT)
    @echo.
    @echo Config created: $(TPL_OUT)
    @echo.
  1. 在根目录下的 Makefile 文件追加配置。
…
include ./hack/config.mk
  1. 添加 git 版本控制
git init -b main

# 在 .gitignore 中增加忽略文件
.env.dev
.env.prod

# 添加配置
git add .
git commit -m 'initialize'

使用

# 生成测试环境配置
make config

# 生成正式服环境配置
make config ENV=prod

使用上边命令就可以在 manifest/config/ 下生成对应的配置文件。

阅读全文 >>

8月 17

不从零搞双因素认证(2FA),而是依靠 goauthentik 来搞。

官网:https://docs.goauthentik.io/install-config/install/docker-compose/

开始

  1. 官网已经为你准备好了 compose.yaml 文件,我们把它下载下来。
mkdir goauthentik-server

cd goauthentik-server

wget https://docs.goauthentik.io/compose.yml

# 还是喜欢用 docker-compose.yaml 作为文件名
mv compose.yml docker-compose.yaml
  1. 准备 .env 文件。
    生成 .env 文件,根据 docker-compose.yaml 和业务需要,设置 .env 的内容。
vim .env

# ==============================================
# Authentik 数据库配置 (PostgreSQL)
# ==============================================

# PostgreSQL 数据库名
PG_DB: authentik
# PostgreSQL 数据库账号
PG_USER: authentik
# PostgreSQL 数据库密码,【必须修改,不能为空】
PG_PASS: fa20260817

# ==============================================
# Authentik 核心密钥【生产务必重新生成!】
# ==============================================
# 生成命令: openssl rand -hex 16
# 这个密钥加密数据库内TOTP密钥、会话Cookie;丢失则现有用户MFA设备全部无法解密,需要全部重置MFA
AUTHENTIK_SECRET_KEY=94b3f57608c0392e417e21ec721e41dc

# ==============================================
# 镜像版本配置
# ==============================================
# Authentik镜像地址,默认官方ghcr镜像
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
# Authentik版本标签,和compose里保持一致,升级时修改此处
AUTHENTIK_TAG=2026.5.6

# ==============================================
# 端口映射配置(宿主机端口)
# ==============================================
# HTTP端口,生产环境建议不暴露,只用HTTPS
COMPOSE_PORT_HTTP=9000
# HTTPS访问端口
COMPOSE_PORT_HTTPS=9443
  1. 运行 docker compose up -d

阅读全文 >>