From 2c16699423af0b3065211120bbc5398391d1c4cd Mon Sep 17 00:00:00 2001 From: jacek Date: Fri, 14 Aug 2026 01:34:37 +0800 Subject: [PATCH] Add interactive installer and lifecycle manager --- .github/workflows/release.yml | 12 +- CHANGELOG.md | 31 +- Cargo.lock | 2 +- Cargo.toml | 6 +- README.md | 123 +- README.zh-CN.md | 116 +- docs/configuration.md | 4 +- docs/configuration.zh-CN.md | 5 +- docs/index.md | 5 + docs/index.zh-CN.md | 5 + docs/installation-management.md | 153 ++ docs/installation-management.zh-CN.md | 131 ++ docs/production-hardening.md | 32 +- install.sh | 89 ++ ops/deploy.sh | 75 +- ops/systemd/rust-xhttp.service | 36 +- scripts/check_docs.py | 1 + scripts/gate.sh | 3 + scripts/m14_installer_smoke.sh | 33 + scripts/package_release.sh | 53 + src/bin/rust-xhttpctl.rs | 11 + src/management.rs | 1837 +++++++++++++++++++++++++ 22 files changed, 2642 insertions(+), 121 deletions(-) create mode 100644 docs/installation-management.md create mode 100644 docs/installation-management.zh-CN.md create mode 100755 install.sh create mode 100755 scripts/m14_installer_smoke.sh create mode 100755 scripts/package_release.sh create mode 100644 src/bin/rust-xhttpctl.rs create mode 100644 src/management.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a49fa7..0628014 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,13 +25,8 @@ jobs: TAG: ${{ github.ref_name }} run: | set -euo pipefail - archive="rust-xhttp-${TAG}-x86_64-unknown-linux-gnu" - mkdir "$archive" - cp target/release/rust-xhttp README.md README.zh-CN.md LICENSE \ - config.example.json config.acme.example.json "$archive/" - cp -r docs "$archive/" - tar -czf "$archive.tar.gz" "$archive" - sha256sum "$archive.tar.gz" > "$archive.tar.gz.sha256" + mkdir release-dist + scripts/package_release.sh "$TAG" release-dist - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -39,5 +34,6 @@ jobs: run: | set -euo pipefail archive="rust-xhttp-${TAG}-x86_64-unknown-linux-gnu.tar.gz" - gh release create "$TAG" "$archive" "$archive.sha256" \ + gh release create "$TAG" "release-dist/$archive" \ + "release-dist/$archive.sha256" "release-dist/install.sh" \ --title "rust-xhttp $TAG" --generate-notes --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb8102..47264f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.2.0] - 2026-08-14 + +### Added + +- Added the separate Rust `rust-xhttpctl` interactive installer and lifecycle + manager for systemd installation, status/log access, diagnosis, transactional + config editing, repair, update, rollback, and preserve-or-purge uninstall. +- Added a version-pinned one-command bootstrap that verifies the GitHub Release + SHA-256 before starting the Rust wizard. +- Added bilingual installation and long-term management documentation plus an + alternate-root installer smoke test in the required quality gate. + +### Changed + +- Run managed deployments as a dedicated non-login user with stable `/etc`, + `/usr/local/bin`, and `/var/lib` paths instead of a root-owned working tree. +- Package both the daemon and manager, the pinned bootstrap, configuration + examples, and bilingual documentation in every Linux release archive. + +### Security + +- Hardened the canonical systemd unit with config preflight, a read-only system, + home/device isolation, restricted address families, and only + `CAP_NET_BIND_SERVICE`. +- Online updates enforce HTTPS-only redirects, validated tags/archive paths, + matching binary versions, published SHA-256 verification, config preflight, + atomic replacement, and automatic failed-activation rollback. + ## [0.1.1] - 2026-08-14 ### Changed @@ -40,4 +68,5 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [0.1.0]: https://github.com/jacek4yang/rust-xhttp/releases/tag/v0.1.0 [0.1.1]: https://github.com/jacek4yang/rust-xhttp/compare/v0.1.0...v0.1.1 -[Unreleased]: https://github.com/jacek4yang/rust-xhttp/compare/v0.1.1...HEAD +[0.2.0]: https://github.com/jacek4yang/rust-xhttp/compare/v0.1.1...v0.2.0 +[Unreleased]: https://github.com/jacek4yang/rust-xhttp/compare/v0.2.0...HEAD diff --git a/Cargo.lock b/Cargo.lock index 2e2a99b..8cfce80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1389,7 +1389,7 @@ dependencies = [ [[package]] name = "rust-xhttp" -version = "0.1.1" +version = "0.2.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index d27b692..d24e369 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-xhttp" -version = "0.1.1" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Pure-Rust XHTTP/VLESS server wire-compatible with the official Xray-core client (XHTTP packet-up + VLESS + VLESS-Encryption + Vision + XUDP)" @@ -23,6 +23,10 @@ path = "src/lib.rs" name = "rust-xhttp" path = "src/main.rs" +[[bin]] +name = "rust-xhttpctl" +path = "src/bin/rust-xhttpctl.rs" + [dependencies] # async runtime / io tokio = { version = "1.40", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros", "signal"] } diff --git a/README.md b/README.md index da5600b..3ee373b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Single crate, one module per protocol layer (mirrors the sibling `rust-reality` | Path | Responsibility | |------|----------------| | `src/main.rs`, `src/runtime.rs` | entry point + stack wiring | +| `src/bin/rust-xhttpctl.rs`, `src/management.rs` | interactive installer and lifecycle manager | | `src/config.rs` | strict Xray-shaped JSON schema, validation, safe defaults | | `src/acme.rs` | HTTP-01 issuance, renewal backoff, atomic certificate activation | | `src/origin.rs` | hyper 1.x origin over in-tree TLS 1.3/H2, HTTP/1.1, and h2c; health routes | @@ -73,47 +74,120 @@ Per-protocol ground-truth maps and deployment notes live in [`docs/`](docs/). The non-XHTTP static fallback is documented in [`docs/static-fallback.md`](docs/static-fallback.md). -## Build & test +## One-command interactive installation + +The managed installer currently targets **x86_64 Linux with systemd**. Before +running it, point an A/AAAA record at the server and allow inbound TCP 443. The +recommended automatic-certificate mode also needs inbound TCP 80 for ACME +HTTP-01. Then run: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://github.com/jacek4yang/rust-xhttp/releases/latest/download/install.sh | sudo sh +``` + +The shell file is only a small bootstrap: it resolves one immutable GitHub +release, downloads the archive and its published SHA-256 file, verifies them, +then starts the **Rust `rust-xhttpctl` wizard** on the terminal. If you prefer to +inspect every privileged instruction first: ```bash -cargo build --release # → target/release/rust-xhttp -cargo test # unit + reorder + crypto + integration -cargo clippy --all-targets --all-features -- -D warnings -cargo fmt --all -- --check +curl --proto '=https' --tlsv1.2 -fLo install.sh \ + https://github.com/jacek4yang/rust-xhttp/releases/latest/download/install.sh +less install.sh +sudo sh install.sh ``` -Requires Rust 1.88+ (edition 2024; required for the security-fixed `time` dependency). +The wizard can configure: + +- automatic Let's Encrypt issuance and renewal, existing PEM files, or + plaintext behind Cloudflare/nginx/another TLS terminator; +- domain, listen address/port, generated or supplied UUID, randomized XHTTP + path, and optional `xtls-rprx-vision` flow; +- a generated customizable blog, or a copied/preloaded user `dist` directory; +- a dedicated unprivileged `rust-xhttp` account and a hardened, enabled systemd + service with only `CAP_NET_BIND_SERVICE`. + +It validates syntax and referenced resources before systemd starts the service. +PEM keys are copied with restricted permissions; custom site content is copied +under the service-owned state directory. Existing configuration is backed up. + +### Long-term management + +The installed `rust-xhttpctl` binary owns the complete lifecycle: + +| Task | Command | +| --- | --- | +| Interactive management menu | `sudo rust-xhttpctl manage` | +| Service status | `rust-xhttpctl status` | +| Follow the last 100 journal lines | `rust-xhttpctl logs` | +| Check files, config, systemd enablement and health | `rust-xhttpctl doctor` | +| Validate, edit, back up and atomically activate config | `sudo rust-xhttpctl edit` | +| Start/stop/restart | `sudo rust-xhttpctl service restart` | +| Verified update to the latest release | `sudo rust-xhttpctl update` | +| Install a specific release | `sudo rust-xhttpctl update v0.2.0` | +| Swap back to the previous binary set | `sudo rust-xhttpctl rollback` | +| Recreate permissions and the hardened unit | `sudo rust-xhttpctl repair` | +| Remove service/binaries but preserve config and data | `sudo rust-xhttpctl uninstall` | +| Remove service, config, ACME keys, site and rollback data | `sudo rust-xhttpctl uninstall --purge` | + +Updates are transactional: the manager verifies the archive checksum, checks +the new daemon against the installed config, retains the current daemon and +manager as one rollback set, restarts systemd, and restores the previous set if +activation fails. `edit` follows the same validate-before-restart rule and +restores its timestamped backup after a failed restart. + +Managed files use this stable layout: + +```text +/usr/local/bin/rust-xhttp # network daemon +/usr/local/bin/rust-xhttpctl # installer and lifecycle manager +/etc/rust-xhttp/config.json # Xray-shaped configuration +/etc/rust-xhttp/backups/ # configuration history +/etc/rust-xhttp/tls/ # copied manual PEM files +/var/lib/rust-xhttp/acme/ # ACME account, cert and renewal state +/var/lib/rust-xhttp/site/ # optional preloaded dist site +/var/lib/rust-xhttp-manager/ # update/rollback state (root-only) +``` + +Read the full [installation and management guide](docs/installation-management.md) +for firewall, reverse-proxy, recovery and trust details. Configuration mirrors +Xray's `inbounds/settings/streamSettings/xhttpSettings` layout; see the complete +[configuration guide](docs/configuration.md). + +### Existing config, manual release, or source build -Official Linux release binaries target `x86-64-v3` (Haswell/Zen or newer). Build -from source with an overridden `RUSTFLAGS` value for older CPUs; see -[`docs/production-hardening.md`](docs/production-hardening.md). +To install a reviewed existing config from an extracted release: -## Install +```bash +sudo ./rust-xhttpctl install \ + --server-binary ./rust-xhttp \ + --ctl-binary ./rust-xhttpctl \ + --config /path/to/config.json +``` -Download the current `x86_64-unknown-linux-gnu` archive from -[GitHub Releases](https://github.com/jacek4yang/rust-xhttp/releases/latest), or build locally: +To build both binaries yourself, install Rust 1.88+ and run: ```bash git clone https://github.com/jacek4yang/rust-xhttp.git cd rust-xhttp -cargo build --locked --release +cargo build --locked --release --bins +sudo target/release/rust-xhttpctl install \ + --server-binary target/release/rust-xhttp \ + --ctl-binary target/release/rust-xhttpctl ``` -## Run +Official Linux binaries target `x86-64-v3` (Haswell/Zen or newer). Older CPUs +must build from source with a compatible `RUSTFLAGS` target. The daemon remains +usable without systemd for containers or custom supervisors: ```bash -cp config.acme.example.json config.json # edit UUID, domain, path, and email -./target/release/rust-xhttp check config.json -sudo ./target/release/rust-xhttp config.json +rust-xhttp check /path/to/config.json +rust-xhttp /path/to/config.json ``` -The config mirrors Xray's `inbounds/settings/streamSettings/xhttpSettings` -layout. Direct TLS can use user-managed PEM files or built-in ACME HTTP-01 with -background renewal and atomic activation. Ordinary traffic is a generated, -customizable blog by default, or a preloaded user `dist` directory. Logging is -controlled by `log.loglevel` or `RUST_LOG`. See the complete -[configuration guide](docs/configuration.md) and -[performance/availability analysis](docs/performance-and-availability.md). +For development, run `cargo test`, `cargo clippy --all-targets -- -D warnings`, +and `cargo fmt --all -- --check`. ## Scope & non-claims @@ -137,6 +211,7 @@ welcome under [`CONTRIBUTING.md`](CONTRIBUTING.md). | --- | --- | --- | | Documentation index | [English](docs/index.md) | [简体中文](docs/index.zh-CN.md) | | Configuration and deployment | [English](docs/configuration.md) | [简体中文](docs/configuration.zh-CN.md) | +| Installation and management | [English](docs/installation-management.md) | [简体中文](docs/installation-management.zh-CN.md) | | Benchmarks and evidence | [English](docs/benchmarks.md) | [简体中文](docs/benchmarks.zh-CN.md) | | Performance and availability | [English](docs/performance-and-availability.md) | [简体中文](docs/performance-and-availability.zh-CN.md) | | Hotspot optimization report | [English](docs/performance-hotspots.md) | [简体中文](docs/performance-hotspots.zh-CN.md) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 05fda11..804f572 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,6 +52,7 @@ XHTTP (packet-up) + VLESS + VLESS-Encryption + xtls-rprx-vision + XUDP | 路径 | 职责 | | --- | --- | | `src/main.rs`, `src/runtime.rs` | 入口与协议栈装配 | +| `src/bin/rust-xhttpctl.rs`, `src/management.rs` | 交互式安装与生命周期管理 | | `src/config.rs` | 严格 Xray 风格 JSON、校验与安全默认值 | | `src/acme.rs` | HTTP-01 签发、续期退避与证书原子激活 | | `src/origin.rs` | 内置 TLS 1.3/H2、HTTP/1.1 与 h2c 的 hyper 源站 | @@ -63,30 +64,112 @@ XHTTP (packet-up) + VLESS + VLESS-Encryption + xtls-rprx-vision + XUDP | `src/xudp.rs` | XUDP 与普通 VLESS-UDP 编解码 | | `src/site.rs` | 自动生成博客或预加载用户 `dist` 网站 | -## 构建、安装与运行 +## 一条命令交互式安装 -需要 Rust 1.88+(edition 2024,确保使用已修复安全问题的 `time` 依赖)。官方 Linux 发布包使用 `x86-64-v3`,要求 -Haswell/Zen 或更新 CPU;旧 CPU 请参考 -[`docs/production-hardening.md`](docs/production-hardening.md) 从源码构建。 +托管安装器目前支持使用 systemd 的 **x86_64 Linux**。安装前请把域名 A/AAAA +记录指向服务器并放行 TCP 443;推荐的自动证书模式还需要放行 TCP 80,以完成 +ACME HTTP-01 验证。然后执行: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://github.com/jacek4yang/rust-xhttp/releases/latest/download/install.sh | sudo sh +``` + +这段 shell 只是很薄的引导程序:它锁定同一个不可变 GitHub Release,下载压缩包和 +Release 中公布的 SHA-256 文件并在本机校验,随后在当前终端启动 **Rust 编写的 +`rust-xhttpctl` 安装向导**。如果希望先审查全部提权指令: + +```bash +curl --proto '=https' --tlsv1.2 -fLo install.sh \ + https://github.com/jacek4yang/rust-xhttp/releases/latest/download/install.sh +less install.sh +sudo sh install.sh +``` + +交互向导支持: + +- Let's Encrypt 自动申请/续期、已有 PEM 证书,或位于 Cloudflare/nginx/其他 TLS + 终止器后的明文回源; +- 域名、监听地址与端口、自动生成或用户提供的 UUID、随机 XHTTP 路径,以及可选 + `xtls-rprx-vision` flow; +- 默认生成的可定制博客,或者复制并预加载用户指定的 `dist` 目录; +- 创建独立低权限 `rust-xhttp` 用户,以及仅保留 `CAP_NET_BIND_SERVICE` 的加固 + systemd service,并立即设置开机启动。 + +安装器会在 systemd 启动前校验 JSON 和引用资源。手动证书私钥会以受限权限复制; +自定义网站复制到服务自己的状态目录;现有配置会先备份。 + +### 长期管理 + +安装后的 `rust-xhttpctl` 覆盖完整生命周期: + +| 操作 | 命令 | +| --- | --- | +| 交互式管理菜单 | `sudo rust-xhttpctl manage` | +| 查看服务状态 | `rust-xhttpctl status` | +| 跟踪最近 100 行 journal 日志 | `rust-xhttpctl logs` | +| 检查文件、配置、systemd 启用与运行状态 | `rust-xhttpctl doctor` | +| 校验、编辑、备份并原子启用配置 | `sudo rust-xhttpctl edit` | +| 启动/停止/重启 | `sudo rust-xhttpctl service restart` | +| 校验并升级到最新 Release | `sudo rust-xhttpctl update` | +| 安装指定 Release | `sudo rust-xhttpctl update v0.2.0` | +| 切换回上一套二进制 | `sudo rust-xhttpctl rollback` | +| 修复权限和加固 systemd unit | `sudo rust-xhttpctl repair` | +| 删除服务和二进制,保留配置与数据 | `sudo rust-xhttpctl uninstall` | +| 连同配置、ACME 密钥、网站和回滚数据彻底删除 | `sudo rust-xhttpctl uninstall --purge` | + +升级是事务式的:管理器会校验压缩包 SHA-256,用新服务端预检当前配置,把现有服务端 +和管理器作为一套回滚版本保存,重启 systemd;如果启用失败,会自动恢复旧版本。 +`edit` 同样坚持“先校验、再重启”,重启失败时恢复带时间戳的配置备份。 + +托管安装使用固定目录: + +```text +/usr/local/bin/rust-xhttp # 网络服务端 +/usr/local/bin/rust-xhttpctl # 安装与生命周期管理器 +/etc/rust-xhttp/config.json # Xray 风格配置 +/etc/rust-xhttp/backups/ # 配置历史 +/etc/rust-xhttp/tls/ # 安装器复制的手动 PEM +/var/lib/rust-xhttp/acme/ # ACME 账户、证书与续期状态 +/var/lib/rust-xhttp/site/ # 可选的预加载 dist 网站 +/var/lib/rust-xhttp-manager/ # root-only 升级与回滚状态 +``` + +防火墙、反代、恢复和信任边界详见[安装与长期管理指南](docs/installation-management.zh-CN.md); +JSON 字段见[完整配置指南](docs/configuration.zh-CN.md)。 + +### 使用现有配置、手动 Release 或源码安装 + +从 Release 解压后,可以安装一份已经审查的配置: + +```bash +sudo ./rust-xhttpctl install \ + --server-binary ./rust-xhttp \ + --ctl-binary ./rust-xhttpctl \ + --config /path/to/config.json +``` + +也可以安装 Rust 1.88+ 后自己构建两个二进制: ```bash git clone https://github.com/jacek4yang/rust-xhttp.git cd rust-xhttp -cargo build --locked --release +cargo build --locked --release --bins +sudo target/release/rust-xhttpctl install \ + --server-binary target/release/rust-xhttp \ + --ctl-binary target/release/rust-xhttpctl +``` + +官方 Linux 包使用 `x86-64-v3`(Haswell/Zen 或更新 CPU);旧 CPU 要用适配的 +`RUSTFLAGS` 从源码构建。容器或自定义 supervisor 不必使用 systemd,可直接运行: -cp config.acme.example.json config.json -# 修改 UUID、域名、路径和邮箱 -./target/release/rust-xhttp check config.json -sudo ./target/release/rust-xhttp config.json +```bash +rust-xhttp check /path/to/config.json +rust-xhttp /path/to/config.json ``` -也可以从 [GitHub Releases](https://github.com/jacek4yang/rust-xhttp/releases/latest) -下载 `x86_64-unknown-linux-gnu` 压缩包。配置沿用 Xray 的 -`inbounds/settings/streamSettings/xhttpSettings` 结构。直连 TLS 可选用户 PEM 或 -内置 ACME HTTP-01 自动签发/续期;普通访问默认显示可定制的美观博客,也能预加载 -用户 `dist` 目录。日志由 `log.loglevel` 或 `RUST_LOG` 控制。完整教学见 -[配置指南](docs/configuration.zh-CN.md)与 -[性能/可用性分析](docs/performance-and-availability.zh-CN.md)。 +开发检查使用 `cargo test`、`cargo clippy --all-targets -- -D warnings` 和 +`cargo fmt --all -- --check`。 ## 支持范围与非声明 @@ -100,6 +183,7 @@ VLESS 协议。不支持 stream-up/stream-one,也不声称“不可检测” | --- | --- | --- | | 文档索引 | [English](docs/index.md) | [简体中文](docs/index.zh-CN.md) | | 配置与部署 | [English](docs/configuration.md) | [简体中文](docs/configuration.zh-CN.md) | +| 安装与长期管理 | [English](docs/installation-management.md) | [简体中文](docs/installation-management.zh-CN.md) | | Benchmark 与证据 | [English](docs/benchmarks.md) | [简体中文](docs/benchmarks.zh-CN.md) | | 性能与可用性 | [English](docs/performance-and-availability.md) | [简体中文](docs/performance-and-availability.zh-CN.md) | | 热点优化报告 | [English](docs/performance-hotspots.md) | [简体中文](docs/performance-hotspots.zh-CN.md) | diff --git a/docs/configuration.md b/docs/configuration.md index ec88a34..3da8d5c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,8 +68,8 @@ Requirements: - Public TCP port 80 must reach `challengeListen`; HTTP-01 cannot issue wildcard certificates. - The process needs permission to bind ports 80/443 and write `cacheDir`. The - supplied systemd unit grants `CAP_NET_BIND_SERVICE` and creates - `/var/lib/rust-xhttp` with mode `0700`. + managed systemd unit grants `CAP_NET_BIND_SERVICE`; `rust-xhttpctl` creates + the service-owned state directory and a mode-`0700` ACME directory. - `acceptTerms` is deliberately required. Setting it to `false` rejects the config. diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 7b816a1..c6ebfd9 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -64,8 +64,9 @@ UUID、XHTTP 路径和 Host。核心配置为: - `domains` 中所有域名必须已解析到本机; - 公网 TCP 80 必须能到达 `challengeListen`;HTTP-01 不支持通配符证书; -- 进程要有 80/443 端口绑定权限和 `cacheDir` 写权限。仓库提供的 systemd unit - 会授予 `CAP_NET_BIND_SERVICE`,并以 `0700` 创建 `/var/lib/rust-xhttp`; +- 进程要有 80/443 端口绑定权限和 `cacheDir` 写权限。托管 systemd unit 会授予 + `CAP_NET_BIND_SERVICE`;`rust-xhttpctl` 创建服务状态目录和权限为 `0700` 的 + ACME 目录; - 必须显式设置 `acceptTerms: true`,否则配置校验失败。 80 端口只在 `/.well-known/acme-challenge/` 返回 ACME token,其余 HTTP 请求永久 diff --git a/docs/index.md b/docs/index.md index 8ae0a54..95625d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,12 +6,16 @@ Start with the configuration guide: it covers installation, server and official Xray-client configuration, strict JSON fields, ACME/manual TLS, website fallback, VLESS-Encryption, verification, and troubleshooting. +For the one-command systemd wizard and ongoing updates, rollback, repair, and +uninstall, use the [installation and management guide](installation-management.md). + ## Operator guides | Guide | English | 简体中文 | | --- | --- | --- | | Project overview | [English](../README.md) | [简体中文](../README.zh-CN.md) | | Configuration and deployment | [English](configuration.md) | [简体中文](configuration.zh-CN.md) | +| Installation and management | [English](installation-management.md) | [简体中文](installation-management.zh-CN.md) | | Benchmarks and raw evidence | [English](benchmarks.md) | [简体中文](benchmarks.zh-CN.md) | | Performance and availability | [English](performance-and-availability.md) | [简体中文](performance-and-availability.zh-CN.md) | | Hotspot optimization report | [English](performance-hotspots.md) | [简体中文](performance-hotspots.zh-CN.md) | @@ -26,6 +30,7 @@ VLESS-Encryption, verification, and troubleshooting. - [Session resumption analysis](session-resumption-analysis.md) — explicit no-ticket policy. - [Security audit notes](security-audit.md) — implemented controls and open work. - [Production hardening](production-hardening.md) — systemd, CPU target, sockets, and memory. +- [Installation and management](installation-management.md) — wizard and lifecycle operations. - [Project guide](project-guide.md) — contributor workflow and module map. - [Geo routing](geo-routing.md) — currently unsupported and reserved for future work. diff --git a/docs/index.zh-CN.md b/docs/index.zh-CN.md index 3c962ef..ab7253b 100644 --- a/docs/index.zh-CN.md +++ b/docs/index.zh-CN.md @@ -5,12 +5,16 @@ 建议从配置指南开始:它包含安装、服务端与官方 Xray 客户端配置、严格 JSON、 ACME/手动证书、网站 fallback、VLESS-Encryption、验证和排错。 +一条命令 systemd 向导以及后续升级、回滚、修复和卸载见 +[安装与长期管理](installation-management.zh-CN.md)。 + ## 运维指南 | 指南 | English | 简体中文 | | --- | --- | --- | | 项目概览 | [English](../README.md) | [简体中文](../README.zh-CN.md) | | 配置与部署 | [English](configuration.md) | [简体中文](configuration.zh-CN.md) | +| 安装与长期管理 | [English](installation-management.md) | [简体中文](installation-management.zh-CN.md) | | Benchmark 与原始证据 | [English](benchmarks.md) | [简体中文](benchmarks.zh-CN.md) | | 性能与可用性 | [English](performance-and-availability.md) | [简体中文](performance-and-availability.zh-CN.md) | | 热点优化报告 | [English](performance-hotspots.md) | [简体中文](performance-hotspots.zh-CN.md) | @@ -24,6 +28,7 @@ ACME/手动证书、网站 fallback、VLESS-Encryption、验证和排错。 - [TLS 会话恢复分析](session-resumption-analysis.md) — 明确的不发 ticket 策略。 - [安全审计说明](security-audit.md) — 已实现控制与待完成工作。 - [生产加固](production-hardening.md) — systemd、CPU 目标、socket 与内存。 +- [安装与长期管理](installation-management.zh-CN.md) — 交互向导与生命周期运维。 - [项目指南](project-guide.md) — 贡献流程与模块图。 - [Geo 路由](geo-routing.md) — 当前不支持,为未来工作保留。 diff --git a/docs/installation-management.md b/docs/installation-management.md new file mode 100644 index 0000000..d6725f1 --- /dev/null +++ b/docs/installation-management.md @@ -0,0 +1,153 @@ +# Installation and Long-Term Management + +English | [简体中文](installation-management.zh-CN.md) + +`rust-xhttpctl` is the Rust administrative companion to the network daemon. It +is a separate binary, so installation dependencies and interactive code never +enter the server data path. + +## Preconditions + +- x86_64 Linux with systemd for the official managed package; +- a Haswell/Zen or newer CPU for the official `x86-64-v3` binary, or a local + source build for an older CPU; +- root access through `sudo`, plus `curl`, `tar`, and `sha256sum`; +- TCP 443 reachable from clients; +- for automatic certificates, public DNS already resolving to the server and + TCP 80 reachable by the ACME CA. + +The ACME HTTP-01 listener cannot share port 80 with an existing web server. Use +manual certificates or terminate TLS in the existing proxy in that situation. + +## Bootstrap and trust boundary + +```bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://github.com/jacek4yang/rust-xhttp/releases/latest/download/install.sh | sudo sh +``` + +The release copy of `install.sh` is stamped with its release tag. It downloads +only that tag's archive and checksum over HTTPS, verifies SHA-256, extracts only +`rust-xhttp` and `rust-xhttpctl`, rejects symlinks, then launches the Rust +installer using `/dev/tty`. SHA-256 detects corruption and mismatched assets; it +does not replace trust in the GitHub repository and release workflow. Review the +script and release provenance when that trust model is insufficient. + +The Rust wizard performs all persistent changes. It creates a system account, +copies files atomically, writes restrictive modes, validates configuration and +resources, installs the hardened unit, reloads systemd, and checks that the +enabled service becomes active. + +## Wizard choices + +### Automatic certificate + +Enter the public domain and contact email. The daemon owns account/certificate +state below `/var/lib/rust-xhttp/acme` and renews in the background with atomic +certificate activation. + +### Existing certificate + +Enter the source paths of the full chain and private key. The installer copies +them to `/etc/rust-xhttp/tls`; the service cannot read `/root` or home directories +because `ProtectHome=true`. + +### TLS proxy or CDN + +Choose plaintext mode when Cloudflare, nginx, or another trusted local component +terminates TLS. The default bind is `127.0.0.1:8080`. Authenticate and firewall +the origin if it must bind a non-loopback address. + +### Fallback site + +The default is a generated blog with configurable language and identity. +Selecting a `dist` directory copies regular files into +`/var/lib/rust-xhttp/site`; symlinks and special files are rejected. The daemon +preloads it for unauthenticated/non-XHTTP requests. The directory must contain +the configured `index.html`. + +## Service security model + +The unit runs as the non-login `rust-xhttp` user. It validates config with +`ExecStartPre`, grants only `CAP_NET_BIND_SERVICE`, makes system/config paths +read-only, allows writes only in `/var/lib/rust-xhttp`, hides homes/devices, +restricts address families, disables core dumps, and sets FD/memory limits. +Modify limits with a drop-in so `repair` can restore the canonical unit safely: + +```bash +sudo systemctl edit rust-xhttp +sudo systemctl daemon-reload +sudo systemctl restart rust-xhttp +``` + +## Configuration lifecycle + +`sudo rust-xhttpctl edit` edits a private copy using `$VISUAL`, `$EDITOR`, or +`vi`, validates syntax and resources, creates a timestamped backup below +`/etc/rust-xhttp/backups`, then atomically replaces the live file. If restart +fails, it restores the backup and attempts to bring the old service back. + +```bash +rust-xhttp check /etc/rust-xhttp/config.json +``` + +Relative resource paths resolve from `/var/lib/rust-xhttp`; absolute paths are +clearer in production. + +## Updates and rollback + +```bash +sudo rust-xhttpctl update # latest release +sudo rust-xhttpctl update v0.2.0 # selected release +sudo rust-xhttpctl rollback # previous binary pair +``` + +The manager allows HTTPS-only redirects, validates the tag/archive paths and +published SHA-256, checks both binary identities, and asks the downloaded server +to validate the installed config. It then saves the current daemon and manager +together, atomically replaces them, regenerates the unit, and restarts. Failed +activation restores both old binaries. One rollback generation is retained; a +successful rollback swaps the two sets so the operation can be reversed. + +Updates do not overwrite config, certificates, ACME state, or fallback content. + +## Operations and recovery + +```bash +rust-xhttpctl status +rust-xhttpctl logs +rust-xhttpctl doctor +sudo rust-xhttpctl service restart +sudo rust-xhttpctl repair +``` + +`doctor` is read-only and reports missing files, config/resource errors, and +systemd enablement/activity. `repair` recreates the account, directories, +ownership, and unit after validating installed binaries and config. It never +regenerates user config. + +For ACME failure, verify external DNS, public port 80 reachability, system time, +and whether another process owns the challenge port. For restart failure, run +`rust-xhttp check` and inspect `journalctl -u rust-xhttp` before changing files. + +## Uninstall and retained data + +`sudo rust-xhttpctl uninstall` disables the unit and removes both executables, +but preserves configuration, certificates, website, and state for reinstall. +`--purge` also removes those directories, root-only rollback data, ACME private +material, and the system account. Purge is irreversible; back up first. + +## Noninteractive/staged installation + +```bash +sudo rust-xhttpctl install \ + --server-binary ./rust-xhttp --ctl-binary ./rust-xhttpctl \ + --config ./config.json --yes + +rust-xhttpctl install --root /tmp/rust-xhttp-image --no-start \ + --server-binary ./rust-xhttp --ctl-binary ./rust-xhttpctl \ + --config ./config.acme.example.json --yes +``` + +The alternate root affects installed filesystem locations only; JSON and unit +contents retain their production absolute paths. diff --git a/docs/installation-management.zh-CN.md b/docs/installation-management.zh-CN.md new file mode 100644 index 0000000..bcc9d7f --- /dev/null +++ b/docs/installation-management.zh-CN.md @@ -0,0 +1,131 @@ +# 安装与长期管理 + +[English](installation-management.md) | 简体中文 + +`rust-xhttpctl` 是 Rust 编写的服务端管理器。它单独构建,因此安装依赖、交互和在线 +更新代码不会进入网络服务端的数据热路径。 + +## 安装前提 + +- 官方托管包要求使用 systemd 的 x86_64 Linux; +- 官方 `x86-64-v3` 二进制要求 Haswell/Zen 或更新 CPU,旧 CPU 要从源码构建; +- 能通过 `sudo` 获得 root 权限,并安装了 `curl`、`tar`、`sha256sum`; +- 客户端能访问 TCP 443; +- 自动证书模式要求公共 DNS 已指向服务器,且 ACME CA 能访问 TCP 80。 + +ACME HTTP-01 不能和已有 Web 服务共同占用 80 端口。这种情况应使用手动证书,或让 +已有反代终止 TLS。 + +## 引导程序与信任边界 + +```bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://github.com/jacek4yang/rust-xhttp/releases/latest/download/install.sh | sudo sh +``` + +Release 中的 `install.sh` 会写入固定标签,只下载同标签压缩包和校验文件。它强制 +HTTPS、校验 SHA-256、只解压 `rust-xhttp` 与 `rust-xhttpctl`、拒绝符号链接,再通过 +`/dev/tty` 启动 Rust 安装器。SHA-256 能发现传输损坏和资产错配,但不能替代对 GitHub +仓库和 Release 工作流的信任;供应链要求更高时,应先审查脚本和 Release 来源。 + +持久变更全部由 Rust 向导执行:创建系统用户、原子复制文件、设置受限权限、校验 +配置与资源、安装加固 unit、reload systemd,并确认设置开机启动后的服务为 active。 + +## 向导选项 + +### 自动证书 + +输入公共域名和联系邮箱。账户与证书保存在 `/var/lib/rust-xhttp/acme`,服务端后台 +续期并原子启用新证书。 + +### 已有证书 + +输入完整证书链和私钥的来源路径。安装器复制到 `/etc/rust-xhttp/tls`;由于 +`ProtectHome=true`,服务不会直接读取 `/root` 或普通用户 home。 + +### TLS 反代或 CDN + +由 Cloudflare、nginx 或其他可信本机组件终止 TLS 时选择明文模式,默认监听 +`127.0.0.1:8080`。必须监听非 loopback 地址时,应另行认证和限制源站防火墙。 + +### 回落网站 + +默认生成可配置语言和身份信息的博客。选择 `dist` 时,普通文件会复制到 +`/var/lib/rust-xhttp/site`,符号链接和特殊文件会被拒绝。服务端启动时预加载,并向 +未认证/非 XHTTP 请求展示;目录必须包含配置的 `index.html`。 + +## 服务安全模型 + +标准 unit 以不可登录的 `rust-xhttp` 用户运行,通过 `ExecStartPre` 预检配置,仅授予 +`CAP_NET_BIND_SERVICE`,系统和配置只读,仅允许写 `/var/lib/rust-xhttp`,隐藏 home +和设备、限制地址族、禁止 core dump,并设置 FD/内存限制。自定义限制请用 drop-in, +这样 `repair` 可以安全恢复标准 unit: + +```bash +sudo systemctl edit rust-xhttp +sudo systemctl daemon-reload +sudo systemctl restart rust-xhttp +``` + +## 配置生命周期 + +`sudo rust-xhttpctl edit` 使用 `$VISUAL`、`$EDITOR` 或 `vi` 编辑私有副本,校验语法和 +资源,在 `/etc/rust-xhttp/backups` 创建带时间戳备份,再原子替换线上文件。重启失败 +时会恢复备份并尝试拉起旧服务。 + +```bash +rust-xhttp check /etc/rust-xhttp/config.json +``` + +相对资源路径以 `/var/lib/rust-xhttp` 为基准;生产环境使用绝对路径更清晰。 + +## 升级与回滚 + +```bash +sudo rust-xhttpctl update # 最新 Release +sudo rust-xhttpctl update v0.2.0 # 指定 Release +sudo rust-xhttpctl rollback # 上一套二进制 +``` + +管理器只允许 HTTPS redirect,校验标签、压缩包路径和 Release SHA-256,检查两个 +二进制身份,并让下载的新服务端预检当前配置。随后把当前服务端和管理器作为一套保存, +原子替换、恢复标准 unit 并重启。启用失败会恢复两个旧二进制。系统保留一代回滚; +成功回滚会交换两套文件,因此可以反向切换。 + +升级不会覆盖配置、证书、ACME 状态和回落网站。 + +## 日常运维与恢复 + +```bash +rust-xhttpctl status +rust-xhttpctl logs +rust-xhttpctl doctor +sudo rust-xhttpctl service restart +sudo rust-xhttpctl repair +``` + +`doctor` 是只读检查,会报告文件缺失、配置/资源错误和 systemd 启用/运行状态。 +`repair` 在确认二进制和配置可用后重建用户、目录、属主与 unit,不会重新生成配置。 + +ACME 失败时应从外部检查 DNS、80 端口公网可达性、系统时间和 challenge 端口占用。 +重启失败时先运行 `rust-xhttp check` 并查看 `journalctl -u rust-xhttp`。 + +## 卸载与数据保留 + +`sudo rust-xhttpctl uninstall` 禁用 unit 并删除两个二进制,但保留配置、证书、网站和 +状态,以供重装复用。`--purge` 还会删除这些目录、root-only 回滚数据、ACME 私密 +材料和系统用户。彻底删除不可恢复,应先备份。 + +## 非交互/镜像安装 + +```bash +sudo rust-xhttpctl install \ + --server-binary ./rust-xhttp --ctl-binary ./rust-xhttpctl \ + --config ./config.json --yes + +rust-xhttpctl install --root /tmp/rust-xhttp-image --no-start \ + --server-binary ./rust-xhttp --ctl-binary ./rust-xhttpctl \ + --config ./config.acme.example.json --yes +``` + +替代 root 只影响文件实际安装位置;JSON 和 unit 内仍保留生产绝对路径。 diff --git a/docs/production-hardening.md b/docs/production-hardening.md index 9f25406..e8d5a1f 100644 --- a/docs/production-hardening.md +++ b/docs/production-hardening.md @@ -1,24 +1,33 @@ # Production Hardening -First complete the [configuration guide](configuration.md), then use the -systemd unit at `ops/systemd/rust-xhttp.service`: +The recommended local setup is the Rust +[interactive installer and manager](installation-management.md). For deploying +a locally built release to one or more already configured root SSH targets: ```bash ops/deploy.sh root@server ``` -The script atomically replaces the binary and restarts the service. It does not -modify the remote JSON, website, certificate, or private keys. +Each target must already contain `/etc/rust-xhttp/config.json`. The script sends +both locally built binaries and asks `rust-xhttpctl` to validate the existing +config, install the canonical unit, and restart. It does not replace website, +certificate, or ACME data. ## Remote layout ```text -/root/xhttp/ - config.json +/usr/local/bin/ rust-xhttp - rust-xhttp.old + rust-xhttpctl +/etc/rust-xhttp/ + config.json + backups/ + tls/ /var/lib/rust-xhttp/ - acme/ # only when automatic certificates are enabled + acme/ + site/ +/var/lib/rust-xhttp-manager/ + rollback/ # previous daemon + manager pair ``` ## Runtime notes @@ -35,9 +44,10 @@ modify the remote JSON, website, certificate, or private keys. system overhead. Do not increase concurrency limits independently. - `workers: 0`, `reusePort: true`, `backlog: 4096`, and 300-second keepalive are intended Linux defaults. Measure before changing worker count. -- Put a user `dist` directory under a service-readable path and keep it - immutable at runtime. ACME cache is the only normal runtime write path. -- Use `rust-xhttp check config.json` in deployment automation before restart. +- The service runs as a dedicated non-login user. Put custom `dist` content in + `/var/lib/rust-xhttp/site`; the installer copies and preloads it there. +- Use `rust-xhttp check /etc/rust-xhttp/config.json` in deployment automation + before restart. `ExecStartPre` enforces the same check in systemd. - Monitor SIGTERM drain, accept-pressure warnings, buffer/session rejections, target timeouts, certificate expiry, and ACME renewal failures. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a256ba2 --- /dev/null +++ b/install.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# Minimal bootstrap for the Rust rust-xhttpctl installer. The release archive and +# checksum are fetched from the same immutable GitHub release, verified locally, +# and then the Rust wizard performs every privileged installation step. +set -eu + +REPOSITORY="jacek4yang/rust-xhttp" +TARGET="x86_64-unknown-linux-gnu" +RELEASE_TAG="@RUST_XHTTP_TAG@" + +fail() { + printf 'rust-xhttp installer: %s\n' "$*" >&2 + exit 1 +} + +command -v curl >/dev/null 2>&1 || fail "curl is required" +command -v sha256sum >/dev/null 2>&1 || fail "sha256sum is required" +command -v tar >/dev/null 2>&1 || fail "tar is required" +command -v awk >/dev/null 2>&1 || fail "awk is required" +[ "$(uname -s)" = "Linux" ] || fail "managed installation currently supports Linux only" +[ "$(uname -m)" = "x86_64" ] || fail "official managed releases currently support x86_64 only" + +case "$RELEASE_TAG" in + @RUST_*) + effective_url=$(curl --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --fail --silent --show-error --location --output /dev/null \ + --write-out '%{url_effective}' \ + "https://github.com/$REPOSITORY/releases/latest") + RELEASE_TAG=${effective_url##*/} + ;; +esac +case "$RELEASE_TAG" in + v*) ;; + *) fail "GitHub returned an invalid release tag: $RELEASE_TAG" ;; +esac +case "${RELEASE_TAG#v}" in + ''|*[!0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.+-]*) + fail "GitHub returned an invalid release tag: $RELEASE_TAG" + ;; +esac + +temporary=$(mktemp -d "/tmp/rust-xhttp-install.XXXXXX") +case "$temporary" in + /tmp/rust-xhttp-install.*) ;; + *) fail "mktemp returned an unexpected path" ;; +esac +cleanup() { + rm -r -- "$temporary" +} +trap cleanup EXIT HUP INT TERM + +archive="rust-xhttp-$RELEASE_TAG-$TARGET.tar.gz" +base="https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG" +curl --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --fail --silent --show-error --location \ + --output "$temporary/$archive" "$base/$archive" +curl --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --fail --silent --show-error --location \ + --output "$temporary/$archive.sha256" "$base/$archive.sha256" + +( + cd "$temporary" + sha256sum --check "$archive.sha256" +) +prefix="rust-xhttp-$RELEASE_TAG-$TARGET" +members=$(tar --list --verbose --gzip --file "$temporary/$archive" \ + "$prefix/rust-xhttp" "$prefix/rust-xhttpctl") +printf '%s\n' "$members" | awk ' + substr($1, 1, 1) != "-" { exit 1 } + { count += 1 } + END { if (count != 2) exit 1 } +' || fail "release binaries must be unique regular files" +tar --extract --gzip --file "$temporary/$archive" --directory "$temporary" \ + --no-same-owner --no-same-permissions \ + "$prefix/rust-xhttp" "$prefix/rust-xhttpctl" + +server="$temporary/$prefix/rust-xhttp" +manager="$temporary/$prefix/rust-xhttpctl" +[ -f "$server" ] && [ ! -L "$server" ] || fail "release is missing rust-xhttp" +[ -f "$manager" ] && [ ! -L "$manager" ] || fail "release is missing rust-xhttpctl" +chmod 755 "$server" "$manager" + +printf '\nVerified rust-xhttp %s. Starting the Rust installation wizard...\n\n' "$RELEASE_TAG" +if [ "$(id -u)" -eq 0 ]; then + "$manager" install --server-binary "$server" --ctl-binary "$manager" /dev/tty +else + command -v sudo >/dev/null 2>&1 || fail "run this installer as root or install sudo" + sudo "$manager" install --server-binary "$server" --ctl-binary "$manager" /dev/tty +fi diff --git a/ops/deploy.sh b/ops/deploy.sh index efaedb5..ecc8687 100755 --- a/ops/deploy.sh +++ b/ops/deploy.sh @@ -1,15 +1,7 @@ #!/usr/bin/env bash -# Build rust-xhttp (optimized, x86_64) and deploy it to one or more remote -# hosts as a hardened systemd service. Idempotent: re-running upgrades the binary -# in place and restarts the service. -# -# Usage: -# ops/deploy.sh [ ...] -# -# Requirements on the REMOTE host (set up once, out of band): -# - /root/xhttp/config.json -# The script copies the freshly built binary and the systemd unit, (re)loads the -# unit, and enables + restarts the service. It does NOT touch your config or keys. +# Build and deploy both rust-xhttp binaries to one or more root SSH targets. +# Each target must already have /etc/rust-xhttp/config.json. The Rust manager +# validates that config, atomically installs the binaries, and manages systemd. set -euo pipefail cd "$(dirname "$0")/.." @@ -19,38 +11,45 @@ if [ "$#" -lt 1 ]; then exit 2 fi -UNIT="ops/systemd/rust-xhttp.service" -REMOTE_DIR="/root/xhttp" -SERVICE="rust-xhttp" +echo "==> building release binaries" +cargo build --locked --release --bins +test -x target/release/rust-xhttp +test -x target/release/rust-xhttpctl -echo "==> building release binary" -cargo build --release -BIN="target/release/$SERVICE" -test -x "$BIN" - -TMP_GZ="$(mktemp --suffix=.gz)" -trap 'rm -f "$TMP_GZ"' EXIT -gzip -c "$BIN" > "$TMP_GZ" +local_stage=$(mktemp -d) +case "$local_stage" in + /tmp/*) ;; + *) echo "unexpected temporary directory: $local_stage" >&2; exit 1 ;; +esac +trap 'rm -r -- "$local_stage"' EXIT +gzip -c target/release/rust-xhttp > "$local_stage/rust-xhttp.gz" +gzip -c target/release/rust-xhttpctl > "$local_stage/rust-xhttpctl.gz" for host in "$@"; do + remote_stage="/tmp/rust-xhttp-deploy-$$" echo "==> deploying to $host" - scp -q "$TMP_GZ" "$host:$REMOTE_DIR/$SERVICE.gz" - scp -q "$UNIT" "$host:/etc/systemd/system/$SERVICE.service" - ssh "$host" "SERVICE='$SERVICE' REMOTE_DIR='$REMOTE_DIR' bash -s" <<'REMOTE' + ssh "$host" "install -d -m 700 '$remote_stage'" + scp -q "$local_stage/rust-xhttp.gz" "$local_stage/rust-xhttpctl.gz" \ + "$host:$remote_stage/" + ssh "$host" "REMOTE_STAGE='$remote_stage' bash -s" <<'REMOTE' set -euo pipefail -cd "$REMOTE_DIR" -gunzip -c "$SERVICE.gz" > "$SERVICE.new" -rm -f "$SERVICE.gz" -[ -f "$SERVICE" ] && cp -f "$SERVICE" "$SERVICE.old" || true -mv -f "$SERVICE.new" "$SERVICE" -chmod +x "$SERVICE" -systemctl daemon-reload -systemctl enable "$SERVICE" >/dev/null 2>&1 || true -systemctl restart "$SERVICE" -sleep 2 -systemctl is-active --quiet "$SERVICE" && echo " [$(hostname)] active" || { echo " [$(hostname)] FAILED"; journalctl -u "$SERVICE" -n 20 --no-pager; exit 1; } -ss -tlnH '( sport = :443 )' | grep -q . && echo " [$(hostname)] listening on :443" || echo " [$(hostname)] WARNING: not listening on :443" +case "$REMOTE_STAGE" in + /tmp/rust-xhttp-deploy-[0-9]*) ;; + *) echo "unsafe remote staging path" >&2; exit 1 ;; +esac +cleanup() { rm -r -- "$REMOTE_STAGE"; } +trap cleanup EXIT +gzip -d "$REMOTE_STAGE/rust-xhttp.gz" +gzip -d "$REMOTE_STAGE/rust-xhttpctl.gz" +chmod 755 "$REMOTE_STAGE/rust-xhttp" "$REMOTE_STAGE/rust-xhttpctl" +test -f /etc/rust-xhttp/config.json +"$REMOTE_STAGE/rust-xhttpctl" install \ + --server-binary "$REMOTE_STAGE/rust-xhttp" \ + --ctl-binary "$REMOTE_STAGE/rust-xhttpctl" \ + --config /etc/rust-xhttp/config.json --yes +systemctl is-active --quiet rust-xhttp +echo " [$(hostname)] active" REMOTE done -echo "==> done. Tail logs with: ssh journalctl -u $SERVICE -f" +echo "==> done. Follow logs with: ssh rust-xhttpctl logs" diff --git a/ops/systemd/rust-xhttp.service b/ops/systemd/rust-xhttp.service index 76fb77e..3b01fb3 100644 --- a/ops/systemd/rust-xhttp.service +++ b/ops/systemd/rust-xhttp.service @@ -1,7 +1,4 @@ [Unit] -# Production systemd unit for rust-xhttp (x86_64 Linux). -# Install: see ops/deploy.sh, or copy to /etc/systemd/system/ and -# systemctl daemon-reload && systemctl enable --now rust-xhttp Description=rust-xhttp XHTTP/VLESS server Documentation=https://github.com/jacek4yang/rust-xhttp After=network-online.target @@ -11,16 +8,18 @@ StartLimitBurst=5 [Service] Type=simple -# The binary resolves its config path relative to the working directory. -WorkingDirectory=/root/xhttp -ExecStart=/root/xhttp/rust-xhttp config.json -Restart=always +User=rust-xhttp +Group=rust-xhttp +WorkingDirectory=/var/lib/rust-xhttp +ExecStartPre=/usr/local/bin/rust-xhttp check /etc/rust-xhttp/config.json +ExecStart=/usr/local/bin/rust-xhttp /etc/rust-xhttp/config.json +Restart=on-failure RestartSec=2s TimeoutStopSec=35s -StateDirectory=rust-xhttp -StateDirectoryMode=0700 +UMask=0027 LimitNOFILE=65536 +LimitCORE=0 MemoryHigh=1536M MemoryMax=2G StandardOutput=journal @@ -28,20 +27,23 @@ StandardError=journal SyslogIdentifier=rust-xhttp NoNewPrivileges=true -ProtectSystem=full -ProtectHome=false -ReadWritePaths=/root/xhttp /var/lib/rust-xhttp +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/etc/rust-xhttp +ReadWritePaths=/var/lib/rust-xhttp PrivateTmp=true +PrivateDevices=true +ProtectHostname=true ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true ProtectControlGroups=true RestrictSUIDSGID=true RestrictRealtime=true LockPersonality=true -AmbientCapabilities=CAP_NET_BIND_SERVICE -CapabilityBoundingSet=CAP_NET_BIND_SERVICE - -# Disable core dumps so key material is never written to disk. -LimitCORE=0 +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX [Install] WantedBy=multi-user.target diff --git a/scripts/check_docs.py b/scripts/check_docs.py index 931352b..e5e3483 100644 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -16,6 +16,7 @@ ("SECURITY.md", "SECURITY.zh-CN.md"), ("docs/index.md", "docs/index.zh-CN.md"), ("docs/configuration.md", "docs/configuration.zh-CN.md"), + ("docs/installation-management.md", "docs/installation-management.zh-CN.md"), ("docs/benchmarks.md", "docs/benchmarks.zh-CN.md"), ( "docs/performance-and-availability.md", diff --git a/scripts/gate.sh b/scripts/gate.sh index 223dc32..5b4595d 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -24,4 +24,7 @@ cargo clippy --all-targets -- -D warnings step "test" cargo test --quiet -- --test-threads=1 +step "installer" +scripts/m14_installer_smoke.sh + printf '\n\033[1;32mGATE PASSED\033[0m\n' diff --git a/scripts/m14_installer_smoke.sh b/scripts/m14_installer_smoke.sh new file mode 100755 index 0000000..315f7ae --- /dev/null +++ b/scripts/m14_installer_smoke.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Exercise the real Rust installer against an alternate root. No host users, +# /etc files, services, or sockets are touched. +set -euo pipefail +cd "$(dirname "$0")/.." + +cargo build --quiet --locked --bins +stage_root=$(mktemp -d -p /tmp rust-xhttp-installer-smoke.XXXXXX) +case "$stage_root" in + /tmp/rust-xhttp-installer-smoke.*) ;; + *) echo "unsafe staging path: $stage_root" >&2; exit 1 ;; +esac +cleanup() { rm -r -- "$stage_root"; } +trap cleanup EXIT + +target/debug/rust-xhttpctl install \ + --root "$stage_root" --no-start --yes \ + --config config.acme.example.json \ + --server-binary target/debug/rust-xhttp \ + --ctl-binary target/debug/rust-xhttpctl >/dev/null + +test -x "$stage_root/usr/local/bin/rust-xhttp" +test -x "$stage_root/usr/local/bin/rust-xhttpctl" +test -f "$stage_root/etc/rust-xhttp/config.json" +test -f "$stage_root/etc/systemd/system/rust-xhttp.service" +grep -q '^User=rust-xhttp$' "$stage_root/etc/systemd/system/rust-xhttp.service" +grep -q '^ExecStartPre=/usr/local/bin/rust-xhttp check ' \ + "$stage_root/etc/systemd/system/rust-xhttp.service" +"$stage_root/usr/local/bin/rust-xhttp" \ + check "$stage_root/etc/rust-xhttp/config.json" >/dev/null +"$stage_root/usr/local/bin/rust-xhttpctl" --version | grep -q '^rust-xhttpctl ' + +echo "managed installer staging smoke test passed" diff --git a/scripts/package_release.sh b/scripts/package_release.sh new file mode 100755 index 0000000..3ea9094 --- /dev/null +++ b/scripts/package_release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Build the release archive layout and standalone version-pinned bootstrap. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "usage: $0 [output-directory]" >&2 + exit 2 +fi +tag=$1 +output=${2:-release-dist} +case "$tag" in + v*) ;; + *) echo "release tag must start with v" >&2; exit 2 ;; +esac +case "${tag#v}" in + ''|*[!0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.+-]*) + echo "invalid release tag: $tag" >&2 + exit 2 + ;; +esac +test -x target/release/rust-xhttp +test -x target/release/rust-xhttpctl +mkdir -p "$output" +output_absolute=$(realpath "$output") +repository_absolute=$(pwd -P) +if [ "$output_absolute" = "$repository_absolute" ]; then + echo "refusing to write release assets over the repository root" >&2 + exit 2 +fi + +archive="rust-xhttp-${tag}-x86_64-unknown-linux-gnu" +mkdir "$output/$archive" +chmod 755 "$output/$archive" +install -m 755 target/release/rust-xhttp target/release/rust-xhttpctl \ + "$output/$archive/" +install -m 644 README.md README.zh-CN.md LICENSE config.example.json \ + config.acme.example.json "$output/$archive/" +sed "s/@RUST_XHTTP_TAG@/$tag/g" install.sh > "$output/install.sh" +chmod 755 "$output/install.sh" +cp "$output/install.sh" "$output/$archive/install.sh" +while IFS= read -r -d '' document; do + install -D -m 644 "$document" "$output/$archive/$document" +done < <(git ls-files -z --cached --others --exclude-standard docs) +archive_epoch=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct)} +tar -C "$output" --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@$archive_epoch" -czf "$output/$archive.tar.gz" "$archive" +( + cd "$output" + sha256sum "$archive.tar.gz" > "$archive.tar.gz.sha256" +) + +echo "$output/$archive.tar.gz" diff --git a/src/bin/rust-xhttpctl.rs b/src/bin/rust-xhttpctl.rs new file mode 100644 index 0000000..a88a0e4 --- /dev/null +++ b/src/bin/rust-xhttpctl.rs @@ -0,0 +1,11 @@ +//! Administrative companion for installing and managing rust-xhttp. + +#[path = "../management.rs"] +mod management; + +fn main() { + if let Err(error) = management::run() { + eprintln!("rust-xhttpctl: {error}"); + std::process::exit(1); + } +} diff --git a/src/management.rs b/src/management.rs new file mode 100644 index 0000000..70ab641 --- /dev/null +++ b/src/management.rs @@ -0,0 +1,1837 @@ +//! Installation and lifecycle management for the `rust-xhttpctl` companion binary. +//! +//! The network daemon deliberately does not depend on this module. Administrative +//! operations live in a separate binary so interactive prompts, release downloads, and +//! systemd orchestration cannot affect the server data path. + +use rand::RngCore; +use rust_xhttp::config::Config; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::env; +use std::error::Error; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +type Result = std::result::Result>; + +const SERVICE: &str = "rust-xhttp"; +const SERVICE_USER: &str = "rust-xhttp"; +const REPOSITORY: &str = "jacek4yang/rust-xhttp"; +const RELEASE_TARGET: &str = "x86_64-unknown-linux-gnu"; + +#[derive(Clone, Debug)] +struct Layout { + root: PathBuf, +} + +impl Layout { + fn system() -> Self { + Self { + root: PathBuf::from("/"), + } + } + + fn under(root: PathBuf) -> Result { + if !root.is_absolute() { + return Err(message("--root must be an absolute path")); + } + Ok(Self { root }) + } + + fn is_system(&self) -> bool { + self.root == Path::new("/") + } + + fn path(&self, absolute: &str) -> PathBuf { + debug_assert!(absolute.starts_with('/')); + if self.is_system() { + PathBuf::from(absolute) + } else { + self.root.join(absolute.trim_start_matches('/')) + } + } + + fn server_binary(&self) -> PathBuf { + self.path("/usr/local/bin/rust-xhttp") + } + + fn ctl_binary(&self) -> PathBuf { + self.path("/usr/local/bin/rust-xhttpctl") + } + + fn config_dir(&self) -> PathBuf { + self.path("/etc/rust-xhttp") + } + + fn config(&self) -> PathBuf { + self.path("/etc/rust-xhttp/config.json") + } + + fn unit(&self) -> PathBuf { + self.path("/etc/systemd/system/rust-xhttp.service") + } + + fn state_dir(&self) -> PathBuf { + self.path("/var/lib/rust-xhttp") + } + + fn manager_dir(&self) -> PathBuf { + self.path("/var/lib/rust-xhttp-manager") + } + + fn rollback_dir(&self) -> PathBuf { + self.manager_dir().join("rollback") + } +} + +#[derive(Debug)] +struct InstallOptions { + server_binary: PathBuf, + ctl_binary: PathBuf, + config_source: Option, + layout: Layout, + assume_yes: bool, + no_start: bool, +} + +#[derive(Debug)] +struct GeneratedConfig { + source: String, + host: String, + port: u16, + uuid: String, + path: String, + security: String, + manual_tls: Option<(PathBuf, PathBuf)>, + dist_source: Option, +} + +#[derive(Debug)] +struct FileRollback { + target: PathBuf, + backup: Option, + mode: u32, +} + +#[derive(Debug)] +struct SiteRollback { + target: PathBuf, + backup: Option, +} + +pub fn run() -> Result<()> { + let mut arguments = env::args_os().skip(1).collect::>(); + if arguments.is_empty() { + print_help(); + return Ok(()); + } + let command = arguments.remove(0); + match command.to_string_lossy().as_ref() { + "-h" | "--help" | "help" => { + print_help(); + Ok(()) + } + "-V" | "--version" | "version" => { + println!("rust-xhttpctl {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + "install" => install(parse_install_options(arguments)?), + "manage" => { + reject_arguments(&arguments)?; + interactive_manage() + } + "service" => service_command(arguments), + "status" => { + reject_arguments(&arguments)?; + service_action("status") + } + "logs" => { + reject_arguments(&arguments)?; + service_action("logs") + } + "doctor" => { + reject_arguments(&arguments)?; + doctor(&Layout::system()) + } + "edit" => { + reject_arguments(&arguments)?; + require_root()?; + edit_config(&Layout::system()) + } + "update" => update_command(arguments), + "rollback" => { + reject_arguments(&arguments)?; + require_root()?; + rollback(&Layout::system()) + } + "repair" => repair_command(arguments), + "uninstall" => uninstall_command(arguments), + other => Err(message(format!( + "unknown command {other:?}; run rust-xhttpctl --help" + ))), + } +} + +fn print_help() { + println!( + "rust-xhttpctl {version}\n\ + \n\ + Install and manage a rust-xhttp systemd deployment.\n\ + \n\ + USAGE:\n\ + rust-xhttpctl install [OPTIONS]\n\ + rust-xhttpctl manage\n\ + rust-xhttpctl service \n\ + rust-xhttpctl doctor\n\ + rust-xhttpctl edit\n\ + rust-xhttpctl update [vVERSION] [--force]\n\ + rust-xhttpctl rollback\n\ + rust-xhttpctl repair [--no-restart]\n\ + rust-xhttpctl uninstall [--purge] [--yes]\n\ + \n\ + INSTALL OPTIONS:\n\ + --server-binary PATH daemon binary to install\n\ + --ctl-binary PATH manager binary to install\n\ + --config PATH validated existing JSON instead of the wizard\n\ + --yes accept replacement prompts (requires --config)\n\ + --no-start install files without enabling/starting systemd\n\ + --root PATH stage into an alternate root (requires --no-start)\n\ + \n\ + Run mutating system commands with sudo. status, logs, and doctor are read-only.", + version = env!("CARGO_PKG_VERSION") + ); +} + +fn parse_install_options(arguments: Vec) -> Result { + let current = env::current_exe()?; + let sibling_server = current + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("rust-xhttp"); + let mut options = InstallOptions { + server_binary: sibling_server, + ctl_binary: current, + config_source: None, + layout: Layout::system(), + assume_yes: false, + no_start: false, + }; + let mut position = 0; + while position < arguments.len() { + match arguments[position].to_string_lossy().as_ref() { + "--server-binary" => { + position += 1; + options.server_binary = required_path(&arguments, position, "--server-binary")?; + } + "--ctl-binary" => { + position += 1; + options.ctl_binary = required_path(&arguments, position, "--ctl-binary")?; + } + "--config" => { + position += 1; + options.config_source = Some(required_path(&arguments, position, "--config")?); + } + "--root" => { + position += 1; + options.layout = Layout::under(required_path(&arguments, position, "--root")?)?; + } + "--yes" | "-y" => options.assume_yes = true, + "--no-start" => options.no_start = true, + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + unknown => return Err(message(format!("unknown install option {unknown:?}"))), + } + position += 1; + } + if options.assume_yes && options.config_source.is_none() && !options.layout.config().is_file() { + return Err(message( + "--yes requires --config unless an installed config already exists", + )); + } + if !options.layout.is_system() && !options.no_start { + return Err(message("--root requires --no-start")); + } + Ok(options) +} + +fn install(options: InstallOptions) -> Result<()> { + ensure_supported_platform()?; + if options.layout.is_system() { + require_root()?; + } + verify_binary(&options.server_binary, "rust-xhttp")?; + verify_binary(&options.ctl_binary, "rust-xhttpctl")?; + require_matching_versions(&options.server_binary, &options.ctl_binary, None)?; + + if options.layout.is_system() { + ensure_system_user()?; + } + prepare_directories(&options.layout)?; + + let (config_source, generated) = select_config(&options)?; + validate_config_source(&config_source)?; + + let mut file_rollbacks = Vec::new(); + let mut site_rollback = None; + if let Some(details) = &generated { + if let Some((certificate, private_key)) = &details.manual_tls { + file_rollbacks = install_manual_tls(&options.layout, certificate, private_key)?; + } + if let Some(source) = &details.dist_source { + match install_site(&options.layout, source) { + Ok(rollback) => site_rollback = Some(rollback), + Err(error) => { + restore_resources(&file_rollbacks, None)?; + return Err(error); + } + } + } + } + + let config_path = options.layout.config(); + let candidate = unique_path(&options.layout.config_dir(), ".config.candidate")?; + if let Err(error) = atomic_write(&candidate, config_source.as_bytes(), 0o640) { + restore_resources(&file_rollbacks, site_rollback.as_ref())?; + return Err(error); + } + if let Err(error) = validate_config_file(&candidate) { + let _ = fs::remove_file(&candidate); + restore_resources(&file_rollbacks, site_rollback.as_ref())?; + return Err(error); + } + if config_path.exists() { + backup_config(&options.layout, &config_path)?; + } + fs::rename(&candidate, &config_path)?; + sync_directory(&options.layout.config_dir())?; + copy_atomic( + &options.server_binary, + &options.layout.server_binary(), + 0o755, + )?; + copy_atomic(&options.ctl_binary, &options.layout.ctl_binary(), 0o755)?; + atomic_write(&options.layout.unit(), systemd_unit().as_bytes(), 0o644)?; + + if options.layout.is_system() { + apply_ownership(&options.layout)?; + validate_config_file(&config_path)?; + run_checked(Command::new("systemctl").arg("daemon-reload"))?; + if !options.no_start { + run_checked(Command::new("systemctl").arg("enable").arg(SERVICE))?; + run_checked(Command::new("systemctl").arg("restart").arg(SERVICE))?; + run_checked( + Command::new("systemctl") + .arg("is-active") + .arg("--quiet") + .arg(SERVICE), + )?; + } + } else { + validate_config_file(&config_path)?; + } + + println!("\nInstallation completed successfully."); + println!(" server: {}", options.layout.server_binary().display()); + println!(" manager: {}", options.layout.ctl_binary().display()); + println!(" config: {}", config_path.display()); + println!(" unit: {}", options.layout.unit().display()); + if let Some(details) = generated { + println!("\nClient connection values (store the UUID securely):"); + println!(" address/SNI: {}", details.host); + println!(" port: {}", details.port); + println!(" UUID: {}", details.uuid); + println!(" XHTTP path: {}", details.path); + println!(" security: {}", details.security); + } + if options.layout.is_system() && !options.no_start { + println!("\nManage it with: sudo rust-xhttpctl manage"); + println!("Follow logs with: rust-xhttpctl logs"); + } + Ok(()) +} + +fn select_config(options: &InstallOptions) -> Result<(String, Option)> { + if let Some(source) = &options.config_source { + let text = fs::read_to_string(source).map_err(|error| { + message(format!("cannot read config {}: {error}", source.display())) + })?; + validate_config_source(&text)?; + return Ok((text, None)); + } + if options.layout.config().is_file() + && (options.assume_yes + || confirm( + &format!( + "Reuse existing config {}?", + options.layout.config().display() + ), + true, + )?) + { + return Ok((fs::read_to_string(options.layout.config())?, None)); + } + let generated = interactive_config()?; + Ok((generated.source.clone(), Some(generated))) +} + +fn interactive_config() -> Result { + println!("rust-xhttp interactive installation\n"); + println!("TLS mode:"); + println!(" 1) Automatic Let's Encrypt certificate (recommended)"); + println!(" 2) Existing PEM certificate and key"); + println!(" 3) Plain HTTP behind Cloudflare/nginx/another TLS proxy"); + let mode = prompt("Choose TLS mode", Some("1"))?; + if !matches!(mode.as_str(), "1" | "2" | "3") { + return Err(message("TLS mode must be 1, 2, or 3")); + } + + let host = required_prompt("Public domain name", None)?; + let default_port = if mode == "3" { "8080" } else { "443" }; + let port = prompt("Listen port", Some(default_port))? + .parse::() + .map_err(|_| message("listen port must be an integer from 1 to 65535"))?; + if port == 0 { + return Err(message("listen port must not be zero")); + } + let default_listen = if mode == "3" { "127.0.0.1" } else { "0.0.0.0" }; + let listen = prompt("Listen address", Some(default_listen))?; + let uuid = prompt("VLESS UUID", Some(&random_uuid()))?; + let random_path = format!("/{}/", random_hex(8)); + let mut path = prompt("XHTTP path", Some(&random_path))?; + if !path.starts_with('/') { + path.insert(0, '/'); + } + if !path.ends_with('/') { + path.push('/'); + } + let email_label = prompt("Client label", Some("primary"))?; + let vision = confirm("Enable xtls-rprx-vision flow?", false)?; + let mut manual_tls = None; + + let mut stream = json!({ + "network": "xhttp", + "security": if mode == "3" { "none" } else { "tls" }, + "xhttpSettings": { + "path": path, + "host": host, + "scMaxEachPostBytes": 1_000_000, + "scMaxBufferedPosts": 30, + "sessionGraceSeconds": 30, + "noSSEHeader": false, + "serverMaxHeaderBytes": 8192, + "xPaddingBytes": "100-1000", + "uplinkDataPlacement": "body", + "uplinkDataKey": "" + } + }); + if mode == "1" { + let contact = required_prompt("ACME contact email", None)?; + stream.as_object_mut().unwrap().insert( + "tlsSettings".into(), + json!({ + "alpn": ["h2", "http/1.1"], + "acme": { + "domains": [host], + "email": contact, + "directoryUrl": "https://acme-v02.api.letsencrypt.org/directory", + "challengeListen": "0.0.0.0:80", + "cacheDir": "/var/lib/rust-xhttp/acme", + "renewBeforeDays": 30, + "renewCheckHours": 12, + "acceptTerms": true + } + }), + ); + } else if mode == "2" { + let cert = PathBuf::from(required_prompt("Certificate chain PEM path", None)?); + let key = PathBuf::from(required_prompt("Private key PEM path", None)?); + if !cert.is_file() || !key.is_file() { + return Err(message( + "certificate and private key must be existing files", + )); + } + manual_tls = Some((cert, key)); + stream.as_object_mut().unwrap().insert( + "tlsSettings".into(), + json!({ + "alpn": ["h2", "http/1.1"], + "certificates": [{ + "certificateFile": "/etc/rust-xhttp/tls/fullchain.pem", + "keyFile": "/etc/rust-xhttp/tls/privkey.pem" + }] + }), + ); + } + + let use_dist = confirm( + "Serve an existing dist directory instead of the generated blog?", + false, + )?; + let dist_source = if use_dist { + let source = PathBuf::from(required_prompt("Path to dist directory", None)?); + if !source.is_dir() { + return Err(message(format!("{} is not a directory", source.display()))); + } + Some(source) + } else { + None + }; + let fallback = if dist_source.is_some() { + json!({ + "mode": "directory", + "dist": "/var/lib/rust-xhttp/site", + "index": "index.html", + "maxFileBytes": 8388608, + "maxTotalBytes": 134217728 + }) + } else { + let language = prompt("Fallback blog language (en or zh-CN)", Some("en"))?; + let title = prompt("Fallback blog title (blank = generated)", Some(""))?; + let author = prompt("Fallback blog author (blank = generated)", Some(""))?; + let description = prompt("Fallback blog description (blank = generated)", Some(""))?; + json!({ + "mode": "builtin", + "index": "index.html", + "maxFileBytes": 8388608, + "maxTotalBytes": 134217728, + "site": { + "seed": host, + "title": title, + "author": author, + "description": description, + "language": language + } + }) + }; + + let value = json!({ + "log": { "loglevel": "info" }, + "inbounds": [{ + "tag": "vless-xhttp-in", + "listen": listen, + "port": port, + "protocol": "vless", + "settings": { + "clients": [{ + "id": uuid, + "email": email_label, + "flow": if vision { "xtls-rprx-vision" } else { "" } + }], + "decryption": "none" + }, + "streamSettings": stream + }], + "server": { + "workers": 0, + "tcpNodelay": true, + "reusePort": true, + "backlog": 4096, + "tcpKeepaliveSeconds": 300, + "gracefulShutdownSeconds": 30, + "limits": { + "maxSessions": 65536, + "maxPendingPacketsPerSession": 30, + "maxPendingBytesPerSession": 16777216, + "globalBufferBytes": 1073741824_u64, + "maxConcurrentTargetConns": 100000, + "handshakeTimeoutSeconds": 10, + "targetConnectSeconds": 10, + "udpAssociationIdleSeconds": 60 + } + }, + "fallback": fallback + }); + let mut source = serde_json::to_string_pretty(&value)?; + source.push('\n'); + validate_config_source(&source)?; + Ok(GeneratedConfig { + source, + host, + port, + uuid, + path, + security: if mode == "3" { + "none (TLS proxy)".into() + } else { + "tls".into() + }, + manual_tls, + dist_source, + }) +} + +fn validate_config_source(source: &str) -> Result { + Config::from_json_str(source).map_err(|error| message(format!("invalid config: {error}"))) +} + +fn validate_config_file(path: &Path) -> Result<()> { + let config = Config::load(path) + .map_err(|error| message(format!("invalid config {}: {error}", path.display())))?; + rust_xhttp::runtime::validate(&config).map_err(|error| { + message(format!( + "config resource validation failed for {}: {error}", + path.display() + )) + }) +} + +fn prepare_directories(layout: &Layout) -> Result<()> { + create_dir(&layout.config_dir(), 0o750)?; + create_dir(&layout.config_dir().join("backups"), 0o700)?; + create_dir(&layout.config_dir().join("tls"), 0o750)?; + create_dir(&layout.state_dir(), 0o750)?; + create_dir(&layout.state_dir().join("acme"), 0o700)?; + create_dir(&layout.state_dir().join("site"), 0o750)?; + create_dir(&layout.manager_dir(), 0o700)?; + if let Some(parent) = layout.unit().parent() { + create_dir(parent, 0o755)?; + } + if let Some(parent) = layout.server_binary().parent() { + create_dir(parent, 0o755)?; + } + Ok(()) +} + +fn install_manual_tls( + layout: &Layout, + certificate: &Path, + private_key: &Path, +) -> Result> { + let target = layout.config_dir().join("tls"); + create_dir(&target, 0o750)?; + let mut rollbacks = Vec::new(); + let fullchain = target.join("fullchain.pem"); + rollbacks.push(snapshot_file(layout, &fullchain, "fullchain", 0o644)?); + if let Err(error) = copy_atomic(certificate, &fullchain, 0o644) { + restore_resources(&rollbacks, None)?; + return Err(error); + } + let private_key_target = target.join("privkey.pem"); + match snapshot_file(layout, &private_key_target, "privkey", 0o640) { + Ok(rollback) => rollbacks.push(rollback), + Err(error) => { + restore_resources(&rollbacks, None)?; + return Err(error); + } + } + if let Err(error) = copy_atomic(private_key, &private_key_target, 0o640) { + restore_resources(&rollbacks, None)?; + return Err(error); + } + Ok(rollbacks) +} + +fn snapshot_file(layout: &Layout, target: &Path, label: &str, mode: u32) -> Result { + let backup = if target.is_file() { + let backup = unique_path( + &layout.config_dir().join("backups"), + &format!("{label}.pem"), + )?; + copy_atomic(target, &backup, 0o600)?; + println!("Previous {label} backup: {}", backup.display()); + Some(backup) + } else { + None + }; + Ok(FileRollback { + target: target.to_owned(), + backup, + mode, + }) +} + +fn install_site(layout: &Layout, source: &Path) -> Result { + let staging = unique_directory(&layout.manager_dir(), "site-new")?; + if let Err(error) = copy_site_tree(source, &staging, 0) { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + let destination = layout.state_dir().join("site"); + let backup = layout.manager_dir().join(format!( + "site-backup-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + )); + let previous = if destination.exists() { + fs::rename(&destination, &backup)?; + Some(backup.clone()) + } else { + None + }; + if let Err(error) = fs::rename(&staging, &destination) { + if previous.is_some() { + let _ = fs::rename(&backup, &destination); + } + return Err(error.into()); + } + println!("Installed static site from {}", source.display()); + if previous.is_some() { + println!("Previous static site backup: {}", backup.display()); + } + Ok(SiteRollback { + target: destination, + backup: previous, + }) +} + +fn restore_resources(files: &[FileRollback], site: Option<&SiteRollback>) -> Result<()> { + if let Some(site) = site { + if site.target.exists() { + fs::remove_dir_all(&site.target)?; + } + if let Some(backup) = &site.backup { + fs::rename(backup, &site.target)?; + } + } + for file in files.iter().rev() { + if let Some(backup) = &file.backup { + copy_atomic(backup, &file.target, file.mode)?; + } else { + remove_file_if_present(&file.target)?; + } + } + Ok(()) +} + +fn copy_site_tree(source: &Path, destination: &Path, depth: usize) -> Result<()> { + if depth > 32 { + return Err(message("dist directory nesting exceeds 32 levels")); + } + for entry in fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let target = destination.join(entry.file_name()); + if file_type.is_symlink() { + return Err(message(format!( + "dist directory contains unsupported symlink: {}", + entry.path().display() + ))); + } + if file_type.is_dir() { + fs::create_dir(&target)?; + fs::set_permissions(&target, fs::Permissions::from_mode(0o750))?; + copy_site_tree(&entry.path(), &target, depth + 1)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &target)?; + fs::set_permissions(&target, fs::Permissions::from_mode(0o640))?; + } else { + return Err(message(format!( + "dist directory contains unsupported file type: {}", + entry.path().display() + ))); + } + } + Ok(()) +} + +fn create_dir(path: &Path, mode: u32) -> Result<()> { + fs::create_dir_all(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + Ok(()) +} + +fn ensure_system_user() -> Result<()> { + let user_exists = Command::new("id") + .args(["-u", SERVICE_USER]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + let group_exists = Command::new("getent") + .args(["group", SERVICE_USER]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if user_exists { + if !group_exists { + return Err(message( + "existing rust-xhttp user has no matching group; refusing to repurpose it", + )); + } + let output = run_output(Command::new("getent").args(["passwd", SERVICE_USER]))?; + let record = String::from_utf8(output.stdout)?; + let fields = record.trim().split(':').collect::>(); + let managed_shell = fields + .get(6) + .is_some_and(|shell| shell.ends_with("/nologin") || shell.ends_with("/false")); + if fields.get(5) != Some(&"/var/lib/rust-xhttp") || !managed_shell { + return Err(message( + "an unmanaged rust-xhttp account already exists; choose a clean host account name", + )); + } + return Ok(()); + } + if group_exists { + return Err(message( + "an unmanaged rust-xhttp group already exists; refusing to repurpose it", + )); + } + let shell = ["/usr/sbin/nologin", "/sbin/nologin", "/bin/false"] + .into_iter() + .find(|path| Path::new(path).exists()) + .unwrap_or("/bin/false"); + run_checked( + Command::new("useradd") + .arg("--system") + .arg("--user-group") + .arg("--home-dir") + .arg("/var/lib/rust-xhttp") + .arg("--no-create-home") + .arg("--shell") + .arg(shell) + .arg(SERVICE_USER), + ) +} + +fn apply_ownership(layout: &Layout) -> Result<()> { + run_checked( + Command::new("chown") + .arg("-R") + .arg(format!("root:{SERVICE_USER}")) + .arg(layout.config_dir()), + )?; + run_checked( + Command::new("chown") + .arg("-R") + .arg(format!("{SERVICE_USER}:{SERVICE_USER}")) + .arg(layout.state_dir()), + )?; + run_checked( + Command::new("chown") + .arg("-R") + .arg("root:root") + .arg(layout.manager_dir()), + ) +} + +fn systemd_unit() -> String { + include_str!("../ops/systemd/rust-xhttp.service").to_owned() +} + +fn service_command(mut arguments: Vec) -> Result<()> { + if arguments.len() != 1 { + return Err(message( + "usage: rust-xhttpctl service ", + )); + } + service_action(&arguments.remove(0).to_string_lossy()) +} + +fn service_action(action: &str) -> Result<()> { + match action { + "status" => run_inherited( + Command::new("systemctl") + .arg("status") + .arg("--no-pager") + .arg("--full") + .arg(SERVICE), + ), + "logs" => run_inherited( + Command::new("journalctl") + .arg("--unit") + .arg(SERVICE) + .arg("--lines") + .arg("100") + .arg("--follow"), + ), + "start" | "stop" | "restart" | "enable" | "disable" => { + require_root()?; + run_checked(Command::new("systemctl").arg(action).arg(SERVICE)) + } + other => Err(message(format!("unsupported service action {other:?}"))), + } +} + +fn interactive_manage() -> Result<()> { + loop { + println!( + "\nrust-xhttp management\n\ + 1) Status\n\ + 2) Follow logs\n\ + 3) Diagnose installation\n\ + 4) Validate and edit config\n\ + 5) Restart service\n\ + 6) Start service\n\ + 7) Stop service\n\ + 8) Update to latest release\n\ + 9) Roll back previous binaries\n\ + 10) Repair systemd integration\n\ + 11) Uninstall (preserve data)\n\ + 0) Exit" + ); + match prompt("Select an action", Some("1"))?.as_str() { + "1" => service_action("status")?, + "2" => service_action("logs")?, + "3" => doctor(&Layout::system())?, + "4" => { + require_root()?; + edit_config(&Layout::system())?; + } + "5" => service_action("restart")?, + "6" => service_action("start")?, + "7" => service_action("stop")?, + "8" => { + require_root()?; + update(&Layout::system(), None, false)?; + } + "9" => { + require_root()?; + rollback(&Layout::system())?; + } + "10" => { + require_root()?; + repair(&Layout::system(), true)?; + } + "11" => { + require_root()?; + uninstall(&Layout::system(), false, false)?; + return Ok(()); + } + "0" => return Ok(()), + _ => println!("Unknown selection."), + } + } +} + +fn doctor(layout: &Layout) -> Result<()> { + let mut failures = 0; + failures += check_path("server binary", &layout.server_binary(), true); + failures += check_path("manager binary", &layout.ctl_binary(), true); + failures += check_path("configuration", &layout.config(), false); + failures += check_path("systemd unit", &layout.unit(), false); + if layout.config().is_file() { + match validate_config_file(&layout.config()) { + Ok(()) => println!("[ok] config syntax and resources"), + Err(error) => { + println!("[fail] config syntax/resources: {error}"); + failures += 1; + } + } + } + failures += check_command("service enabled", "systemctl", &["is-enabled", SERVICE]); + failures += check_command("service active", "systemctl", &["is-active", SERVICE]); + if failures == 0 { + println!("All installation checks passed."); + Ok(()) + } else { + Err(message(format!("{failures} installation check(s) failed"))) + } +} + +fn check_path(label: &str, path: &Path, executable: bool) -> usize { + let valid = fs::metadata(path).is_ok_and(|metadata| { + metadata.is_file() && (!executable || metadata.permissions().mode() & 0o111 != 0) + }); + if valid { + println!("[ok] {label}: {}", path.display()); + 0 + } else { + println!("[fail] {label}: {}", path.display()); + 1 + } +} + +fn check_command(label: &str, command: &str, arguments: &[&str]) -> usize { + let success = Command::new(command) + .args(arguments) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if success { + println!("[ok] {label}"); + 0 + } else { + println!("[fail] {label}"); + 1 + } +} + +fn edit_config(layout: &Layout) -> Result<()> { + let config = layout.config(); + validate_config_file(&config)?; + let temporary = unique_path(&layout.config_dir(), ".config.edit")?; + fs::copy(&config, &temporary)?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + let editor = env::var_os("VISUAL") + .or_else(|| env::var_os("EDITOR")) + .unwrap_or_else(|| OsString::from("vi")); + let status = Command::new(&editor).arg(&temporary).status()?; + if !status.success() { + let _ = fs::remove_file(&temporary); + return Err(message(format!("editor {:?} exited with {status}", editor))); + } + if let Err(error) = validate_config_file(&temporary) { + let _ = fs::remove_file(&temporary); + return Err(message(format!( + "edited config is invalid; installed config was not changed: {error}" + ))); + } + let backup = backup_config(layout, &config)?; + fs::rename(&temporary, &config)?; + fs::set_permissions(&config, fs::Permissions::from_mode(0o640))?; + sync_directory(&layout.config_dir())?; + apply_ownership(layout)?; + if let Err(error) = run_checked(Command::new("systemctl").arg("restart").arg(SERVICE)) { + copy_atomic(&backup, &config, 0o640)?; + apply_ownership(layout)?; + let _ = run_checked(Command::new("systemctl").arg("restart").arg(SERVICE)); + return Err(message(format!( + "restart failed; restored {}: {error}", + backup.display() + ))); + } + println!("Config validated, installed, and service restarted."); + println!("Backup: {}", backup.display()); + Ok(()) +} + +fn backup_config(layout: &Layout, source: &Path) -> Result { + let directory = layout.config_dir().join("backups"); + create_dir(&directory, 0o700)?; + let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let mut destination = directory.join(format!("config-{stamp}.json")); + if destination.exists() { + destination = directory.join(format!("config-{stamp}-{}.json", random_hex(3))); + } + copy_atomic(source, &destination, 0o600)?; + Ok(destination) +} + +fn update_command(arguments: Vec) -> Result<()> { + let mut tag = None; + let mut force = false; + for argument in arguments { + match argument.to_string_lossy().as_ref() { + "--force" => force = true, + "--help" | "-h" => { + println!("usage: rust-xhttpctl update [vVERSION] [--force]"); + return Ok(()); + } + value if tag.is_none() => tag = Some(value.to_owned()), + value => return Err(message(format!("unexpected update argument {value:?}"))), + } + } + require_root()?; + update(&Layout::system(), tag, force) +} + +fn update(layout: &Layout, requested_tag: Option, force: bool) -> Result<()> { + ensure_supported_platform()?; + let tag = match requested_tag { + Some(tag) => validate_tag(&tag)?, + None => latest_release_tag()?, + }; + let installed_version = binary_version(&layout.server_binary())?; + if !force && installed_version.split_whitespace().nth(1) == Some(tag.trim_start_matches('v')) { + println!("Already running {installed_version}; use --force to reinstall."); + return Ok(()); + } + println!("Downloading rust-xhttp {tag}..."); + let downloaded = download_release(layout, &tag)?; + validate_downloaded_release( + layout, + &downloaded.server, + &downloaded.ctl, + Some(tag.trim_start_matches('v')), + )?; + create_rollback(layout)?; + + let install_result = (|| -> Result<()> { + copy_atomic(&downloaded.server, &layout.server_binary(), 0o755)?; + copy_atomic(&downloaded.ctl, &layout.ctl_binary(), 0o755)?; + run_checked( + Command::new(layout.ctl_binary()) + .arg("repair") + .arg("--no-restart"), + )?; + run_checked(Command::new("systemctl").arg("restart").arg(SERVICE))?; + run_checked( + Command::new("systemctl") + .arg("is-active") + .arg("--quiet") + .arg(SERVICE), + ) + })(); + if let Err(error) = install_result { + restore_rollback(layout)?; + let _ = run_checked( + Command::new(layout.ctl_binary()) + .arg("repair") + .arg("--no-restart"), + ); + let _ = run_checked(Command::new("systemctl").arg("restart").arg(SERVICE)); + let _ = fs::remove_dir_all(&downloaded.temporary); + return Err(message(format!( + "update failed and previous binaries were restored: {error}" + ))); + } + let _ = fs::remove_dir_all(&downloaded.temporary); + println!("Updated successfully to {tag}."); + println!("Roll back with: sudo rust-xhttpctl rollback"); + Ok(()) +} + +struct DownloadedRelease { + temporary: PathBuf, + server: PathBuf, + ctl: PathBuf, +} + +fn latest_release_tag() -> Result { + let output = run_output( + Command::new("curl") + .arg("--proto") + .arg("=https") + .arg("--proto-redir") + .arg("=https") + .arg("--tlsv1.2") + .arg("--fail") + .arg("--silent") + .arg("--show-error") + .arg("--location") + .arg("--output") + .arg("/dev/null") + .arg("--write-out") + .arg("%{url_effective}") + .arg(format!("https://github.com/{REPOSITORY}/releases/latest")), + )?; + let url = String::from_utf8(output.stdout)?; + let tag = url + .trim_end_matches('/') + .rsplit('/') + .next() + .ok_or_else(|| message("GitHub latest release redirect did not contain a tag"))?; + validate_tag(tag) +} + +fn validate_tag(tag: &str) -> Result { + if tag.len() < 2 + || !tag.starts_with('v') + || !tag[1..].chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '+') + }) + { + return Err(message(format!("invalid release tag {tag:?}"))); + } + Ok(tag.to_owned()) +} + +fn download_release(layout: &Layout, tag: &str) -> Result { + create_dir(&layout.manager_dir(), 0o700)?; + let temporary = unique_directory(&layout.manager_dir(), "update")?; + let archive_name = format!("rust-xhttp-{tag}-{RELEASE_TARGET}.tar.gz"); + let archive = temporary.join(&archive_name); + let checksum = temporary.join(format!("{archive_name}.sha256")); + let base = format!("https://github.com/{REPOSITORY}/releases/download/{tag}"); + curl_download(&format!("{base}/{archive_name}"), &archive)?; + curl_download(&format!("{base}/{archive_name}.sha256"), &checksum)?; + verify_checksum(&archive, &checksum, &archive_name)?; + let prefix = format!("rust-xhttp-{tag}-{RELEASE_TARGET}"); + validate_archive_paths(&archive, &prefix)?; + validate_regular_archive_member(&archive, &format!("{prefix}/rust-xhttp"))?; + validate_regular_archive_member(&archive, &format!("{prefix}/rust-xhttpctl"))?; + let root = temporary.join(&prefix); + fs::create_dir(&root)?; + run_checked( + Command::new("tar") + .arg("--extract") + .arg("--gzip") + .arg("--file") + .arg(&archive) + .arg("--directory") + .arg(&temporary) + .arg("--no-same-owner") + .arg("--no-same-permissions") + .arg(format!("{prefix}/rust-xhttp")) + .arg(format!("{prefix}/rust-xhttpctl")), + )?; + let server = checked_release_file(&temporary, &root.join("rust-xhttp"))?; + let ctl = checked_release_file(&temporary, &root.join("rust-xhttpctl"))?; + Ok(DownloadedRelease { + temporary, + server, + ctl, + }) +} + +fn curl_download(url: &str, destination: &Path) -> Result<()> { + run_checked( + Command::new("curl") + .arg("--proto") + .arg("=https") + .arg("--proto-redir") + .arg("=https") + .arg("--tlsv1.2") + .arg("--fail") + .arg("--silent") + .arg("--show-error") + .arg("--location") + .arg("--output") + .arg(destination) + .arg(url), + ) +} + +fn verify_checksum(archive: &Path, checksum: &Path, archive_name: &str) -> Result<()> { + let source = fs::read_to_string(checksum)?; + let mut fields = source.split_whitespace(); + let expected = fields + .next() + .ok_or_else(|| message("release checksum file is empty"))?; + let named = fields + .next() + .ok_or_else(|| message("release checksum does not name its archive"))? + .trim_start_matches('*'); + if named != archive_name || expected.len() != 64 { + return Err(message("release checksum file has an unexpected format")); + } + let mut reader = BufReader::new(File::open(archive)?); + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let length = reader.read(&mut buffer)?; + if length == 0 { + break; + } + digest.update(&buffer[..length]); + } + let actual = format!("{:x}", digest.finalize()); + if !actual.eq_ignore_ascii_case(expected) { + return Err(message(format!( + "release SHA-256 mismatch: expected {expected}, got {actual}" + ))); + } + Ok(()) +} + +fn validate_archive_paths(archive: &Path, expected_root: &str) -> Result<()> { + let output = run_output( + Command::new("tar") + .arg("--list") + .arg("--gzip") + .arg("--file") + .arg(archive), + )?; + let listing = String::from_utf8(output.stdout)?; + for entry in listing.lines() { + let path = Path::new(entry); + let mut components = path.components(); + if components.next() != Some(Component::Normal(OsStr::new(expected_root))) + || components.any(|component| !matches!(component, Component::Normal(_))) + { + return Err(message(format!("unsafe release archive path {entry:?}"))); + } + } + Ok(()) +} + +fn validate_regular_archive_member(archive: &Path, member: &str) -> Result<()> { + let output = run_output( + Command::new("tar") + .arg("--list") + .arg("--verbose") + .arg("--gzip") + .arg("--file") + .arg(archive) + .arg(member), + )?; + let listing = String::from_utf8(output.stdout)?; + let lines = listing.lines().collect::>(); + if lines.len() != 1 || !lines[0].starts_with('-') { + return Err(message(format!( + "release member {member:?} must occur once as a regular file" + ))); + } + Ok(()) +} + +fn checked_release_file(temporary: &Path, path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(message(format!( + "release member {} is not a regular file", + path.display() + ))); + } + let canonical_temporary = temporary.canonicalize()?; + let canonical = path.canonicalize()?; + if !canonical.starts_with(canonical_temporary) { + return Err(message("release member escaped its temporary directory")); + } + Ok(canonical) +} + +fn validate_downloaded_release( + layout: &Layout, + server: &Path, + ctl: &Path, + expected_version: Option<&str>, +) -> Result<()> { + verify_binary(server, "rust-xhttp")?; + verify_binary(ctl, "rust-xhttpctl")?; + require_matching_versions(server, ctl, expected_version)?; + run_checked(Command::new(server).arg("check").arg(layout.config())) +} + +fn require_matching_versions( + server: &Path, + ctl: &Path, + expected_version: Option<&str>, +) -> Result<()> { + let server_output = binary_version(server)?; + let ctl_output = binary_version(ctl)?; + let server_version = server_output + .split_whitespace() + .nth(1) + .ok_or_else(|| message("rust-xhttp did not report a version number"))?; + let ctl_version = ctl_output + .split_whitespace() + .nth(1) + .ok_or_else(|| message("rust-xhttpctl did not report a version number"))?; + if server_version != ctl_version { + return Err(message(format!( + "daemon version {server_version} does not match manager version {ctl_version}" + ))); + } + if let Some(expected) = expected_version + && server_version != expected + { + return Err(message(format!( + "release tag requires version {expected}, but binaries report {server_version}" + ))); + } + Ok(()) +} + +fn create_rollback(layout: &Layout) -> Result<()> { + let staging = unique_directory(&layout.manager_dir(), "rollback-new")?; + copy_atomic(&layout.server_binary(), &staging.join("rust-xhttp"), 0o700)?; + copy_atomic(&layout.ctl_binary(), &staging.join("rust-xhttpctl"), 0o700)?; + atomic_write( + &staging.join("version.txt"), + format!("{}\n", binary_version(&layout.server_binary())?).as_bytes(), + 0o600, + )?; + let rollback = layout.rollback_dir(); + if rollback.exists() { + fs::remove_dir_all(&rollback)?; + } + fs::rename(staging, rollback)?; + Ok(()) +} + +fn restore_rollback(layout: &Layout) -> Result<()> { + let rollback = layout.rollback_dir(); + copy_atomic(&rollback.join("rust-xhttp"), &layout.server_binary(), 0o755)?; + copy_atomic(&rollback.join("rust-xhttpctl"), &layout.ctl_binary(), 0o755)?; + Ok(()) +} + +fn rollback(layout: &Layout) -> Result<()> { + let rollback_dir = layout.rollback_dir(); + let rollback_server = rollback_dir.join("rust-xhttp"); + let rollback_ctl = rollback_dir.join("rust-xhttpctl"); + if !rollback_server.is_file() || !rollback_ctl.is_file() { + return Err(message("no previous binary set is available")); + } + validate_downloaded_release(layout, &rollback_server, &rollback_ctl, None)?; + let current = unique_directory(&layout.manager_dir(), "rollback-current")?; + copy_atomic(&layout.server_binary(), ¤t.join("rust-xhttp"), 0o700)?; + copy_atomic(&layout.ctl_binary(), ¤t.join("rust-xhttpctl"), 0o700)?; + atomic_write( + ¤t.join("version.txt"), + format!("{}\n", binary_version(&layout.server_binary())?).as_bytes(), + 0o600, + )?; + + let result = (|| -> Result<()> { + restore_rollback(layout)?; + run_checked( + Command::new(layout.ctl_binary()) + .arg("repair") + .arg("--no-restart"), + )?; + run_checked(Command::new("systemctl").arg("restart").arg(SERVICE))?; + run_checked( + Command::new("systemctl") + .arg("is-active") + .arg("--quiet") + .arg(SERVICE), + ) + })(); + if let Err(error) = result { + copy_atomic(¤t.join("rust-xhttp"), &layout.server_binary(), 0o755)?; + copy_atomic(¤t.join("rust-xhttpctl"), &layout.ctl_binary(), 0o755)?; + let _ = run_checked( + Command::new(layout.ctl_binary()) + .arg("repair") + .arg("--no-restart"), + ); + let _ = run_checked(Command::new("systemctl").arg("restart").arg(SERVICE)); + let _ = fs::remove_dir_all(¤t); + return Err(message(format!( + "rollback failed; current binaries were restored: {error}" + ))); + } + fs::remove_dir_all(&rollback_dir)?; + fs::rename(current, rollback_dir)?; + println!("Rollback completed. The replaced version is now the next rollback target."); + Ok(()) +} + +fn repair_command(arguments: Vec) -> Result<()> { + let restart = match arguments.as_slice() { + [] => true, + [flag] if flag == "--no-restart" => false, + [flag] if flag == "--help" || flag == "-h" => { + println!("usage: rust-xhttpctl repair [--no-restart]"); + return Ok(()); + } + _ => return Err(message("usage: rust-xhttpctl repair [--no-restart]")), + }; + require_root()?; + repair(&Layout::system(), restart) +} + +fn repair(layout: &Layout, restart: bool) -> Result<()> { + ensure_system_user()?; + prepare_directories(layout)?; + validate_config_file(&layout.config())?; + verify_binary(&layout.server_binary(), "rust-xhttp")?; + verify_binary(&layout.ctl_binary(), "rust-xhttpctl")?; + atomic_write(&layout.unit(), systemd_unit().as_bytes(), 0o644)?; + apply_ownership(layout)?; + run_checked(Command::new("systemctl").arg("daemon-reload"))?; + run_checked(Command::new("systemctl").arg("enable").arg(SERVICE))?; + if restart { + run_checked(Command::new("systemctl").arg("restart").arg(SERVICE))?; + } + println!("systemd integration repaired successfully."); + Ok(()) +} + +fn uninstall_command(arguments: Vec) -> Result<()> { + let mut purge = false; + let mut assume_yes = false; + for argument in arguments { + match argument.to_string_lossy().as_ref() { + "--purge" => purge = true, + "--yes" | "-y" => assume_yes = true, + "--help" | "-h" => { + println!("usage: rust-xhttpctl uninstall [--purge] [--yes]"); + return Ok(()); + } + value => return Err(message(format!("unknown uninstall option {value:?}"))), + } + } + require_root()?; + uninstall(&Layout::system(), purge, assume_yes) +} + +fn uninstall(layout: &Layout, purge: bool, assume_yes: bool) -> Result<()> { + let question = if purge { + "Remove service, binaries, configuration, certificates, website data, and rollback data?" + } else { + "Remove the service and binaries while preserving configuration and data?" + }; + if !assume_yes && !confirm(question, false)? { + println!("Uninstall cancelled."); + return Ok(()); + } + let _ = Command::new("systemctl") + .args(["disable", "--now", SERVICE]) + .status(); + remove_file_if_present(&layout.unit())?; + let _ = Command::new("systemctl").arg("daemon-reload").status(); + let _ = Command::new("systemctl") + .args(["reset-failed", SERVICE]) + .status(); + remove_file_if_present(&layout.server_binary())?; + remove_file_if_present(&layout.ctl_binary())?; + if purge { + remove_directory_if_present(&layout.config_dir())?; + remove_directory_if_present(&layout.state_dir())?; + remove_directory_if_present(&layout.manager_dir())?; + let _ = Command::new("userdel").arg(SERVICE_USER).status(); + let _ = Command::new("groupdel").arg(SERVICE_USER).status(); + println!("rust-xhttp and all managed data were removed."); + } else { + println!("rust-xhttp binaries and service were removed."); + println!("Preserved config: {}", layout.config_dir().display()); + println!("Preserved state: {}", layout.state_dir().display()); + println!("Re-running the installer will reuse the preserved config."); + } + Ok(()) +} + +fn ensure_supported_platform() -> Result<()> { + if env::consts::OS != "linux" { + return Err(message( + "the managed installer currently supports Linux only", + )); + } + if env::consts::ARCH != "x86_64" { + return Err(message( + "official managed releases currently support x86_64 only; build from source for this architecture", + )); + } + Ok(()) +} + +fn require_root() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + return Err(message("this operation requires root; run it with sudo")); + } + Ok(()) +} + +fn verify_binary(path: &Path, expected_name: &str) -> Result<()> { + let metadata = fs::metadata(path) + .map_err(|error| message(format!("cannot access binary {}: {error}", path.display())))?; + if !metadata.is_file() { + return Err(message(format!("{} is not a file", path.display()))); + } + let output = run_output(Command::new(path).arg("--version"))?; + let version = String::from_utf8(output.stdout)?; + if version.split_whitespace().next() != Some(expected_name) { + return Err(message(format!( + "{} identified as {:?}, expected {expected_name}", + path.display(), + version.trim() + ))); + } + Ok(()) +} + +fn binary_version(path: &Path) -> Result { + let output = run_output(Command::new(path).arg("--version"))?; + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +fn atomic_write(destination: &Path, content: &[u8], mode: u32) -> Result<()> { + let parent = destination + .parent() + .ok_or_else(|| message(format!("{} has no parent", destination.display())))?; + let temporary = unique_path(parent, ".rust-xhttpctl-write")?; + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(content)?; + file.sync_all()?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(mode))?; + fs::rename(&temporary, destination)?; + sync_directory(parent)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn copy_atomic(source: &Path, destination: &Path, mode: u32) -> Result<()> { + let parent = destination + .parent() + .ok_or_else(|| message(format!("{} has no parent", destination.display())))?; + if !parent.is_dir() { + create_dir(parent, 0o755)?; + } + let temporary = unique_path(parent, ".rust-xhttpctl-copy")?; + let result = (|| -> Result<()> { + fs::copy(source, &temporary)?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(mode))?; + File::open(&temporary)?.sync_all()?; + fs::rename(&temporary, destination)?; + sync_directory(parent)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn sync_directory(path: &Path) -> Result<()> { + File::open(path)?.sync_all()?; + Ok(()) +} + +fn unique_path(parent: &Path, prefix: &str) -> Result { + for _ in 0..32 { + let candidate = parent.join(format!("{prefix}.{}.{}", std::process::id(), random_hex(6))); + if !candidate.exists() { + return Ok(candidate); + } + } + Err(message("failed to allocate a unique temporary path")) +} + +fn unique_directory(parent: &Path, prefix: &str) -> Result { + let path = unique_path(parent, prefix)?; + fs::create_dir(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + Ok(path) +} + +fn random_hex(bytes: usize) -> String { + let mut random = vec![0_u8; bytes]; + rand::thread_rng().fill_bytes(&mut random); + random.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn random_uuid() -> String { + let mut bytes = [0_u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15] + ) +} + +fn prompt(label: &str, default: Option<&str>) -> Result { + match default { + Some(value) if !value.is_empty() => print!("{label} [{value}]: "), + _ => print!("{label}: "), + } + io::stdout().flush()?; + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_owned(); + if answer.is_empty() { + Ok(default.unwrap_or_default().to_owned()) + } else { + Ok(answer) + } +} + +fn required_prompt(label: &str, default: Option<&str>) -> Result { + let answer = prompt(label, default)?; + if answer.is_empty() { + Err(message(format!("{label} must not be empty"))) + } else { + Ok(answer) + } +} + +fn confirm(label: &str, default: bool) -> Result { + let marker = if default { "Y/n" } else { "y/N" }; + let answer = prompt(&format!("{label} ({marker})"), Some(""))?; + if answer.is_empty() { + return Ok(default); + } + match answer.to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Err(message("answer must be yes or no")), + } +} + +fn required_path(arguments: &[OsString], position: usize, option: &str) -> Result { + arguments + .get(position) + .map(PathBuf::from) + .ok_or_else(|| message(format!("{option} requires a path"))) +} + +fn reject_arguments(arguments: &[OsString]) -> Result<()> { + if arguments.is_empty() { + Ok(()) + } else { + Err(message("unexpected arguments")) + } +} + +fn run_checked(command: &mut Command) -> Result<()> { + let description = format!("{command:?}"); + let status = command.status()?; + if status.success() { + Ok(()) + } else { + Err(message(format!("command failed ({status}): {description}"))) + } +} + +fn run_inherited(command: &mut Command) -> Result<()> { + run_checked(command) +} + +fn run_output(command: &mut Command) -> Result { + let description = format!("{command:?}"); + let output = command.output()?; + if output.status.success() { + Ok(output) + } else { + Err(message(format!( + "command failed ({}): {}: {}", + output.status, + description, + String::from_utf8_lossy(&output.stderr).trim() + ))) + } +} + +fn remove_file_if_present(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn remove_directory_if_present(path: &Path) -> Result<()> { + match fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn message(value: impl Into) -> Box { + io::Error::other(value.into()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alternate_root_only_prefixes_host_paths() { + let layout = Layout::under(PathBuf::from("/tmp/image")).unwrap(); + assert_eq!( + layout.server_binary(), + PathBuf::from("/tmp/image/usr/local/bin/rust-xhttp") + ); + assert!(systemd_unit().contains("ExecStart=/usr/local/bin/rust-xhttp ")); + } + + #[test] + fn release_tags_reject_path_and_option_injection() { + assert_eq!(validate_tag("v0.2.0").unwrap(), "v0.2.0"); + assert!(validate_tag("../../etc/passwd").is_err()); + assert!(validate_tag("--output=x").is_err()); + assert!(validate_tag("0.2.0").is_err()); + } + + #[test] + fn random_uuid_has_rfc4122_v4_shape() { + let value = random_uuid(); + assert_eq!(value.len(), 36); + assert_eq!(&value[14..15], "4"); + assert!(matches!(&value[19..20], "8" | "9" | "a" | "b")); + assert!(uuid::Uuid::parse_str(&value).is_ok()); + } + + #[test] + fn generated_unit_is_hardened_and_preflights_config() { + let unit = systemd_unit(); + assert!(unit.contains("User=rust-xhttp")); + assert!(unit.contains("ExecStartPre=/usr/local/bin/rust-xhttp check")); + assert!(unit.contains("NoNewPrivileges=true")); + assert!(unit.contains("CapabilityBoundingSet=CAP_NET_BIND_SERVICE")); + assert!(unit.contains("ProtectSystem=strict")); + } + + #[test] + fn checksum_verification_binds_digest_and_name() { + let root = unique_directory(&env::temp_dir(), "rust-xhttp-checksum-test").unwrap(); + let archive = root.join("release.tar.gz"); + let checksum = root.join("release.tar.gz.sha256"); + fs::write(&archive, b"release bytes").unwrap(); + let digest = format!("{:x}", Sha256::digest(b"release bytes")); + fs::write(&checksum, format!("{digest} release.tar.gz\n")).unwrap(); + verify_checksum(&archive, &checksum, "release.tar.gz").unwrap(); + + fs::write(&checksum, format!("{digest} another.tar.gz\n")).unwrap(); + assert!(verify_checksum(&archive, &checksum, "release.tar.gz").is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn site_copy_rejects_symlinks() { + use std::os::unix::fs::symlink; + + let root = unique_directory(&env::temp_dir(), "rust-xhttp-site-test").unwrap(); + let source = root.join("source"); + let destination = root.join("destination"); + fs::create_dir(&source).unwrap(); + fs::create_dir(&destination).unwrap(); + fs::write(source.join("index.html"), b"hello").unwrap(); + copy_site_tree(&source, &destination, 0).unwrap(); + assert_eq!(fs::read(destination.join("index.html")).unwrap(), b"hello"); + + symlink("index.html", source.join("link.html")).unwrap(); + assert!(copy_site_tree(&source, &destination, 0).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn manual_tls_and_site_changes_can_be_restored() { + let root = unique_directory(&env::temp_dir(), "rust-xhttp-resource-test").unwrap(); + let layout = Layout::under(root.join("image")).unwrap(); + prepare_directories(&layout).unwrap(); + + let certificate = root.join("new-cert.pem"); + let private_key = root.join("new-key.pem"); + fs::write(&certificate, b"new certificate").unwrap(); + fs::write(&private_key, b"new key").unwrap(); + fs::write( + layout.config_dir().join("tls/fullchain.pem"), + b"old certificate", + ) + .unwrap(); + fs::write(layout.config_dir().join("tls/privkey.pem"), b"old key").unwrap(); + let files = install_manual_tls(&layout, &certificate, &private_key).unwrap(); + assert_eq!( + fs::read(layout.config_dir().join("tls/fullchain.pem")).unwrap(), + b"new certificate" + ); + + fs::write(layout.state_dir().join("site/index.html"), b"old site").unwrap(); + let source_site = root.join("new-site"); + fs::create_dir(&source_site).unwrap(); + fs::write(source_site.join("index.html"), b"new site").unwrap(); + let site = install_site(&layout, &source_site).unwrap(); + restore_resources(&files, Some(&site)).unwrap(); + assert_eq!( + fs::read(layout.config_dir().join("tls/fullchain.pem")).unwrap(), + b"old certificate" + ); + assert_eq!( + fs::read(layout.config_dir().join("tls/privkey.pem")).unwrap(), + b"old key" + ); + assert_eq!( + fs::read(layout.state_dir().join("site/index.html")).unwrap(), + b"old site" + ); + fs::remove_dir_all(root).unwrap(); + } +}