diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a605b83 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "external/ChuSQL"] + path = external/ChuSQL + url = ChuGit:/srv/git/ChuSQL.git +[submodule "external/ChuSQL-RustDriver"] + path = external/ChuSQL-RustDriver + url = ChuGit:/srv/git/ChuSQL-RustDriver.git diff --git a/Cargo.lock b/Cargo.lock index 335c8a0..7885c60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -215,6 +221,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "ssh-key", "tempfile", "tokio", "tower", @@ -231,6 +238,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -255,6 +278,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -265,6 +300,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -272,10 +317,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -307,6 +386,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -375,6 +464,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -408,12 +498,32 @@ dependencies = [ "r-efi", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -553,6 +663,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -575,6 +694,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -582,6 +704,12 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -680,6 +808,41 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -687,6 +850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -695,6 +859,44 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -729,6 +931,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -741,6 +952,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "png" version = "0.18.1" @@ -763,6 +995,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -874,6 +1115,37 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustix" version = "1.1.5" @@ -905,6 +1177,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "serde" version = "1.0.229" @@ -984,6 +1270,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1009,6 +1306,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1037,6 +1344,62 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "p256", + "p384", + "p521", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1414,6 +1777,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 6272a11..98b57e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,4 @@ [workspace] -members = ["apps/api", "crates/chusql-driver"] +members = ["apps/api"] +exclude = ["external/ChuSQL", "external/ChuSQL-RustDriver"] resolver = "3" diff --git a/README.md b/README.md index 24491f1..f6fd596 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,38 @@ # ChuBlog -Rust/Axum 博客后端。文章作者在本地编辑 Markdown,通过受限 SSH Git 推送发布。注册、登录、权限管理使用 HTTP API;文章仅通过 Git 修改。ChuSQL 使用项目内复制的 `crates/chusql-driver`,只存可重建索引。 +Rust/Axum 博客后端。文章作者在本地编辑 Markdown,通过受限 SSH Git 推送发布。注册、登录、权限管理使用 HTTP API;文章仅通过 Git 修改。ChuSQL 通过外部子模块 `external/ChuSQL-RustDriver` 接入,只存可重建索引。 本轮按最新需求只实现后端,没有前端或在线文章管理。`PLAN.md` 保留原始规划,当前范围与后续安排见 [TODO.md](TODO.md)。 ## 项目与运行 ```text -workspace/ -├── ChuBlog/ # 本项目 -│ └── crates/chusql-driver/ # 项目内的驱动副本 -└── ChuSQL/ # 现有数据库及已编译 Linux 产物 +ChuBlog/ +├── apps/api/ +├── external/ +│ ├── ChuSQL/ # Git 子模块,主仓库只记录版本指针 +│ └── ChuSQL-RustDriver/ # Git 子模块 +├── config/git-local.config +└── scripts/init-deps.py ``` +先初始化外部依赖: + +```sh +python3 scripts/init-deps.py +``` + +数据库地址为 `ChuGit:/srv/git/ChuSQL.git`,驱动为 `ChuGit:/srv/git/ChuSQL-RustDriver.git`。客户端需要配置 `ChuGit` SSH 别名。服务器上存在这两个本地仓库时,初始化脚本会优先使用项目级 `insteadOf` 将链接映射到 `file:///srv/git/…`,不经过 SSH。`config/git-local.config` 同时包含 SSH/GitHub 链接到本地仓库的映射,只在本项目启用,不修改全局配置。 + +开发电脑可明确选择已有本地仓库(工作树或 bare 仓库),不会把依赖的未提交改动复制进来: + +```sh +python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver +``` + +子模块固定到已提交版本;源仓库必须包含这些提交。依赖源码和构建产物不进入博客仓库,更新依赖时只提交 `.gitmodules`/配置及子模块指针。不要对主仓库使用 `git add --force` 重新收录依赖文件。 + + 开发检查: ```sh @@ -25,7 +45,7 @@ cargo clippy --all-targets -- -D warnings ```sh # ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。 -python3 scripts/prepare-deploy.py +python3 scripts/prepare-deploy.py --build-database cp .env.example .env chmod 600 .env # 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。 @@ -38,7 +58,7 @@ docker compose --env-file .env -f deploy/compose.yaml run --rm db-init 构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。 -`prepare-deploy.py` 复制相邻 ChuSQL 的既有编译产物,不修改该项目;没有产物会明确失败。跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。镜像不含编译器、源码、`.env` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 +`prepare-deploy.py` 默认从数据库子模块读取编译产物;`--build-database` 会先构建其 Rust 存储库、Haskell server 和 bootstrap。也可用 `--chusql-source /已有构建的/ChuSQL` 选择已有外部构建,没有产物会明确失败。编译产物只暂存在被忽略的 `deploy/bin/`。跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。镜像不含编译器、源码、`.env` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 Caddy 根据 `SITE_ADDRESS` 提供 HTTPS 和 API 反向代理;`PUBLIC_ORIGIN` 必须与外部站点完全一致,不带结尾斜杠。HTTP 仅对 localhost 开发场景允许。只需公网 TCP 80/443 和 Git SSH 2222(可改);主机管理 SSH 另行管理,数据库和 API 不映射宿主端口。既有 UDP 7001 不受影响。参考 [Caddy 请求体限制](https://caddyserver.com/docs/caddyfile/directives/request_body) 与 [反向代理文档](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)。 @@ -48,7 +68,7 @@ Caddy 根据 `SITE_ADDRESS` 提供 HTTPS 和 API 反向代理;`PUBLIC_ORIGIN` 1. 注册:`POST /api/auth/register`,正文 `{ "username": "alice", "password": "至少12字节的口令" }`。默认角色 `reader`,绝不接受客户端指定角色。`ALLOW_REGISTRATION=false` 可关闭注册。 2. 登录:`POST /api/auth/login`,相同字段。浏览器保存 HttpOnly Cookie;返回 `csrf`。所有修改请求必须携带站点 `Origin` 和 `X-CSRF-Token`。 -3. 本地生成 `ssh-keygen -t ed25519`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。 +3. 本地生成 `ssh-keygen -t ed25519` 或 `ssh-keygen -t rsa -b 3072`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。RSA 使用标准 `ssh-rsa AAAA...` 公钥文本,支持 2048–8192 位;实际认证使用 RSA/SHA-256 或 RSA/SHA-512,不启用旧的 SHA-1 签名。见 [OpenSSH 说明](https://www.openssh.com/txt/release-8.8)。 4. 管理员登录后调用 `PATCH /api/admin/users/alice`,正文 `{ "enabled": true, "role": "publisher", "prefixes": ["alice-"] }`。不同有效发布者的前缀不能重叠。 5. 此时 SSH 才接受该用户的密钥;`reader`、停用用户、未绑密钥的用户均不能访问 Git。管理员也需显式分配前缀和公钥才能发布。 @@ -99,7 +119,7 @@ MVP 每次只接受 **一个直接继承远端 main 的提交**,首次仅允 - Markdown 上限 256 KiB;图片单张 2 MiB、最多 12 张、尺寸 4096×4096;文章合计 6 MiB;快照原始文件合计 64 MiB、最多 1000 篇。 - Git 输入 pack 上限 8 MiB;接收仓库达到 256 MiB 后拒绝新 SSH 会话,需要管理员离线维护。此阈值是应用检查,生产还应为 Docker 数据盘配置真实文件系统配额。 - 使用成熟 Markdown 解析器,拒绝原始 HTML、危险链接协议、外链图片、控制符和双向隐藏字符;代码块是惰性文本,绝不执行 MDX、JS、仓库脚本、构建任务或上传内容。 -- 仅支持 Ed25519 公钥;拒绝把 `authorized_keys` 选项当作公钥上传。SSH 只接受精确的 `git-receive-pack 'content.git'` / `git-upload-pack 'content.git'`,没有 shell、SCP/SFTP、TTY、转发、隧道、用户环境变量。 +- 支持 Ed25519 和 2048–8192 位 RSA 公钥,拒绝弱密钥、负数/畸形 RSA 参数及额外二进制数据;拒绝把 `authorized_keys` 选项当作公钥上传。SSH 只接受精确的 `git-receive-pack 'content.git'` / `git-upload-pack 'content.git'`,没有 shell、SCP/SFTP、TTY、转发、隧道、用户环境变量。 - 接收仓库由 UID 10002 管理;API 为独立 UID 10001,正式仓库/用户密码文件不挂载给 SSH 容器;SSH 只读授权文件和只读固定 hook。接收内容再次验证后才导入 API 自己的 Git 仓库。 - HTTP 请求体 8 KiB;注册/登录合计全局 10 次/分钟,修改操作全局 60 次/分钟;密码哈希并发最多 2。1000 用户、1000 个有效会话上限,一小时固定会话期限。全局限制偏保守,后续可细化 IP/账户限制以改善公平性。 - 统计不收集 IP/访客身份:公开文章数、标签数、Markdown 字节数、启动以来文章 API 请求数。请求数不是独立访客数,重启后清零。 @@ -119,8 +139,14 @@ MVP 每次只接受 **一个直接继承远端 main 的提交**,首次仅允 ## 联调 ```sh -python3 scripts/prepare-deploy.py +python3 scripts/prepare-deploy.py --build-database python3 scripts/smoke.py +python3 scripts/smoke.py --key-type rsa ``` 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。覆盖真实 SSH 身份与授权、非法推送、发布撤回、ChuSQL 索引、断库阅读、重启与撤权。该测试会拉取基础镜像,需要 Docker 可用。 + + +## 历史清理后的协作 + +驱动的旧源码副本会从主仓库历史中移除,后续只保留外部子模块配置。历史重写后,已有克隆建议重新克隆并初始化依赖;不要把旧 main 合并回来,否则可能重新引入已删除的依赖历史。自建服务器的不可达对象清理与其他人的旧克隆需要分别处理,GitHub 缓存和旧提交直链的回收由平台管理。 diff --git a/TODO.md b/TODO.md index 47e0a70..6b45fab 100644 --- a/TODO.md +++ b/TODO.md @@ -7,13 +7,13 @@ - [x] Rust/Axum API:公开文章列表、详情、图片、基础统计、健康接口。 - [x] 用户注册、登录、退出、修改密码;Argon2id、服务端会话、Cookie、Origin/CSRF 校验。 - [x] reader / publisher / admin 权限;注册不能自选权限,管理员单独审批发布权、停用用户,修改权限使已有会话失效。 -- [x] 用户上传/轮换自己的 Ed25519 公钥,公钥存在与具有发布权分别检查。 +- [x] 用户上传/轮换自己的 Ed25519/RSA 公钥,公钥存在与具有发布权分别检查。 - [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。 - [x] 发布者 slug 前缀授权,不允许重叠授权或修改其他命名空间。 - [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。 - [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。 - [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。 -- [x] 项目内 `crates/chusql-driver` 副本;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 +- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置与本地路径优先映射;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 - [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 - [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index c1dd137..53cf483 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -9,13 +9,14 @@ argon2 = "0.5" axum = "0.8" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } -chusql-driver = { path = "../../crates/chusql-driver" } +chusql-driver = { path = "../../external/ChuSQL-RustDriver" } fs2 = "0.4" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +ssh-key = { version = "0.6.7", default-features = false, features = ["std"] } tokio = { version = "1", features = ["full"] } pulldown-cmark = "0.13" tracing = "0.1" diff --git a/apps/api/src/accounts.rs b/apps/api/src/accounts.rs index 0f3ba4b..b882ef5 100644 --- a/apps/api/src/accounts.rs +++ b/apps/api/src/accounts.rs @@ -10,10 +10,7 @@ use axum::{ http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; -use base64::{ - Engine, - engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, -}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -428,23 +425,38 @@ pub async fn logout(State(app): State>, headers: HeaderMap) -> ApiResul } pub fn public_key(value: &str) -> Result { ensure!( - value.len() < 512 && !value.contains(['\n', '\r']), + value.len() <= 4096 && !value.contains(['\n', '\r']), "公钥格式无效" ); - let parts: Vec<_> = value.split_whitespace().collect(); - ensure!( - parts.len() >= 2 && parts[0] == "ssh-ed25519", - "仅支持 Ed25519 公钥,禁止 authorized_keys 选项" - ); - let bytes = STANDARD.decode(parts[1])?; - ensure!( - bytes.len() == 51 - && bytes[..4] == [0, 0, 0, 11] - && &bytes[4..15] == b"ssh-ed25519" - && bytes[15..19] == [0, 0, 0, 32], - "Ed25519 公钥内容无效" - ); - Ok(format!("ssh-ed25519 {}", STANDARD.encode(bytes))) + let key = ssh_key::PublicKey::from_openssh(value) + .context("SSH 公钥格式无效,禁止 authorized_keys 选项")?; + match key.key_data() { + ssh_key::public::KeyData::Ed25519(_) => {} + ssh_key::public::KeyData::Rsa(rsa) => { + let modulus = rsa.n.as_positive_bytes().context("RSA 模数必须为正数")?; + let first = modulus.first().context("RSA 模数为空")?; + let bits = modulus.len() * 8 - first.leading_zeros() as usize; + ensure!( + (2048..=8192).contains(&bits) && modulus.last().is_some_and(|b| b & 1 == 1), + "RSA 模数必须为 2048–8192 位正奇数" + ); + let exponent = rsa.e.as_positive_bytes().context("RSA 指数必须为正数")?; + ensure!(exponent.len() <= 4, "RSA 指数过大"); + let exponent = exponent.iter().fold(0_u32, |n, b| (n << 8) | u32::from(*b)); + ensure!( + exponent >= 3 && exponent % 2 == 1, + "RSA 指数必须为至少 3 的奇数" + ); + } + _ => anyhow::bail!("仅支持 Ed25519 或 RSA 公钥"), + } + // Drop comments and options: the stored value is exactly the parsed public key. + Ok(format!( + "{} {}", + key.algorithm().as_str(), + key.to_bytes() + .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes))? + )) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] diff --git a/apps/api/src/tests.rs b/apps/api/src/tests.rs index 3c2c1fa..d3801b1 100644 --- a/apps/api/src/tests.rs +++ b/apps/api/src/tests.rs @@ -253,6 +253,58 @@ fn ssh_keys_cannot_inject_options_or_commands() { assert!(accounts::public_key(&format!("{key}\nssh-rsa AAA")).is_err()); assert!(accounts::public_key("ssh-ed25519 AAAA").is_err()); } +fn rsa_key(bits: usize, exponent: &[u8]) -> String { + let mut modulus = vec![0xff; bits.div_ceil(8)]; + modulus[0] >>= (8 - bits % 8) % 8; + if modulus[0] & 0x80 != 0 { + modulus.insert(0, 0); + } + let mut bytes = Vec::new(); + for field in [b"ssh-rsa".as_slice(), exponent, modulus.as_slice()] { + bytes.extend((field.len() as u32).to_be_bytes()); + bytes.extend(field); + } + format!( + "ssh-rsa {}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) +} +#[test] +fn rsa_keys_enforce_strength_encoding_and_safe_canonical_form() { + for bits in [2048, 3072, 4096, 8192] { + let key = rsa_key(bits, &[1, 0, 1]); + assert_eq!( + accounts::public_key(&format!("{key} user comment")).unwrap(), + key + ); + } + for key in [ + rsa_key(1024, &[1, 0, 1]), + rsa_key(2047, &[1, 0, 1]), + rsa_key(8193, &[1, 0, 1]), + rsa_key(2048, &[2]), + rsa_key(2048, &[1]), + rsa_key(2048, &[0x80]), + rsa_key(2048, &[1, 0, 0, 0, 1]), + ] { + assert!(accounts::public_key(&key).is_err()); + } + let key = rsa_key(2048, &[1, 0, 1]); + assert!(accounts::public_key(&key.replace("ssh-rsa", "ssh-ed25519")).is_err()); + assert!(accounts::public_key(&format!("command=\"sh\" {key}")).is_err()); + let encoded = key.split_whitespace().nth(1).unwrap(); + let mut bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + bytes.push(0); + assert!( + accounts::public_key(&format!( + "ssh-rsa {}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) + .is_err() + ); +} async fn app(dir: &std::path::Path) -> Arc { let config = dir.join("publishers.json"); let hash = accounts::hash_password("Administrator-test-123".into()) diff --git a/config/git-local.config b/config/git-local.config new file mode 100644 index 0000000..570d43d --- /dev/null +++ b/config/git-local.config @@ -0,0 +1,13 @@ +# Optional on the self-hosted server; loaded by scripts/init-deps.py when these repos exist. +[url "file:///srv/git/ChuSQL.git"] + insteadOf = ChuGit:/srv/git/ChuSQL.git + insteadOf = ssh://git@chunana.cn/srv/git/ChuSQL.git + insteadOf = git@chunana.cn:/srv/git/ChuSQL.git + insteadOf = git@github.com:TheBadRoger/ChuSQL.git + insteadOf = https://github.com/TheBadRoger/ChuSQL.git +[url "file:///srv/git/ChuSQL-RustDriver.git"] + insteadOf = ChuGit:/srv/git/ChuSQL-RustDriver.git + insteadOf = ssh://git@chunana.cn/srv/git/ChuSQL-RustDriver.git + insteadOf = git@chunana.cn:/srv/git/ChuSQL-RustDriver.git + insteadOf = git@github.com:TheBadRoger/ChuSQL-RustDriver.git + insteadOf = https://github.com/TheBadRoger/ChuSQL-RustDriver.git diff --git a/deploy/sshd_config b/deploy/sshd_config index bad679b..35e9e41 100644 --- a/deploy/sshd_config +++ b/deploy/sshd_config @@ -8,7 +8,8 @@ KbdInteractiveAuthentication no PermitEmptyPasswords no PubkeyAuthentication yes AuthenticationMethods publickey -PubkeyAcceptedAlgorithms ssh-ed25519 +PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256 +RequiredRSASize 2048 AllowUsers git AuthorizedKeysFile none AuthorizedKeysCommand /usr/local/bin/chublog-authorized-keys diff --git a/docs/API.md b/docs/API.md index 961cc21..77dc0a5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -13,7 +13,7 @@ | POST | `/api/auth/login` | 公共 + Origin;`{username,password}`,返回 `{username,role,csrf}` 及 Cookie | | POST | `/api/auth/logout` | 登录 + Origin + CSRF;使会话失效并清除 Cookie | | GET | `/api/account` | 登录;返回自己的 `{username,permissions,csrf}`,不含口令哈希 | -| PUT | `/api/account/ssh-key` | 登录 + Origin + CSRF;`{public_key}`,替换自己的唯一 Ed25519 公钥 | +| PUT | `/api/account/ssh-key` | 登录 + Origin + CSRF;`{public_key}`,替换自己的唯一 Ed25519 或 RSA 公钥(RSA 2048–8192 位) | | PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 | | GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 | | PATCH | `/api/admin/users/:username` | 管理员 + Origin + CSRF;`{enabled,role,prefixes}` | diff --git a/docs/decisions/0002-external-dependencies.md b/docs/decisions/0002-external-dependencies.md new file mode 100644 index 0000000..49378fc --- /dev/null +++ b/docs/decisions/0002-external-dependencies.md @@ -0,0 +1,13 @@ +# ADR-002:外部项目使用子模块配置 + +## 状态 + +已接受,2026-10-15。替代早期复制数据库驱动源码的做法。 + +## 决定 + +数据库服务与 Rust 驱动作为两个独立 Git 子模块维护。主仓库只提交远程地址、固定版本指针、初始化/构建配置,不提交依赖源码或编译产物。Cargo 引用初始化后的驱动路径,数据库从外部 checkout 构建并暂存到被忽略的 Docker 构建目录。 + +正式来源使用 `ChuGit:/srv/git/ChuSQL.git` 与 `ChuGit:/srv/git/ChuSQL-RustDriver.git`。在服务器可读本地 bare 仓库时,项目级 `insteadOf` 优先使用 `file:///srv/git/…`;开发者可显式指定已有本地来源。文件协议只在初始化这两个明确子模块的命令中启用,不设置全局许可,不递归获取未知子模块。 + +已有驱动副本从主仓库的历史树中删除。外部项目仍各自保留完整历史;主仓库不能替代依赖仓库分发其对象,源仓库必须先具有固定指针所引用的提交。 diff --git a/external/ChuSQL b/external/ChuSQL new file mode 160000 index 0000000..a45e33f --- /dev/null +++ b/external/ChuSQL @@ -0,0 +1 @@ +Subproject commit a45e33f94bf84ac496c122e687c6e5d0a96465bb diff --git a/external/ChuSQL-RustDriver b/external/ChuSQL-RustDriver new file mode 160000 index 0000000..105ad89 --- /dev/null +++ b/external/ChuSQL-RustDriver @@ -0,0 +1 @@ +Subproject commit 105ad89aceca1c40fd753e107d3cb81c21e649c3 diff --git a/scripts/init-deps.py b/scripts/init-deps.py new file mode 100755 index 0000000..3cab349 --- /dev/null +++ b/scripts/init-deps.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Initialize pinned external submodules, preferring local repositories over SSH.""" +import argparse +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +PROJECTS = ['ChuSQL', 'ChuSQL-RustDriver'] +PREFIX = 'ChuGit:/srv/git/' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--database-repo', type=Path, help='local clone/bare repository of ChuSQL') + parser.add_argument('--driver-repo', type=Path, help='local clone/bare repository of ChuSQL-RustDriver') + args = parser.parse_args() + git_args = [] + local_config = ROOT / 'config/git-local.config' + if not args.database_repo and not args.driver_repo and all((Path('/srv/git') / f'{name}.git').is_dir() for name in PROJECTS): + current = subprocess.run(['git', 'config', '--local', '--get-all', 'include.path'], cwd=ROOT, text=True, capture_output=True, check=False) + if str(local_config) not in current.stdout.splitlines(): + subprocess.run(['git', 'config', '--local', '--add', 'include.path', str(local_config)], cwd=ROOT, check=True) + git_args.extend(['-c', f'include.path={local_config}']) + for name, override in zip(PROJECTS, [args.database_repo, args.driver_repo]): + source = override or Path('/srv/git') / f'{name}.git' + if override and not source.is_dir(): + parser.error(f'Local repository does not exist: {source}') + if source.is_dir(): + source = source.resolve() + subprocess.run(['git', '-C', str(source), 'rev-parse', '--git-dir'], check=True, capture_output=True) + key = f'url.{source.as_uri()}.insteadOf' + url = f'{PREFIX}{name}.git' + subprocess.run(['git', 'config', '--local', '--replace-all', key, url], cwd=ROOT, check=True) + git_args.extend(['-c', f'{key}={url}']) + print(f'{name}: using local repository {source}', flush=True) + # The file protocol is enabled only for this explicit, fixed set of dependencies. + if git_args: + git_args.extend(['-c', 'protocol.file.allow=always']) + subprocess.run(['git', *git_args, 'submodule', 'update', '--init', '--', *[f'external/{name}' for name in PROJECTS]], cwd=ROOT, check=True) + + +if __name__ == '__main__': + main() diff --git a/scripts/prepare-deploy.py b/scripts/prepare-deploy.py index c95d500..8171015 100755 --- a/scripts/prepare-deploy.py +++ b/scripts/prepare-deploy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build the API and copy existing local ChuSQL release artifacts into the Docker context.""" +"""Build the API and stage external ChuSQL artifacts without committing dependencies.""" import argparse from pathlib import Path import shutil @@ -8,17 +8,27 @@ import subprocess root = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--no-build', action='store_true', help='use an existing API release binary') +parser.add_argument('--chusql-source', type=Path, default=root / 'external/ChuSQL', help='initialized/built external ChuSQL checkout') +parser.add_argument('--build-database', action='store_true', help='build external database artifacts before staging') args = parser.parse_args() +chusql = args.chusql_source.resolve() +if not (root / 'external/ChuSQL-RustDriver/Cargo.toml').is_file(): + parser.error('Initialize external dependencies with scripts/init-deps.py first.') +if not (chusql / 'chusql-core/storage/Cargo.toml').is_file(): + parser.error(f'ChuSQL source is missing: {chusql}; initialize dependencies first.') if not args.no_build: subprocess.run(['cargo', 'build', '--locked', '--release'], cwd=root, check=True) -out = root / 'deploy' / 'bin' +if args.build_database: + subprocess.run(['cargo', 'build', '--locked', '--release'], cwd=chusql / 'chusql-core/storage', check=True) + for package in ['chusql-server', 'chusql-bootstrap']: + subprocess.run(['stack', 'build', '--fast'], cwd=chusql / package, check=True) +out = root / 'deploy/bin' out.mkdir(parents=True, exist_ok=True) shutil.copy2(root / 'target/release/chublog-api', out / 'chublog-api') -chusql = root.parent / 'ChuSQL' for package, executable in [('chusql-server', 'chusql-server'), ('chusql-bootstrap', 'csql-bootstrap')]: matches = list((chusql / package / '.stack-work/install').glob(f'*/**/bin/{executable}')) if len(matches) != 1: - raise SystemExit(f'Expected one built {executable}, found {len(matches)}; build {package} first using its stack.yaml.') + raise SystemExit(f'Expected one built {executable}, found {len(matches)}; use --build-database or select a built --chusql-source.') shutil.copy2(matches[0], out / executable) shutil.copy2(chusql / 'chusql-core/storage/target/release/libchusql_core_storage.so', out) -print('Prepared deploy/bin. Build and run Compose from this same Linux architecture (Debian 13).') +print('Prepared ignored deploy/bin. Build and run Compose on the same Debian 13 architecture.') diff --git a/scripts/smoke.py b/scripts/smoke.py index b898609..58e5096 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -3,6 +3,7 @@ Uses an isolated Compose project and removes only that project's containers/volumes. Run prepare-deploy.py first. Ports 18080, 18443, 12222 must be free. """ +import argparse import http.cookiejar import json import os @@ -16,6 +17,9 @@ import urllib.request ROOT = Path(__file__).resolve().parents[1] ORIGIN = 'http://localhost:18080' +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--key-type', choices=['ed25519', 'rsa'], default='ed25519') +args = parser.parse_args() def run(args, **kwargs): @@ -77,7 +81,7 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary: publisher = Client() publisher.call('POST', '/api/auth/register', {'username': 'alice', 'password': password}) publisher.csrf = publisher.call('POST', '/api/auth/login', {'username': 'alice', 'password': password})['csrf'] - run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(tmp / 'key')]) + run(['ssh-keygen', '-q', '-t', args.key_type, '-b', '3072', '-N', '', '-f', str(tmp / 'key')]) publisher.call('PUT', '/api/account/ssh-key', {'public_key': (tmp / 'key.pub').read_text().strip()}) publisher.call('GET', '/api/admin/users', expected=403) env = os.environ.copy() diff --git a/scripts/test-init-deps.py b/scripts/test-init-deps.py new file mode 100644 index 0000000..b75a20c --- /dev/null +++ b/scripts/test-init-deps.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Verify pinned submodules initialize from local repositories without an SSH connection.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name('init-deps.py') + + +class InitializeDependencies(unittest.TestCase): + def test_local_rewrite_fetches_only_pinned_dependency_commits(self): + with tempfile.TemporaryDirectory(prefix='chublog deps ') as temporary: + root = Path(temporary) + project = root / 'blog' + project.mkdir() + (project / 'scripts').mkdir() + shutil.copy2(SCRIPT, project / 'scripts/init-deps.py') + env = {**os.environ, 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1'} + + def git(cwd, *args): + return subprocess.run(['git', *args], cwd=cwd, env=env, check=True, text=True, capture_output=True).stdout.strip() + + git(project, 'init', '--initial-branch=main') + modules = [] + sources = [] + for name in ['ChuSQL', 'ChuSQL-RustDriver']: + source = root / name + source.mkdir() + git(source, 'init', '--initial-branch=main') + (source / 'source.txt').write_text(f'{name} committed source') + git(source, 'add', '.') + git(source, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'Dependency version') + oid = git(source, 'rev-parse', 'HEAD') + # Dirty source must never leak into the blog or fetched dependency. + (source / 'source.txt').write_text('uncommitted changes') + path = f'external/{name}' + modules.append(f'[submodule "{path}"]\n path = {path}\n url = ChuGit:/srv/git/{name}.git\n') + git(project, 'update-index', '--add', '--cacheinfo', f'160000,{oid},{path}') + sources.append(str(source)) + (project / '.gitmodules').write_text(''.join(modules)) + git(project, 'add', '.gitmodules') + subprocess.run(['python3', 'scripts/init-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True) + for name in ['ChuSQL', 'ChuSQL-RustDriver']: + self.assertEqual((project / f'external/{name}/source.txt').read_text(), f'{name} committed source') + self.assertTrue(all(line.startswith('160000') for line in git(project, 'ls-files', '--stage', 'external').splitlines())) + # Running the initialization a second time must preserve the same pins. + subprocess.run(['python3', 'scripts/init-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True) + + +if __name__ == '__main__': + unittest.main()